Author and manage test cases in TestRail via REST API v2 - create cases, organise into suites + sections, update steps + expected results, bulk import from CSV/JSON, set automation status, link to references (Jira / requirements). Covers the Steps / Text / Exploratory templates, custom-field discovery (`get_case_fields`), and pagination on `get_cases`. Use for pre-execution case authoring and repository management. Do NOT use for submitting test-run results (pass/fail, status updates): posting results via add_results_for_cases is a separate post-execution concern.
79
99%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
TestRail organises tests as cases inside sections inside
suites inside projects. The API v2 covers full CRUD on each
plus templates, custom fields, references, and types. Authentication
is HTTP Basic with email + API key per the TestRail support docs
(support.testrail.com/hc/en-us/articles/7077871398036-Cases -
Cloudflare-protected, cite by stable URL).
For the canonical anatomy this skill operates on, see
test-case-anatomy-reference.
Differentiation vs testrail-integration: that skill posts
test-run results via add_results_for_cases. This one operates
on the case repository - create, update, organise, traceability - a strictly upstream concern.
Per TestRail API docs (Cloudflare-protected, support.testrail.com):
export TR_BASE="https://your-tenant.testrail.io"
export TR_EMAIL="you@company.com"
export TR_KEY="<api-key-from-user-profile>"import requests, base64, os, json
auth = base64.b64encode(
f"{os.environ['TR_EMAIL']}:{os.environ['TR_KEY']}".encode()
).decode()
HEADERS = {
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
}
BASE = os.environ["TR_BASE"]
def api(path, method="GET", body=None):
url = f"{BASE}/index.php?/api/v2/{path.lstrip('/')}"
r = requests.request(method, url, headers=HEADERS,
data=json.dumps(body) if body else None)
r.raise_for_status()
return r.json()POST /index.php?/api/v2/add_case/:section_id:
def create_case(section_id, title, template_id=1, type_id=1, priority_id=2,
preconditions=None, steps=None, refs=None):
"""
template_id: 1=Steps, 2=Text, 3=Exploratory
type_id: per project - discover via get_case_types
priority_id: 1=Low, 2=Medium, 3=High, 4=Critical (default project enum)
steps: list of {"content": "Action", "expected": "Outcome"}
refs: comma-separated requirement IDs (e.g., "REQ-123,REQ-124")
"""
body = {
"title": title,
"template_id": template_id,
"type_id": type_id,
"priority_id": priority_id,
}
if preconditions:
body["custom_preconds"] = preconditions
if steps and template_id == 1:
body["custom_steps_separated"] = steps
if refs:
body["refs"] = refs
return api(f"add_case/{section_id}", method="POST", body=body)steps = [
{"content": "Navigate to /login", "expected": "Login form rendered"},
{"content": "Enter alice@example.com + correct password",
"expected": "Submit button enabled"},
{"content": "Click Submit",
"expected": "Redirected to /dashboard within 2 s"},
]
new_case = create_case(
section_id=42,
title="Login with valid credentials redirects to dashboard",
template_id=1,
steps=steps,
preconditions="User `alice@example.com` exists; password set to 'pw123'.",
refs="REQ-AUTH-001",
)
print(new_case["id"]) # e.g., 1234Each tenant defines its own custom fields, case types, and priorities; discover their IDs at runtime rather than hard-coding them:
fields = api("get_case_fields") # system_name, label, type_id per field
types = api("get_case_types") # id + name per case type
prios = api("get_priorities") # id + name per priorityThe type_id enumeration, the field-discovery loop, and the section / suite
hierarchy are in
references/testrail-api-reference.md.
POST /index.php?/api/v2/update_case/:case_id:
def update_case(case_id, **fields):
return api(f"update_case/{case_id}", method="POST", body=fields)
# Bulk re-tag:
update_case(1234, refs="REQ-AUTH-001,REQ-AUTH-002")case = api(f"get_case/1234")
# List with pagination:
cases = []
offset = 0
while True:
page = api(f"get_cases/{project_id}&suite_id={suite_id}"
f"&limit=250&offset={offset}")
cases.extend(page.get("cases", page) if isinstance(page, dict) else page)
if isinstance(page, dict) and page.get("size", 0) < 250:
break
offset += 250Newer TestRail versions return {"offset", "limit", "size", "_links", "cases": [...]};
older return a bare array. Handle both.
Build name-to-id maps once, then inside the loop check for an existing case by
title before create so re-runs stay idempotent. get_cases accepts filter
(substring match on title):
import csv
# Name -> id maps, discovered once (see Discover fields, types + priorities)
type_ids = {t["name"]: t["id"] for t in api("get_case_types")}
prio_ids = {p["name"]: p["id"] for p in api("get_priorities")}
created, skipped = [], []
for row in csv.DictReader(open("legacy-cases.csv")):
title = row["title"]
existing = api(f"get_cases/{project_id}&suite_id={suite_id}"
f"&filter={requests.utils.quote(title[:60])}")
if existing.get("cases"):
skipped.append((title, existing["cases"][0]["id"]))
continue # already present; skip and log
steps = [
{"content": s, "expected": e}
for s, e in zip(row["steps"].split("|"), row["expected"].split("|"))
]
case = create_case(
section_id=int(row["section_id"]),
title=title,
template_id=1,
type_id=type_ids[row["type"]],
priority_id=prio_ids[row["priority"]],
preconditions=row.get("preconditions"),
steps=steps,
refs=row.get("refs"),
)
created.append(case["id"])Verify: assert len(created) + len(skipped) equals the row count before
declaring the import done; a shortfall means a row raised - inspect it, fix the
source row, and re-run (already-created cases are skipped by the title check).
add_case response is the full case object with id, created_on,
updated_on, created_by etc. Permalink:
url = f"{BASE}/index.php?/cases/view/{case_id}"Sync cases from a tests/ directory layout. One pattern: each
spec file has front-matter declaring the TestRail case ID; on PR
merge, post updates back:
- name: Sync test cases to TestRail
env:
TR_BASE: ${{ vars.TR_BASE }}
TR_EMAIL: ${{ vars.TR_EMAIL }}
TR_KEY: ${{ secrets.TR_KEY }}
run: python scripts/sync-testrail.py| Anti-pattern | Why it fails | Fix |
|---|---|---|
Hard-coded type_id, priority_id | IDs differ per project | Discover via get_case_types / get_priorities |
Steps in custom_steps (Text template) | Per-step results unavailable | Use custom_steps_separated (Steps template) |
Plain title with no refs | Coverage reports show 0% requirement coverage | Always set refs to requirement IDs |
| Creating cases in the root section | Hard to find later; organisation chaos | Always pick a section; create sections as needed |
No pagination on get_cases | Misses cases beyond first 250 | Loop with offset until empty |
| Storing API key in code | Token leak | Environment variable |
| Polling for case existence on every CI run | Rate-limited | Cache case-ID-by-title within the CI run |
refs is free text. TestRail doesn't validate that referenced
IDs exist in Jira / Linear / etc. Pair with traceability matrix
reconciliation.test-case-anatomy-reference.xray-case-management,
zephyr-scale-case-management,
allure-testops-case-management,
qase-io-case-management.testrail-integration (in the qa-test-reporting plugin) - different scope (result sync; not case authoring).