Test visibility and Test Impact Analysis

Embed
Preview of the card
<a href="https://watchtowerops.net/docs/test-visibility"><img src="https://watchtowerops.net/docs/_api/public/orgs/aa5959cb-1a0b-4a9a-bc9a-913e7ac31cd9/spaces/b7aac181-9916-4805-b253-becc5fd2ca62/pages/8cf72512-b437-4a02-861e-67519b98731c/social-card.svg?v=3e7ae33eec8b" alt="Test visibility and Test Impact Analysis" width="600" height="315"></a>

Watchtower stores every test result your CI uploads. From those results it shows flaky tests (with quarantine), each test's history, the slowest tests, failures grouped by message, and per-branch or per-PR views. With per-test coverage it also tells CI which tests a change can skip (Test Impact Analysis, TIA).

Everything below uses the project API with an API key (WT_API_KEY, sent as Authorization: Bearer). Uploading results and coverage needs a key that can manage the project. Reading needs read access.

1. Send results

All frameworks end up at POST /v1/projects/{prj}/test-runs. This is a multipart upload of JUnit XML with the fields run_id, branch, sha, optional suite and optional coverage_pct. It accepts up to 20 MiB, and posting the same file again changes nothing. Test ids are suite::classname.name (suite::name when there is no class). Use the same suite name everywhere so ids stay stable.

Common environment for the examples (GitHub Actions):

env:
  WT_API_URL: https://watchtower.example.com
  WT_API_KEY: ${{ secrets.WT_API_KEY }}
  WT_PROJECT_ID: prj_...
  WT_CI_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}

Framework

Setup

Vitest

npm i -D @watchtowerobservability/node, then add reporters: ['default', '@watchtowerobservability/node/vitest'] to vitest.config.ts. With WT_CI_RUN_ID set, the reporter uploads JUnit itself (it also needs WT_INGEST_KEY, WT_PROJECT_ID and WT_API_URL).

Jest

Add reporters: ['default', '@watchtowerobservability/node/jest'] to jest.config.js. The environment variables are the same as for Vitest.

pytest

pip install watchtower-sdk. The plugin loads automatically. Run pytest --junitxml=junit.xml, then wtctl ci upload-junit junit.xml --suite pytest .... With --wt-test-optimization you also get quarantine and TIA.

go test

go test -json ./... | wtctl ci gotest --api "$WT_API_URL" --project "$WT_PROJECT_ID" --run-id "$WT_CI_RUN_ID" --sha "$GITHUB_SHA" --branch "$GITHUB_HEAD_REF". It converts the stream to JUnit, uploads it and fails the step like go test would. Ids are <import path>::<Test>.

cargo nextest

In .config/nextest.toml, set [profile.ci.junit] and path = "junit.xml". Then run cargo nextest run --profile ci and wtctl ci upload-junit target/nextest/ci/junit.xml --suite cargo ....

Playwright

Use reporter: [['list'], ['junit', { outputFile: 'results/junit.xml' }]], then wtctl ci upload-junit results/junit.xml --suite playwright .... Retries show up as attempts and feed flake detection.

Anything with JUnit XML (Maven Surefire, Gradle, xUnit, NUnit, PHPUnit, RSpec with rspec_junit_formatter, ...)

wtctl ci upload-junit <file.xml> --api "$WT_API_URL" --project "$WT_PROJECT_ID" --run-id "$WT_CI_RUN_ID" --sha "$GITHUB_SHA" --branch "$GITHUB_HEAD_REF" [--coverage 81.5]. With curl: curl -F file=@junit.xml -F run_id=$WT_CI_RUN_ID -F sha=$GITHUB_SHA -F branch=$GITHUB_HEAD_REF -H "Authorization: Bearer $WT_API_KEY" $WT_API_URL/v1/projects/$WT_PROJECT_ID/test-runs. Surefire flakyFailure/rerunFailure count as retries.

Upload in an if: always() step so that failing runs are recorded too.

2. Read results

What

API

UI (CI, then Tests)

Flaky tests: passed and failed on one commit, or 3 or more outcome flips in the last 20 runs. Includes flake rate, the pipelines they broke and quarantine.

GET /tests?flaky=true

Results

One test's attempts: status, duration, retry, branch, commit and failure message. Also shows the files the test executed at its last coverage record.

GET /tests/{suite::test}/history

click a test name

Slowest tests: p50, p95, max and the summed CI time

GET /tests/slowest

Slowest

Failures grouped by message. The first line is used, with numbers and ids replaced by #. Each group shows a sample, its affected tests and runs.

GET /tests/failures

Failures

Coverage trend

GET /tests/coverage

Every read takes from/to (RFC 3339, default the last 7 days) and branch. Set branch to a PR's head branch for the PR view, or to main for the default-branch view. In the UI, the branch filter applies to every view.

Flaky tests can be quarantined (POST /tests/{id}/quarantine with a ticket and expiry). A quarantined test still runs, but its failures do not fail the build when you use wtctl ci junit --respect-quarantine, the Vitest/Jest test plan or the pytest plugin. Auto-quarantine and early flake detection are set in PUT /tests/flaky-policy.

3. Test Impact Analysis

TIA skips tests whose recorded files a change does not touch. It works in two steps.

Record per-test coverage (on the default branch)

Run the full suite with coverage recording, for example nightly or on every push to main. Each recording replaces the previous one per test.

Framework

How coverage is recorded

pytest

pytest --wt-tia-record records the files each test executes, per test.

Vitest

Set coverage: { provider: 'istanbul', enabled: true }. In setupFiles, call vitestSetup({ beforeAll, afterEach, afterAll }) from @watchtowerobservability/node/test-plan. Then set WT_TIA_RECORD=1 and WT_API_KEY. Each test file's executed files (plus the file itself) are recorded for the tests in it that ran.

Jest

Run jest --coverage with testEnvironment set to the jestEnvironment(...) wrapper (see test-plan.ts) and WT_TIA_RECORD=1. Recording is per test file.

go test

Run wtctl tests coverage go ./... --api "$WT_API_URL" --project "$WT_PROJECT_ID". It runs each package with -coverpkg over the module, so files in other packages count too. Recording is per package.

Any tool that writes lcov with per-test TN: records

wtctl tests coverage upload --lcov coverage.lcov --suite <suite> ...

Anything else (cargo, JVM, custom)

Write {"suite::test": ["src/a.rs", ...]} and run wtctl tests coverage upload --map coverage.json ..., or call PUT /tests/coverage-map directly.

Paths must be repository-relative, the same paths git diff --name-only prints. wtctl converts absolute and working-directory-relative paths for you.

Select tests in a PR

- uses: actions/checkout@v4
  with: { fetch-depth: 0 }
- run: wtctl tests select --base origin/${{ github.base_ref }} --api "$WT_API_URL" --project "$WT_PROJECT_ID" --format skip > skip.txt

POST /v1/projects/{prj}/tia/select with {"changed_files": [...], "tests": [...]} returns {run_all, reasons, run, skip, unknown_files}. Feed the skip list to your runner. --format go-skip prints a go test -skip regexp: go test -skip "$(wtctl tests select ... --format go-skip)" ./.... The Vitest/Jest test plan and the pytest plugin apply the same decisions themselves when WT_TEST_OPTIMIZATION=1 and WT_GIT_BASE are set.

Safety rules. When any of these applies, every test runs and skip is empty:

  • The diff is empty or unknown.

  • No per-test coverage has been recorded yet.

  • A configuration, dependency or build file changed. This covers lockfiles, manifests (package.json, Cargo.toml, go.mod, pyproject.toml, ...), test-runner configuration (vitest.config.*, jest.config.*, conftest.py, pytest.ini, tsconfig*, ...), Makefile, Dockerfile, and .github/workflows/ and other CI configuration.

  • A changed file is one that no recorded test executed, such as a new module, a fixture, a data file or a rename. Documentation (*.md, *.rst, LICENSE, ...) is the exception.

Tests that were never recorded always run. So do tests recorded with no files. TIA does not handle disabled or quarantined tests. Those decisions come from POST /tests/plan.

Delivery is at-least-once: uploading results or coverage again is safe.

Updated