Testland
Browse all skills & agents

test-management-sync

Syncs automated test results into test management tools - TestRail (standalone, `add_run` + batched `add_results_for_cases`), Xray for Jira (JWT auth + `/api/v2/import/execution/*`), and Zephyr Scale (Bearer token + `/testexecutions`) - from CI. The body carries the vendor-independent push-results workflow (map tests to case IDs, open a run / execution / cycle per build, batch results back, close on main only, run as an `if: always()` step) with TestRail as the worked example; full vendor specifics live in references/ (testrail.md, xray.md, zephyr.md). Use when automated suites must keep the team's test management view in sync without a human copy-paste step; for hosted cross-run flakiness analytics rather than TCM sync use currents-integration, and for authoring / migrating test CASES rather than pushing results see qa-test-management's tcm-case-management.

Install with skills.sh (any agent)

npx skills add testland/qa --skill test-management-sync
View source

test-management-sync

Overview

Teams that manage test cases in a test management tool need automation result sync - without it, automated runs don't update the tool and the test-management view drifts from reality. The sync job has the same five-step shape across every vendor; only the auth model, the endpoints, and the case-ID scheme differ.

This skill wires that sync: the vendor-independent workflow below, TestRail as the worked example, and per-vendor API detail in references/.

Vendor routing

VendorShapeAuthResults APIReference
TestRail (Gurock / Idera)Standalone TCM (not a Jira app)Basic (email + API key)add_run + batched add_results_for_casesreferences/testrail.md
XrayJira app (Test / Test Execution issue types)Cloud: client_id+client_secret → 24h JWT; Server/DC: PAT/api/v2/import/execution/{junit,cucumber,nunit,testng,robot}references/xray.md
Zephyr Scale (SmartBear, formerly TM4J)Jira app (Test Cycles)Long-lived Bearer tokenPOST /testexecutions (or bulk /automations/executions/junit)references/zephyr.md

Routing rules:

  • Test management is standalone TestRailreferences/testrail.md.
  • Test management is a Jira app → Xray (references/xray.md) or Zephyr Scale (references/zephyr.md) - check which app the Jira instance has installed; Zephyr further disambiguates into Scale / Squad / Enterprise (the zephyr reference's variant table).
  • The team wants cross-run flakiness analytics, not case sync → currents-integration (different job entirely).

When to use

  • The team runs both manual and automated tests and the automated results need to land in the test management tool.
  • A CI pipeline must auto-create a run / execution / cycle per build and populate results so release management has the full picture.
  • The team manages test cases in the tool and wants per-case mapping back to the automated test (via case-ID labels in test names or annotations).

The push-results workflow (vendor-independent)

Every vendor sync follows the same five steps:

  1. Authenticate from CI secrets - never URL params, never logged (TestRail: Basic email+key; Xray Cloud: JWT exchange per run; Zephyr Scale: Bearer token).
  2. Map tests to case IDs - embed the vendor's case ID / issue key in the test name (test('adds to cart [C1234]')) or use an annotation (@XrayTest(key=...), @TestRailCase(id=...), @TestCaseKey(...)). Name-embedding is lowest-friction; either way the sync script must recover the ID from the result.
  3. Open a container per build - a TestRail Test Run, an Xray Test Execution, or a Zephyr Test Cycle - scoped to exactly the cases the automated suite covers, named <branch> · <sha-short>.
  4. Batch results back - one batched POST (or bounded-concurrency loop) rather than one call per test; every vendor has a rate limit that per-test posting trips on real suites.
  5. Close / finalize on main only - closed containers are read-only; PR runs stay open so flake-fix reruns can update them.

Run the sync as an if: always() CI step after the test step so failed runs still update the tool. The input is the runner's JUnit XML - parse it with junit-xml-analysis.

Worked example - TestRail

A Jest suite syncing one build to TestRail project 42, suite 7.

  1. Tests carry the case ID in the name (Step 2, name-embedding): test('can add to cart [C1234]', ...).
  2. Export credentials and run the suite to JUnit XML:
export TESTRAIL_HOST=https://acme.testrail.io
export TESTRAIL_USER=ci@acme.com
export TESTRAIL_API_KEY=...            # My Settings > API Keys
npm test -- --reporters=jest-junit
  1. The sync script opens a run scoped to the covered cases (include_all: False + explicit case_ids - without this a 5,000-case project produces a 5,000-row run of empty cells), then batches the results in one POST:
run_id = open_run(42, 7, "main · a1b2c3d", [1234])   # add_run → Run ID
add_results(run_id, [                                 # add_results_for_cases (batch)
    {'case_id': 1234, 'status_id': 1, 'comment': 'green on CI', 'elapsed': '12s'},
])

status_id: 1 is Passed per the stock convention (1 Passed / 2 Blocked / 3 Untested / 4 Retest / 5 Failed) - but read get_statuses at script init and verify every ID in your map exists, because TestRail accepts unknown IDs and writes to the wrong status silently. Full script, per-result fields, and CI wiring: references/testrail.md.

  1. Open the run in TestRail to see C1234 marked Passed with the 12s elapsed time. Close the run only when this is the main build (Step 5).

For the same build against Xray the container is a Test Execution fed by POST /api/v2/import/execution/junit?projectKey=... (references/xray.md); against Zephyr Scale it is a Test Cycle fed by POST /testexecutions (references/zephyr.md).

Anti-patterns (all vendors)

Anti-patternWhy it failsFix
Per-test result POSTsN API calls trip every vendor's rate limit (TestRail 180 req/min, Zephyr 60 req/min).Batch (TestRail add_results_for_cases, Xray format import) or bound concurrency (Zephyr).
Hard-coded status IDs / namesCustom statuses break the mapping silently.Fetch the status list at script init; build the map dynamically.
Auto-creating cases / issues from CIEvery renamed test creates a new case; the project fills with orphans.Pre-create cases; sync references existing IDs only.
Posting credentials as URL params or logging tokensSecrets leak in proxy logs / CI logs.Auth headers only; mask tokens (::add-mask::).
Closing every run, including PR runsClosed runs can't accept the rerun after a flake fix.Close / finalize only main runs.
One container reused across many buildsThe run accumulates noise; release sign-off is unreadable.One run / execution / cycle per build.
Sync step not if: always()Failed test runs - the ones release management most needs - never sync.if: always() after the test step.

Limitations

  • Cases must already exist in the tool. The sync maps results to existing case IDs; it doesn't author cases (that's qa-test-management's tcm-case-management).
  • The mapping lives in test names / annotations. Renames that drop the ID silently unmap the test - surface unmapped tests as warnings, never drop them (per-vendor references show how).
  • Vendor APIs differ in batch semantics. TestRail batches natively, Xray imports whole runner files, Zephyr Scale is per-execution with bounded concurrency - don't port one vendor's script shape blindly to another.

References

  • references/testrail.md - TestRail auth, case-ID mapping, add_run / add_results_for_cases / close_run, per-result fields, CI wiring, untested-case handling.
  • references/xray.md - Xray Cloud JWT auth, per-format import endpoints, @XrayTest / @Requirement mapping, testExecKey lifecycle, Playwright reporter.
  • references/zephyr.md - Zephyr Scale variant disambiguation, Bearer auth, Test Cycles, /testexecutions, bounded-concurrency batching, bulk JUnit import.
  • junit-xml-analysis - upstream parser for the input every sync script consumes.
  • currents-integration - different role: test analytics over time, not test management.

TestRail - vendor specifics

View source (opens in new window)

TestRail - vendor specifics

Reference detail for test-management-sync (opens in new window). TestRail (Gurock / Idera) is the standalone (non-Jira) test management tool; the body's worked example targets it. This page carries the full vendor detail: auth, case-ID mapping, run lifecycle, per-result fields, CI wiring, and untested-case handling.

Authentication

TestRail uses HTTP Basic auth with email + API key (preferred over password - the API key is per-user, revocable):

# Generated in TestRail: My Settings → API Keys
TESTRAIL_API_KEY=<generated>
TESTRAIL_USER=test-runner@example.com
TESTRAIL_HOST=https://yourcompany.testrail.io

All requests use:

Authorization: Basic <base64(email:api_key)>
Content-Type: application/json

The base API URL is ${TESTRAIL_HOST}/index.php?/api/v2. Every endpoint is appended after ?/api/v2.

Map test names to TestRail case IDs

Two common patterns:

Pattern A - Embed case ID in test name

def test_C1234_can_add_to_cart():
    ...

A regex extracts C1234 (the TestRail case ID) at sync time.

Pattern B - Annotation / metadata

@Test
@TestRailCase(id = 1234)
void canAddToCart() { ... }

Or in JS:

test('can add to cart [C1234]', async () => {
  // ...
});

Pattern A is the lowest-friction; Pattern B is cleaner when the test framework supports custom annotations. Either way, the sync script needs a way to find the case ID from the test result.

Open a Test Run for the build

# scripts/testrail_sync.py
import base64, json, requests
from os import environ as env

API = f"{env['TESTRAIL_HOST']}/index.php?/api/v2"
AUTH = base64.b64encode(f"{env['TESTRAIL_USER']}:{env['TESTRAIL_API_KEY']}".encode()).decode()
HEADERS = {'Authorization': f'Basic {AUTH}', 'Content-Type': 'application/json'}

def open_run(project_id, suite_id, name, case_ids):
    r = requests.post(
        f'{API}/add_run/{project_id}',
        headers=HEADERS,
        json={
            'suite_id': suite_id,
            'name': name,
            'include_all': False,
            'case_ids': case_ids,
        },
    )
    r.raise_for_status()
    return r.json()['id']  # Run ID

include_all: False + case_ids: [...] opens a run scoped to the exact cases the automated suite covers. Without this, a 5,000-case project produces a 5,000-row Test Run with thousands of empty cells.

Batch results back

The well-known status ID convention for stock TestRail installations:

StatusID
Passed1
Blocked2
Untested3
Retest4
Failed5

Custom status IDs (added by the project admin) follow 6+. Read the get_statuses endpoint at sync-script init to confirm - don't hard-code.

Verify before batching: call get_statuses and assert every status_id in your map appears in the returned set. If one is missing (a renamed or custom status), fix the map and re-run rather than posting - TestRail accepts an unknown status_id and writes the result to the wrong status silently.

def add_results(run_id, results):
    """results = [{'case_id': 1234, 'status_id': 1, 'comment': '...', 'elapsed': '12s'}]"""
    r = requests.post(
        f'{API}/add_results_for_cases/{run_id}',
        headers=HEADERS,
        json={'results': results},
    )
    r.raise_for_status()
    return r.json()

Use add_results_for_cases (batch), not add_result_for_case (per-case). A 200-test run is one POST instead of 200 POSTs; TestRail's rate limit (180 req/min on shared cloud) makes per-case posting flaky.

Per-result fields

Fields accepted on each entry in the add_results_for_cases results array:

FieldUse
case_idRequired. The TestRail case ID.
status_idRequired. Per the status-ID convention above.
commentThe test framework's failure message + stack trace.
elapsedFormat: '1h 30m 45s' or '45s'. Optional.
versionBuild version / commit SHA. Searchable in the UI.
defectsComma-separated Jira / GitHub issue keys.
assignedto_idAuto-assign failures to a specific user.

Close the run

After all results are in:

def close_run(run_id):
    requests.post(f'{API}/close_run/{run_id}', headers=HEADERS)

Closed runs are read-only - no further results can be added. Useful for release-stamp runs; skip for runs that get re-run.

Wire into a CI pipeline

- name: Run tests
  run: npm test -- --reporters=jest-junit
  env:
    JEST_JUNIT_OUTPUT_FILE: junit.xml

- name: Sync to TestRail
  if: always()
  env:
    TESTRAIL_HOST: ${{ secrets.TESTRAIL_HOST }}
    TESTRAIL_USER: ${{ secrets.TESTRAIL_USER }}
    TESTRAIL_API_KEY: ${{ secrets.TESTRAIL_API_KEY }}
    TESTRAIL_PROJECT_ID: '42'
    TESTRAIL_SUITE_ID: '7'
    BUILD_VERSION: ${{ github.sha }}
  run: python scripts/testrail_sync.py junit.xml

The sync script:

  1. Parses junit.xml (see junit-xml-analysis).
  2. Extracts case IDs from test names.
  3. Opens a run named <branch> · <sha-short>.
  4. Batches results.
  5. Optionally closes the run - typically only on main.

Handling untested case IDs

Tests that have no TestRail case ID (case removed; new test; intentional sync-skip) need explicit handling:

unmapped = [t for t in tests if extract_case_id(t['name']) is None]
if unmapped:
    print(f"Warning: {len(unmapped)} tests have no TestRail case ID:")
    for t in unmapped:
        print(f"  - {t['name']}")

Don't silently drop unmapped tests - they're candidates for either new TestRail cases or naming-pattern fixes.

TestRail-specific anti-patterns

Anti-patternWhy it failsFix
Per-test add_result_for_case callsN API calls; rate limit (180 req/min on Cloud) trips on suites >180 cases.add_results_for_cases batch.
Hard-coded status IDs without get_statusesCustom statuses break the mapping; "Failed" goes to "Custom Status" silently.Fetch get_statuses at script init; build the map dynamically.
include_all: True on add_runThe run includes every case in the suite, most as Untested; runs become noise.include_all: False + explicit case_ids: [...].
Posting credentials as URL paramsSecrets leak in proxy logs.Always Basic auth header.
No retry on 5xxTestRail Cloud has occasional 502s; one transient failure loses the whole run.Retry with exponential backoff on 5xx; cap at 3 attempts.
Closing every run, including PR runsClosed runs can't accept reruns; a PR retest after fixing flake fails to update.Close only main runs; PR runs stay open.
Storing case IDs in test code AND in TestRailTwo sources of truth; renames drift.TestRail is canonical; test code references via ID only (Pattern A).

Limitations

  • Test cases must already exist in TestRail. Pre-create cases manually or via add_case before the first sync; the sync script doesn't create cases on the fly.
  • Per-step results require a different endpoint. custom_step_results is per-installation; the project admin must enable a custom field for steps before the API accepts step-level data.
  • Rate limits. Cloud installations cap at 180 req/min per IP. Plan batching accordingly.
  • TestRail Cloud auth is per-user, not per-app. OAuth / SSO is not supported for API access; the API key is the only mechanism.
  • No first-party JUnit XML import. TestRail's own JUnit importer is a separate (deprecated) tool; the sync-script approach here is the modern path.

References

  • TestRail official documentation portal: https://support.testrail.com/hc/en-us/categories/7080117421716 (categorized KB; per-endpoint articles).
  • TestRail API Reference: https://support.testrail.com/hc/en-us/sections/7077986539540 (Results / Runs / Cases / Statuses endpoints).
  • Per-language client libraries: testrail (Python), testrail-java-client (Java), testrail-api (JS) - reference implementations of the API shapes above.

Xray for Jira - vendor specifics

View source (opens in new window)

Xray for Jira - vendor specifics

Reference detail for test-management-sync (opens in new window). Xray exposes test cases / executions as Jira issue types (Test, Test Set, Test Plan, Test Execution, Pre-Condition) - automated test sync writes results into Test Execution issues.

Xray comes in two flavors:

FlavorAuthImport endpoints
Xray Cloudclient_id + client_secret → JWThttps://xray.cloud.getxray.app/api/v2/import/...
Xray Server / DCJira PAT or Basic authhttps://<jira>/rest/raven/2.0/import/...

This page covers the Cloud flow as the primary path; Server notes are inline.

The official documentation is at docs.getxray.app. At the time of authoring (2026-05-05), the Cloud import-results page was 403 to automated WebFetch; the URL is the canonical reference for real-browser navigation. The well-known endpoints + payload shapes below are documented in the official xray-junit-extensions GitHub repo (xray-junit-ext (opens in new window)) and the xray-postman-collections public collections, both first-party Xray-App tools.

How to use

  1. Determine the flavor - Xray Cloud vs Server / DC - since it sets the auth model and import host (table above).
  2. Authenticate: Cloud exchanges client_id + client_secret for a 24h JWT; Server / DC uses a Jira PAT or Basic auth (Step 1).
  3. Pick the import endpoint that matches your runner's output format (/junit, /cucumber, /testng, /nunit, /robot, or generic JSON) (Step 2).
  4. Pin each test method to an existing Test issue with @XrayTest(key=...) (and @Requirement for coverage) so renames don't orphan issues (Step 3).
  5. Emit Xray-extended output - JVM via xray-junit-extensions (Step 5), JavaScript via the official Playwright reporter (below).
  6. POST the results with projectKey=..., adding testExecKey=... to update an existing Test Execution instead of creating a new one (Step 7).
  7. Wire it as an if: always() CI step (End-to-end CI shape below).

Step 1 - Authenticate (Cloud)

Per the xray-junit-ext (opens in new window) reference, Cloud auth is a two-step flow:

# 1. Exchange credentials for a JWT
JWT=$(curl -X POST 'https://xray.cloud.getxray.app/api/v2/authenticate' \
  -H 'Content-Type: application/json' \
  -d '{"client_id": "'"$XRAY_CLIENT_ID"'", "client_secret": "'"$XRAY_CLIENT_SECRET"'"}' \
  | tr -d '"')

# 2. Use the JWT in subsequent requests
curl -X POST 'https://xray.cloud.getxray.app/api/v2/import/execution/junit' \
  -H "Authorization: Bearer $JWT" \
  -H 'Content-Type: application/xml' \
  --data-binary @junit.xml

The JWT has a 24-hour validity (well-documented across Xray API clients); refresh per CI run.

For Xray Server / DC, use a Jira PAT or Basic auth instead; the JWT step is skipped.

Step 2 - Pick the import endpoint per format

Test runner outputCloud endpoint
JUnit XML (Maven, Gradle, Jest, pytest, etc.)/api/v2/import/execution/junit
Cucumber JSON/api/v2/import/execution/cucumber
TestNG XML/api/v2/import/execution/testng
NUnit XML (.NET)/api/v2/import/execution/nunit
xUnit XML (.NET)/api/v2/import/execution/xunit
Robot Framework XML/api/v2/import/execution/robot
Generic JSON (Xray-shape)/api/v2/import/execution

Each endpoint accepts the format's native output and parses it server-side; no per-test pre-mapping is needed.

For the generic JSON endpoint, payload shape:

{
  "info": {
    "summary": "CI run for ABC-123",
    "description": "Automated regression run",
    "user": "ci-runner",
    "version": "1.4.5",
    "revision": "abc1234",
    "testPlanKey": "PROJ-100",
    "testEnvironments": ["staging", "chrome"]
  },
  "tests": [
    {
      "testKey": "PROJ-1234",
      "start": "2026-05-05T14:00:00Z",
      "finish": "2026-05-05T14:00:12Z",
      "comment": "Test passed cleanly",
      "status": "PASSED"
    }
  ]
}

Step 3 - Map test methods to Xray Test issues

Per xray-junit-ext (opens in new window), the JUnit 5/6 extension provides two annotations:

@XrayTest

"enforce mapping of result to specific, existing Test identified by issue key, using the key attribute" (xray-junit-ext (opens in new window))

@Test
@XrayTest(key = "CALC-1000")
public void canAddNumbers() { /* ... */ }

Without @XrayTest, the extension auto-creates a Test issue per JUnit method on first run (auto-provisioning). Pinning with key prevents drift across renames.

@Requirement

"identify the covered requirement(s) ... it's possible to identify one covered issue or more" (xray-junit-ext (opens in new window))

@Test
@Requirement("CALC-1234")
public void canAddNumbers() { /* ... */ }

This populates the Jira-side coverage link from the test back to the requirement issue.

Step 4 - XrayTestReporter for evidence

Per xray-junit-ext (opens in new window), the XrayTestReporterParameterResolver extension injects an XrayTestReporter into test methods:

"Add comments to Test Runs / Define Test Run custom field values / Attach evidence files" (xray-junit-ext (opens in new window))

@Test
@ExtendWith(XrayTestReporterParameterResolver.class)
@XrayTest(key = "CALC-1000")
public void canAddNumbers(XrayTestReporter reporter) {
    // ... test logic ...
    reporter.addComment("Calculator returned correct sum");
    reporter.addEvidence("screenshot.png");
}

Evidence files are attached to the Test Run inside the Test Execution issue - useful for failure debugging from Jira.

Step 5 - Configure the extension

Per xray-junit-ext (opens in new window), the extension reads xray-junit-extensions.properties for output config:

# xray-junit-extensions.properties (place on the test classpath)
report_filename=TEST-results
report_directory=target/xray-reports
add_timestamp_to_report_filename=false

The output is JUnit XML augmented with Xray-specific metadata; pass this enriched XML to the import endpoint (Step 2).

Step 6 - End-to-end CI shape

# .github/workflows/xray-sync.yml
- name: Run tests with Xray-aware JUnit reporter
  run: ./mvnw -B verify
  # Produces target/xray-reports/TEST-results.xml

- name: Get Xray JWT
  id: xray_auth
  env:
    XRAY_CLIENT_ID: ${{ secrets.XRAY_CLIENT_ID }}
    XRAY_CLIENT_SECRET: ${{ secrets.XRAY_CLIENT_SECRET }}
  run: |
    JWT=$(curl -s -X POST 'https://xray.cloud.getxray.app/api/v2/authenticate' \
      -H 'Content-Type: application/json' \
      -d '{"client_id":"'"$XRAY_CLIENT_ID"'","client_secret":"'"$XRAY_CLIENT_SECRET"'"}' \
      | tr -d '"')
    echo "::add-mask::$JWT"
    echo "jwt=$JWT" >> "$GITHUB_OUTPUT"

- name: Import to Xray
  if: always()
  run: |
    curl -X POST 'https://xray.cloud.getxray.app/api/v2/import/execution/junit?projectKey=CALC' \
      -H "Authorization: Bearer ${{ steps.xray_auth.outputs.jwt }}" \
      -H 'Content-Type: application/xml' \
      --data-binary @target/xray-reports/TEST-results.xml

projectKey (the Jira project key) is the critical query param; without it the import fails or lands in the wrong project.

Verify after the POST: assert a 2xx whose body carries the Test Execution issue key the import created or updated. A 4xx (bad projectKey, expired JWT, or malformed XML) or a body with no key means nothing landed - fix that cause and re-run; retry once on a 5xx (transient), and never treat a non-2xx as success.

Non-JVM teams: Playwright reporter

Per the Xray-App GitHub org, the playwright-junit-reporter (opens in new window) project ships a Playwright reporter that emits Xray-compatible JUnit XML:

// playwright.config.ts
reporter: [
  ['list'],
  ['@xray-app/playwright-junit-reporter', {
    outputFile: 'target/xray-reports/results.xml',
  }],
],

Then the same import endpoint consumes the output.

Step 7 - Test Execution issue lifecycle

By default, each import creates a new Test Execution issue. For "update an existing execution per build" (e.g. one execution per release branch), pass testExecKey=PROJ-XYZ in the query string:

POST /api/v2/import/execution/junit?projectKey=CALC&testExecKey=CALC-9999

Pattern:

  • PR runs: new Test Execution per push (lots of issues; auto-archive via Jira workflow after PR merge).
  • Release runs: one Test Execution per release branch; updated on every push (uses testExecKey).

Xray-specific anti-patterns

Anti-patternWhy it failsFix
Auto-provisioning Test issues without @XrayTest(key=...)Renaming a test method creates a new Test issue; old one orphans.Pin every test method to an existing issue with @XrayTest(key="...").
Storing JWT secret in repo / logCloud secret leak; immediate quota abuse.Mask in CI; fetch fresh per run; never log (::add-mask::).
Importing without projectKeyImport lands in default project; other teams see your tests.Always pass projectKey.
New Test Execution per PR push100+ Jira issues per active PR; project clutter.Reuse testExecKey per PR; create new only on push to main.
Using regular JUnit XML reporter (not the Xray-extended one)Loses @XrayTest / @Requirement annotations; mapping fails.Use xray-junit-extensions for JVM or the official Playwright reporter.
Long-lived JWT cache (>24h)Auth fails; CI runs broken silently.Fetch JWT per run; respect the 24h validity.
Importing 5,000 results in one requestServer times out; partial state.Split per suite or per Test Execution; Xray Cloud's import endpoints are sized for typical CI batches.

Limitations

  • Cloud and Server have different endpoints + auth. Don't share scripts; maintain per-flavor variants.
  • Auto-provisioning creates issues in the project. Heavy auto-provisioning can balloon Jira project size; pre-create Test issues via the Xray UI or xray-postman-collections for a pre-seeded suite.
  • No first-party Cypress reporter in the Xray-App org; the community-maintained cypress-xray-junit-reporter is the de-facto choice but isn't officially supported.
  • Documentation domain is JS-rendered + auth-gated. Per the source-fetch failure documented here (2026-05-05), the primary canonical references for the import API require real browser navigation; the GitHub repos under https://github.com/Xray-App/ are the most reliable programmatically-fetchable sources.

References

  • xray-junit-ext (opens in new window) - official xray-junit-extensions repo: @XrayTest(key=...), @Requirement(...), XrayTestReporter injection, xray-junit-extensions.properties config.
  • https://github.com/Xray-App/xray-postman-collections - official Postman collections for every Xray Cloud public API endpoint (including /api/v2/authenticate and /api/v2/import/execution/*).
  • https://github.com/Xray-App/xray-maven-plugin - Maven-side integration with the same import + auth shape.
  • https://github.com/Xray-App/playwright-junit-reporter - official Playwright reporter for Xray-compatible JUnit XML.
  • https://docs.getxray.app/ - canonical doc portal (auth/region-gated; consult in a real browser).

Zephyr Scale - vendor specifics

View source (opens in new window)

Zephyr Scale - vendor specifics

Reference detail for test-management-sync (opens in new window). "Zephyr" disambiguates into three Jira test-management products that are not API-compatible - picking the right one is step zero:

ProductOrigin / current ownerKey API host pattern
Zephyr Scale (formerly TM4J)Adaptavist -> SmartBearhttps://api.zephyrscale.smartbear.com/v2/
Zephyr Squad (the older one)Atlassian -> SmartBearhttps://prod-api.zephyr4jiracloud.com/connect/
Zephyr Enterprise (server-only)SmartBearOn-prem Jira; per-instance

This page covers Zephyr Scale Cloud as the primary path - it's the most-deployed Zephyr variant in 2026 and the one new projects pick. Notes for Squad / Enterprise are inline.

The official documentation is at support.smartbear.com/zephyr-scale-cloud/. At the time of authoring (2026-05-05), the documentation site was behind WebFetch limits (auth/region-gated content); the URL is the canonical reference for real-browser navigation. Patterns below are the stable shapes documented across the SmartBear KB and per-language clients (zephyr-scale-python-client, the Postman collection SmartBear ships, and the mgechev/zephyr-scale-cloud-cli community client).

Authenticate (Zephyr Scale Cloud)

Zephyr Scale Cloud uses a long-lived API token (generated via "API Access Tokens" in the Zephyr Scale settings) sent as a Bearer token:

ZEPHYR_TOKEN=<long-lived-token>

curl -H "Authorization: Bearer $ZEPHYR_TOKEN" \
  'https://api.zephyrscale.smartbear.com/v2/healthcheck'

Unlike Xray Cloud, no JWT exchange step - the token is used directly.

The token is per-account, not per-project - guard it with the same care as a Jira admin credential.

If the team uses Zephyr Squad, the endpoints + auth differ significantly - see the Squad-specific REST API docs and the distinct prod-api.zephyr4jiracloud.com host.

Map test methods to Zephyr Test Cases

Two patterns mirror the TestRail / Xray approach.

Pattern A - Embed Test Case key in test name

def test_TC1234_can_add_to_cart():
    ...
test('can add to cart [TC1234]', async () => { /* ... */ });

A regex extracts TC1234 (the Zephyr Scale Test Case key) at sync time.

Pattern B - JUnit metadata via custom adapter

For Java / TestNG:

@Test
@TestCaseKey("PROJ-T1234")
public void canAddToCart() { /* ... */ }

The @TestCaseKey annotation is provided by community adapters (no first-party SmartBear annotation library at the time of writing); a small custom JUnit extension reads the annotation and emits a Zephyr-compatible JSON file alongside the JUnit XML.

Open a Test Cycle for the build

# scripts/zephyr_sync.py
import os, requests

BASE = 'https://api.zephyrscale.smartbear.com/v2'
HEADERS = {
    'Authorization': f"Bearer {os.environ['ZEPHYR_TOKEN']}",
    'Content-Type': 'application/json',
}
PROJECT_KEY = os.environ['JIRA_PROJECT_KEY']    # e.g. "CALC"

def open_cycle(name, version=None):
    r = requests.post(f'{BASE}/testcycles', headers=HEADERS, json={
        'projectKey': PROJECT_KEY,
        'name': name,                          # e.g. "Build #1234"
        'plannedStartDate': iso_now(),
        'description': f'Automated cycle for {os.environ.get("BUILD_VERSION", "")}',
        'jiraProjectVersion': version,         # optional Jira version ID
    })
    r.raise_for_status()
    return r.json()['key']                      # e.g. "CALC-R42"

The returned key (e.g. CALC-R42) is the Test Cycle's identifier; results land inside it.

Post execution results

Per the documented Zephyr Scale Cloud /testexecutions endpoint shape (consistent across SmartBear KB versions):

def post_execution(cycle_key, test_case_key, status, comment=None,
                   actual_end_date=None, execution_time=None):
    r = requests.post(f'{BASE}/testexecutions', headers=HEADERS, json={
        'projectKey': PROJECT_KEY,
        'testCycleKey': cycle_key,
        'testCaseKey': test_case_key,           # e.g. "CALC-T1234"
        'statusName': status,                   # 'Pass' | 'Fail' | 'Blocked' | 'Not Executed'
        'comment': comment,
        'actualEndDate': actual_end_date,       # ISO-8601
        'executionTime': execution_time,        # milliseconds
    })
    r.raise_for_status()
    return r.json()

statusName accepts the Zephyr-installed status names. For projects with custom statuses, query /statuses?projectKey=...&statusType=TEST_EXECUTION at script init to confirm the available names - don't hard-code beyond the four built-ins (Pass, Fail, Blocked, Not Executed).

Batch multiple results

The /testexecutions endpoint is per-execution. For batched POSTs, the documented /automations/executions endpoint accepts a payload that wraps multiple results - the exact shape is variant per Zephyr Scale version. The conservative pattern is to retry per-execution with bounded concurrency:

from concurrent.futures import ThreadPoolExecutor

def post_all(cycle_key, results, max_concurrent=5):
    with ThreadPoolExecutor(max_workers=max_concurrent) as ex:
        list(ex.map(lambda r: post_execution(cycle_key, **r), results))

max_concurrent=5 keeps under the rate limit (60 req/min on most plans) for typical run sizes.

Wire into CI

- name: Run tests
  run: npm test -- --reporters=jest-junit

- name: Sync to Zephyr Scale
  if: always()
  env:
    ZEPHYR_TOKEN: ${{ secrets.ZEPHYR_TOKEN }}
    JIRA_PROJECT_KEY: 'CALC'
    BUILD_VERSION: ${{ github.sha }}
  run: python scripts/zephyr_sync.py junit.xml

The script:

  1. Parses junit.xml (junit-xml-analysis).
  2. Extracts Test Case keys.
  3. Opens a Test Cycle.
  4. Posts executions with bounded concurrency (above).

Folder + label organization

Zephyr Scale Test Cases live in folders. Two patterns:

  • Per-feature folder: Checkout/, Cart/, Auth/ - automated tests in those folders sync to Test Cases there.
  • Per-tier folder: Smoke/, Regression/, Edge cases/ - automated tests carry a tier label that the sync script translates to folder.

The folder structure is created via the Zephyr UI; the sync script references existing Test Case keys and doesn't create folders on the fly.

JUnit XML import (alternative path)

Zephyr Scale also accepts a JUnit XML file via the /automations/executions/junit endpoint with a multipart body. This is simpler than the per-execution sync but loses per-test metadata (no comment, no execution time per case beyond what JUnit XML carries):

curl -X POST "https://api.zephyrscale.smartbear.com/v2/automations/executions/junit?projectKey=$JIRA_PROJECT_KEY&autoCreateTestCases=true" \
  -H "Authorization: Bearer $ZEPHYR_TOKEN" \
  -F "file=@junit.xml"

Per-execution POST is preferred when comment / evidence matters; this JUnit XML import is the lightweight default.

Worked example

A Jest suite syncing one build to project CALC:

  1. Tests carry the Test Case key in the name (Pattern A): test('can add to cart [CALC-T1234]', ...).
  2. Export the token and run tests to JUnit XML:
export ZEPHYR_TOKEN=...             # from Zephyr Scale > API Access Tokens
export JIRA_PROJECT_KEY=CALC
npm test -- --reporters=jest-junit
  1. The sync script opens a cycle, then posts one execution:
cycle = open_cycle("Build #1234")           # returns e.g. "CALC-R42"
post_execution(cycle, "CALC-T1234", "Pass",
               comment="green on CI", execution_time=1240)

The execution lands inside cycle CALC-R42; open it in Jira to see the Pass recorded against CALC-T1234.

Zephyr-specific anti-patterns

Anti-patternWhy it failsFix
Targeting Zephyr Squad endpoints with Zephyr Scale authDifferent host, different auth model; immediate 401.Confirm the variant (see the variant table at the top).
Hard-coding statusName: "Pass" / "Fail" onlyCustom statuses installed by the project break silently.Query /statuses at init; cache the valid set.
Per-execution POST with 1000 tests, no concurrencySingle-threaded; 30+ minutes for a release run.Bounded concurrency (above).
Per-execution POST with unbounded concurrencyTrips rate limit (60/min); execution drops.max_workers=5.
Reusing one Test Cycle across many buildsCycle accumulates noise; release sign-off is unreadable.One Cycle per build; Cycles can be archived per release.
autoCreateTestCases=true in CIEvery renamed test creates a new Test Case; folder fills with orphans.Pre-create Test Cases manually; sync references existing keys.
Treating the API token as session-scopedToken is long-lived per-account; no refresh.Store in CI secrets; rotate via Zephyr Scale settings, not per-run.

Limitations

  • Three Zephyr products with different APIs. Squad and Scale diverged years ago; Enterprise is its own thing. Check which variant the team has before pattern-matching tutorials.
  • No first-party adapter library across all languages. SmartBear ships Postman collections + Java reference clients; Python / JS / Ruby teams use community-maintained adapters with varying maintenance status.
  • Folder + Test Case management is a UI workflow. Programmatic Test Case creation exists but is fragile across versions; the sync-to-existing-cases pattern is more durable.
  • Documentation site is auth/region-gated. Per the source-fetch failure documented above (2026-05-05), the canonical references require real-browser navigation; per-language client repos are the most reliable programmatic source.

References

  • https://support.smartbear.com/zephyr-scale-cloud/ - canonical Zephyr Scale Cloud documentation portal (auth/region-gated; consult in a real browser).
  • https://support.smartbear.com/zephyr-scale-cloud/api-docs/ - REST API reference for Scale Cloud.
  • https://support.smartbear.com/zephyr-squad-cloud/ - Squad Cloud reference (different product, different API).

Related skills

allure-reports

Configures Allure Report (test-runner adapter install, `allure-results` directory wiring, `categories.json` for failure classification, `history-trend.json` retention via the copy-history-between-runs pattern), runs the Allure CLI to convert `allure-results` to a static HTML site, and uploads the report as a CI artifact. Use when the team needs richer test reporting than JUnit XML - step-level attachments, per-test history, retry tracking, and severity / epic / feature labeling across framework-agnostic adapters (pytest, Jest, JUnit, TestNG, NUnit, Mocha). As a rich static HTML report generator, it is the open-source alternative to the sunset ExtentReports (JVM/.NET per-test HTML narrative); for hosted cross-run flakiness analytics rather than a static per-run report use currents-integration.

coverage-diff-reporter

Builds a per-PR coverage delta report from any pair of LCOV / Cobertura / JSON coverage outputs (current run + baseline from the merge target) - emits a per-file table with line% / branch% deltas, called-out new files, hidden drops (overall +0.1pp but one file -8pp), and a single-line PR-comment summary. Use when the team has coverage in CI but needs human-readable PR feedback that points at the specific file the reviewer should focus on, not just an aggregate number.

coverage-py-analysis

Configures coverage.py for Python projects - wires `coverage run` (replacing `python` for instrumentation), enables branch coverage via the `--branch` flag or `branch = True` config, manages the `.coverage` data file (single-process and `combine` for parallel pytest-xdist runs), authors `.coveragerc` with `source` / `omit` / `fail_under`, and emits the format the downstream tool needs (`coverage report` for terminal, `coverage xml` for Cobertura, `coverage html` for human review, `coverage lcov` for SaaS, `coverage json` for programmatic post-processing). Use for any Python test stack (pytest, unittest, nose) that needs PR-time coverage signal.

currents-integration

Wires Currents.dev cross-run test analytics into a Playwright suite: installs `@currents/playwright`, authors `currents.config.ts` (env-sourced `recordKey` + `projectId`), registers `currentsReporter()`, enables trace/video/screenshot artifacts, and runs via `npx pwc` so per-test traces stream to the Currents dashboard with over-time flakiness, slowest-test, and pass-rate trends. Use when a Playwright suite needs hosted cross-run suite-health analytics; for a static per-run report use extentreports or allure-reports, and to sync results into TestRail / Xray / Zephyr test management use test-management-sync.

jacoco-analysis

Configures JaCoCo for JVM projects (Java / Kotlin / Scala / Groovy) - wires the runtime agent via `jacoco-maven-plugin` `prepare-agent`, generates per-build reports (HTML / XML / CSV) via the `report` goal, gates the build via the `check` goal with element / limit / minimum rules, parses the six native counters (instructions, branches, lines, methods, classes, cyclomatic complexity), and converts JaCoCo XML to LCOV / Cobertura when downstream tools need a different format. Use when the JVM build is Maven / Gradle and the team wants the canonical JVM coverage tool - or to convert JaCoCo output for cross-language coverage aggregation.

junit-xml-analysis

Explains CI test numbers that disagree with what the suite actually did - a 'slowest tests' list dominated by the wrong suite, a release gate or dashboard reading only the summary attributes on the suite element and never the cases below them, or a pass rate that quietly counts skipped tests as passes. Parses JUnit-format XML (the interchange format Jenkins, GitHub Actions, GitLab, Buildkite, and CircleCI all ingest) into per-suite and per-case metrics tables - passed / failed / errored / skipped, time, classname, message, stack - groups failures by classname for trend analysis, and separates new failures from flakes by cross-referencing the `flakyFailure` and rerun elements. Use when a report, gate, or metric derived from test results cannot be trusted.

lcov-analysis

Parses both mainstream coverage interchange formats: LCOV `.info` text files (produced by gcov, llvm-cov, Coverage.py via `py2lcov`, JaCoCo via `xml2lcov`, Devel::Cover, Jest via `lcov` reporter, NYC, and most others) and Cobertura XML (coverage-04.dtd - emitted by JaCoCo, coverage.py `--xml`, Jest's `cobertura` reporter, coverlet, gocover-cobertura; full parser in references/cobertura.md). Extracts per-file line / function / branch metrics from the canonical record keywords (TN/SF/FN/FNDA/FNF/FNH/BRDA/BRF/BRH/DA/LH/LF), computes the diff vs a baseline, and emits per-file gating verdicts. Use for PR coverage gates that don't depend on a specific language runtime, whichever of the two formats the CI emits.

test-coverage-targeter

Builds a "what to test next" recommendation by combining a coverage report (LCOV / Cobertura / coverage.py JSON / Jest JSON / JaCoCo XML) with the PR's `git diff`, ranking uncovered branches by risk × cost - risk weighted by McCabe cyclomatic complexity and code-churn frequency, cost weighted by the unit-test pyramid layer (unit tests cheaper than integration than E2E). Also carries the coverage debt ledger: a weekly per-file drift report over N historical main runs flagging `falling` (line% slid >M pp from peak), `stale` (flat coverage + high churn), and `orphan` (lost last covering test) files, whose rows feed the same targeting. Emits a prioritized list with concrete file:line targets and the test layer recommended for each. Use when a team has the budget to write 5 - 10 new tests and needs help picking which uncovered code to target first instead of blindly chasing 100% coverage, or when specific modules are eroding silently while whole-repo coverage looks fine.

test-run-summary-author

Build-an-X workflow that turns a structured test-run artifact (JUnit XML, Allure JSON, TestRail / Xray / Zephyr export) plus optional release context (version, build URL, deploy target) into a narrative markdown summary for release notes, an exec status update, or a stand-up Slack post. Distinct from the per-framework parsers junit-xml-analysis / allure-reports / coverage-diff-reporter, which emit structured tabular reports: this skill takes the same data and writes the human-readable narrative. Use when a manager needs a draft release note or stand-up summary from a single run; for cross-run trend analytics use currents-integration.