An API test that says only failed is not finished. It has detected a problem, but it has not helped the next developer decide whether the cause was a bad request, a slow dependency, a changed response, or a broken test fixture.
I have found a small receipt contract makes GitHub Actions workflows much easier to operate. Each test run gets a correlation ID, records the request shape without secrets, saves the response summary, and leaves a short artifact even when the assertion fails. The change takes minutes, and the debugging loop becomes much shorter.
Why a green API test can still be hard to debug
Most CI API checks produce a status and a few lines of console output. That is fine when everything is green. It is not enough when a test runs beside several deployments or when a provider returns an unexpected response.
The usual clues are scattered across logs:
- Which commit and workflow run sent the request?
- Was this a fresh test fixture or a reused one?
- Did the API reject the payload, or did the response arrive too late?
- Did a retry see the same result?
Without those facts, developers often rerun the job and hope the second result explains the first. That costs time and can hide a real race condition. A receipt gives the run an identity and puts the important evidence in one place.
Create a receipt contract before the request
Start with a tiny JSON document. Keep it safe to upload as a CI artifact:
{
"run_id": "local-abc123",
"endpoint": "/v1/verification",
"method": "POST",
"started_at": "2026-10-01T14:00:00Z",
"status": "pending"
}
Do not put authorization headers, full email addresses, tokens, or response bodies in this file. A redacted request fingerprint is normally enough to compare retries. For test flows that use a tempmailso fixture, store only an opaque fixture ID and its lifecycle state. Test email data should be temporary and non-production.
The run_id can combine the GitHub run number and commit:
RUN_ID="${GITHUB_RUN_ID:-local}-$(git rev-parse --short HEAD)"
RECEIPT="artifacts/api-receipt-${RUN_ID}.json"
mkdir -p artifacts
Write the initial receipt before making the request. If the process crashes during setup, that first record still shows where the attempt stopped. This detail are easy to skip, but it has saved me from guessing which stage ran.
Capture evidence in GitHub Actions
The workflow should always upload the receipt, including on failure. A simple shell shape looks like this:
set +e
node scripts/api-smoke-test.js --run-id "$RUN_ID" --receipt "$RECEIPT"
TEST_EXIT=$?
set -e
echo "test_exit=$TEST_EXIT" >> "$GITHUB_OUTPUT"
exit "$TEST_EXIT"
Then configure the artifact step with if: always():
- name: Upload API receipt
if: always()
uses: actions/upload-artifact@v4
with:
name: api-receipt-${{ github.run_id }}
path: artifacts/api-receipt-*.json
retention-days: 7
The receipt should finish with a small, stable set of fields: status, http_status, duration_ms, attempts, error_code, and finished_at. Save a hash or normalized summary instead of a secret-bearing body. The useful part are the timestamps and the error category, not a huge dump of data.
For asynchronous verification, record transitions such as fixture_created, request_sent, message_observed, and assertion_passed. This follows the same state-machine thinking for verification APIs that makes event-heavy flows easier to reason about.
Keep email fixtures isolated
Shared test inboxes are a frequent source of false positives. A message from an earlier run can satisfy a current assertion, while a legitimate delay looks like a missing message. Give each run a unique marker and match both the recipient fixture ID and that marker.
The marker should be safe to log, for example verify-${RUN_ID}. The test can poll with a deadline, then classify the result as message_timeout, wrong_fixture, or duplicate_message. Those categories are more actionable than assertion failed.
If a workflow uses a fake email address or searches for the typo-like phrases fake e mail com and tepm mail com in test data, keep those values in fixtures only. They should not become primary application data or appear in production analytics. It is a small boundary, but teams forget it when they copy a quick test into a shared helper.
Turn failure output into a fast next step
A receipt is useful only if someone can act on it. Print a one-line summary in the job log and keep detailed evidence in the artifact:
API receipt: run=1234-abc123 status=failed error=message_timeout attempts=3 duration_ms=30042
Use stable error codes. A reviewer can then search message_timeout across runs, compare duration trends, and distinguish a provider issue from a changed contract. It also make reruns easier to compare because the shape stays the same.
For request validation, pair the receipt with clear policy checks. The ideas in policy objects for signup checks apply outside React too: put rules in named, testable decisions instead of burying them in a long assertion.
A small receipt checklist
Before merging an API workflow, check that it:
- Creates a unique run ID before the first request.
- Writes a redacted receipt before setup can fail.
- Records request, response, timing, retry, and fixture state.
- Uploads the artifact with
if: always(). - Uses bounded retention and removes temporary fixtures.
- Exposes stable error codes without leaking secrets.
When the check fail, the receipt should tell you what happened and what to try next. That is the real productivity win: fewer blind reruns, clearer API ownership, and CI evidence that remains useful after the green checkmark is gone.
Top comments (0)