Getting Automated Test Results Into qTest Without Creating a Mess
Pushing CI test results into qTest via its REST API unifies manual and automated coverage — but the real work is stable ID mapping and idempotent submission, not the API call itself.
27 Apr 2026, 11:21 UTC

Your CI pipeline runs hundreds of automated tests every night, and the results live in a Jenkins console log that nobody outside the team ever opens. Meanwhile, your test managers report coverage out of qTest, where the manual test cases live. The two worlds never meet, so "what's our actual pass rate against this release?" requires a spreadsheet and a meeting. The fix is well understood: push automation results into qTest through its REST API so manual and automated coverage sit in the same place. The part teams underestimate is not the API call — it's the mapping and the idempotency.
The decision that matters: how you map tests to qTest cases
qTest organizes work as test cases, grouped into test runs under test cycles and releases. To record an automated result, your pipeline needs to know which qTest test run corresponds to, say, CheckoutTests.Payment_WithExpiredCard_Fails. You have three broad options:
- Match by name. The automation test name mirrors the qTest case title. Simple to start, brittle in practice — the first time someone renames a case in the UI, results silently stop landing or land on the wrong run.
- Store the qTest ID in the code. An attribute, annotation, or tag on each automated test carries the qTest test case ID (for example, a custom NUnit/Pytest marker like
qtest_id=48211). Renames in qTest don't break anything; the cost is discipline when writing new tests. - Auto-create runs. The pipeline creates new test runs from automation output instead of mapping to pre-planned cases. This keeps results flowing with zero mapping effort, but it can flood cycles with duplicate runs and weakens the requirement-to-test traceability that justified qTest in the first place.
For most teams, the stored-ID approach is the right default: it survives refactoring on both sides and makes the traceability reports meaningful.
The submission flow, at a high level
The exact endpoints and authentication scheme vary between qTest cloud and on-premise deployments and across versions, so treat this as a shape, not a spec — confirm details against your instance's built-in API documentation (reachable from the product UI) before writing code.
- Authenticate. Obtain an API token from your qTest instance (typically a bearer token tied to a user or service account). Store it as a CI secret, never in the repo.
- Resolve the target container. Identify the project, release, and test cycle the results belong to. Many pipelines look up the cycle by name convention, e.g.
Release 4.2 / Nightly Regression. - Submit results. For each automated test, send the outcome (passed/failed), duration, and optionally a log excerpt or attachment, referencing the mapped test case/run ID.
- Verify. Query the cycle afterward and confirm the run counts match what the test framework reported.
A worked example: Pytest to qTest
A minimal, illustrative Python sketch for the submit step — run from your CI agent after the test suite finishes, with network access to your qTest instance and an API token in the environment:
import os, json, requests
BASE = "https://your-company.qtestnet.com" # your instance URL
TOKEN = os.environ["QTEST_API_TOKEN"] # injected as a CI secret
HEADERS = {"Authorization": f"bearer {TOKEN}",
"Content-Type": "application/json"}
# results.json: produced by a pytest plugin/hook, one entry per test
# [{"qtest_run_id": 902134, "status": "PASSED", "duration_ms": 812}, ...]
results = json.load(open("results.json"))
for r in results:
payload = {
"status": r["status"],
"exe_start_date": r["start"],
"exe_end_date": r["end"],
"note": r.get("failure_message", "")
}
# Endpoint path is illustrative — confirm the exact result-log
# endpoint and payload fields against your instance's API docs.
resp = requests.post(
f"{BASE}/api/v3/projects/{PROJECT_ID}/test-runs/{r['qtest_run_id']}/test-logs",
headers=HEADERS, json=payload, timeout=30)
resp.raise_for_status()
Expected check: after the CI job runs, open the target test cycle in qTest and confirm each submitted run shows the new execution with the correct status. If the counts differ, diff the CI test report against the qTest cycle before assuming the API misbehaved — a mapping gap is the usual culprit.
Idempotency: the failure mode that bites later
CI jobs get retried. If your submission logic blindly creates test runs or logs, a single flaky-network retry can double every result, and your pass-rate metrics become fiction. Two practical guards:
- Prefer updating existing runs over creating new ones. Resolve the run ID first (via your stored mapping); only create when nothing exists.
- Record what you submitted. Keep a small artifact (build ID → submitted run IDs) so a retried job can skip or update instead of duplicating.
Also decide what "failed" means before you start: a test that fails and passes on rerun within the same build — do you submit both logs, or the final verdict? qTest will store whatever you send; consistency is your job.
Trade-offs and what to check before committing
The payoff is real: one place for coverage, requirement-to-execution traceability, and defects linkable to runs (and onward to Jira, if that integration is enabled). The costs are real too: a service account and token to manage, a mapping convention the whole team must follow, and a new CI failure mode (qTest unreachable) that you must decide whether to make blocking — most teams make submission best-effort so an API outage doesn't fail the build.
Before building, verify three things on your own instance: the exact authentication method and result-submission endpoints in the built-in API docs; that your license tier includes the API access and modules you expect; and, with a five-test proof of concept, that submitted results land under the intended cycle. If any of those surprise you, it's far cheaper to learn it now than after a thousand runs a night depend on it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.