Documentation

TestNod documentation

Learn how to set up TestNod and configure it to help you spot flaky tests, catch regressions, and see how performance changes over time.

Upload JUnit XML reports from any CI with curl

If your CI provider is not on the dedicated pages, the commands below work in any environment that runs a shell and has curl installed. Adapt the environment variable names to whatever your CI exposes.

The three curl commands

A single build is three calls: register the upload, send the JUnit file to the URL you get back, and finalize the run once every file is in.

# 1. Register the upload. The response includes a presigned URL to send the file to.
response=$(curl -sS -X POST https://app.testnod.com/integrations/test_runs/upload \
  -H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
  -F "test_run[metadata][build_id]=$BUILD_ID" \
  -F "test_run[metadata][branch]=$BRANCH" \
  -F "test_run[metadata][commit_sha]=$COMMIT_SHA" \
  -F "test_run[metadata][run_url]=$RUN_URL" \
  -F "tags[][value]=ci")

# 2. Upload the JUnit file to the presigned URL from that response.
upload_url=$(echo "$response" | grep -o '"presigned_url":"[^"]*"' | cut -d'"' -f4)
curl -sS -H "Content-Type: application/xml" \
  --upload-file path/to/junit.xml "$upload_url"

# 3. After the last file for this build is uploaded, finalize the run.
curl -sS -X POST https://app.testnod.com/integrations/test_runs/finalize \
  -H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
  -d "build_id=$BUILD_ID"

The first call does not carry the report itself; it records the upload and hands back a presigned URL that the PUT in step 2 sends the XML to. That URL expires five minutes after it is issued, so run the two calls back to back rather than splitting them across jobs. Run steps 1 and 2 once per JUnit file you produce (one call pair if your suite runs in a single process, more if you shard across parallel containers), and run finalize exactly once per build. Until finalize lands, the run sits in pending.

The full request and response format for each endpoint is in the Submission API reference.

What you need to provide

Variable Where it comes from
TESTNOD_PROJECT_TOKEN A CI secret you set, sourced from your project's settings page in TestNod.
BUILD_ID A unique-per-build identifier from your CI tool. Required. Use the build number, pipeline ID, or whatever your CI exposes that uniquely names this build. The same value goes on every upload and the matching finalize call.
BRANCH Your CI's branch variable, often $CI_BRANCH, $BRANCH_NAME, or $GIT_BRANCH.
COMMIT_SHA Your CI's commit variable, or $(git rev-parse HEAD) as a fallback.
RUN_URL The URL to the build in your CI tool. Skip if your CI does not expose one.

BUILD_ID is required on the upload. Branch, commit, run URL, and tags are optional but worth setting.

Run on failure too

Whatever construct your CI tool uses for "run this step regardless of earlier failures", use it. Skipping the upload on red builds is the most common day-one mistake, and it hides the runs you most want to inspect.

CI tool Always-run directive
GitHub Actions if: ${{ !cancelled() }}
GitLab CI after_script: or when: always
CircleCI when: always on the step
Jenkins post { always { ... } }
Buildkite allow_dependency_failure: true on the dependent step
Drone when: status: [success, failure]
TeamCity "Execute always" build step option

Inspecting the response from a script

The register call in step 1 is the one most likely to fail outright, since a bad token or a missing build_id is rejected with a 4xx. To log the response and surface a friendlier error to the build log, capture both body and status:

response=$(curl -sS -w "\n%{http_code}" -X POST https://app.testnod.com/integrations/test_runs/upload \
  -H "Project-Token: $TESTNOD_PROJECT_TOKEN" \
  -F "test_run[metadata][build_id]=$BUILD_ID" \
  -F "test_run[metadata][branch]=$BRANCH" \
  -F "test_run[metadata][commit_sha]=$COMMIT_SHA")
status=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')

if [ "$status" -ne 201 ]; then
  echo "TestNod upload failed (HTTP $status): $body" >&2
  exit 1
fi

This pattern is helpful when you want the CI build to fail loudly if the upload itself broke, rather than silently swallowing the error. The body it captures is the JSON you then read presigned_url from for step 2.

Finalize from a separate job

If your CI splits work across multiple jobs (one job per shard, one job per matrix axis), every shard uploads with the same BUILD_ID and a follow-up job calls finalize once. The finalize job needs only the TESTNOD_PROJECT_TOKEN secret and the same BUILD_ID; it does not need the artifact or the source tree.

Be first to try TestNod

We're opening early access soon. Drop your email and we'll get you in, and we're happy to help you set up too.

No spam. We'll only email you about TestNod.