Recipes
Complete working flows for the BesTest REST API: record automated test results into a cycle, import test cases and requirements in bulk, gate a release from CI, and export coverage.
On this page
- First: find things by their key
- Record a CI run's results into a cycle
- 1. Find the execution
- 2. Write the result
- 3. The whole loop
- If the test case is not in the cycle
- Gate a release from CI
- Did anything fail in this cycle?
- Is every critical requirement covered?
- As a pipeline step
- Import test cases in bulk
- Import requirements in bulk
- Export everything for a dashboard
- Bulk work without hitting the limit
- Related
Complete flows, not fragments. Each one is a job someone actually needs doing, written end to end so you can paste it into a terminal, watch it work, then adapt it.
Every example assumes two variables:
export BESTEST_BASE="https://prod-eu.getbestest.com" # your region
export BESTEST_TOKEN="bst_pat_YOUR_TOKEN_HERE"Swap the base URL for your region, and create the token from API & MCP tokens in the app menu. Recipes that write anything need a read-and-write token; the read-only ones say so.
curl -sG with --data-urlencode is used rather than hand-encoding query strings. Filters contain spaces, quotes and comparison operators, and encoding those by hand is where most first attempts break.
First: find things by their key
This is the step every other recipe depends on, and the one that catches everyone.
In the app you see keys: KAN-CY-38, KAN-TC-42, KAN-RQ-7. The API addresses everything by UUID. The bridge is a filter, because every entity carries its key as an ordinary, filterable field:
| You have | Filter on | Lives on |
|---|---|---|
KAN-TC-42 | testCaseKey | /test_cases |
KAN-CY-38 | testCycleKey | /test_cycles |
KAN-RQ-7 | requirementKey | /requirements |
curl -sG "$BESTEST_BASE/api/v1/test_cycles" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "filter=testCycleKey = 'KAN-CY-38'"{
"data": [
{
"type": "test_cycles",
"id": "3f2b9c14-8d5e-4a71-9f60-2c1e7b4a8d33",
"attributes": { "name": "Release 4.2 regression", "testCycleKey": "KAN-CY-38", "status": "ACTIVE" }
}
],
"meta": { "totalCount": 1 }
}A one-liner that returns just the id, used by the scripts below:
cycle_id() {
curl -sG "$BESTEST_BASE/api/v1/test_cycles" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "filter=testCycleKey = '$1'" \
| jq -r '.data[0].id'
}You can also filter across a relationship with dot notation, which saves a lookup:
# Executions in one cycle, for one test case, in a single request
--data-urlencode "filter=testCycle.testCycleKey = 'KAN-CY-38' AND testCase.testCaseKey = 'KAN-TC-42'"Record a CI run's results into a cycle
The most common reason to touch the API at all. Your pipeline just ran, you have a pass or fail per test case, and you want it beside the manual runs so coverage means something.
Needs a read-and-write token.
The shape of it: a cycle already contains an execution per planned test case, sitting at NOT_EXECUTED. You are not creating results, you are filling in the ones that are waiting.
1. Find the execution
CYCLE_KEY="KAN-CY-38"
CASE_KEY="KAN-TC-42"
EXEC_ID=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "filter=testCycle.testCycleKey = '$CYCLE_KEY' AND testCase.testCaseKey = '$CASE_KEY'" \
| jq -r '.data[0].id')2. Write the result
curl -s -X PATCH "$BESTEST_BASE/api/v1/test_case_executions/$EXEC_ID" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/vnd.api+json" \
-d @- <<JSON
{
"data": {
"type": "test_case_executions",
"id": "$EXEC_ID",
"attributes": {
"result": "PASSED",
"executedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"comments": "Playwright run #1482, commit a1b2c3d"
}
}
}
JSONresult must be one of NOT_EXECUTED, IN_PROGRESS, PASSED, FAILED, BLOCKED, SKIPPED.
Putting the build number and commit in comments is the difference between "this failed" and "this failed, here is the run that proves it". Do it.
3. The whole loop
Given a file of KAN-TC-42 PASSED lines produced by your test reporter:
#!/usr/bin/env bash
set -euo pipefail
CYCLE_KEY="KAN-CY-38"
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
while read -r case_key result; do
exec_id=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions" "${AUTH[@]}" \
--data-urlencode "filter=testCycle.testCycleKey = '$CYCLE_KEY' AND testCase.testCaseKey = '$case_key'" \
| jq -r '.data[0].id // empty')
if [ -z "$exec_id" ]; then
echo "skip $case_key: not planned in $CYCLE_KEY" >&2
continue
fi
curl -s -o /dev/null -X PATCH "$BESTEST_BASE/api/v1/test_case_executions/$exec_id" "${AUTH[@]}" \
-H "Content-Type: application/vnd.api+json" \
-d "{\"data\":{\"type\":\"test_case_executions\",\"id\":\"$exec_id\",\"attributes\":{\"result\":\"$result\",\"executedAt\":\"$NOW\"}}}"
echo "$case_key -> $result"
sleep 1.2 # stay under 100 requests a minute (2 calls per case)
done < results.txtEach test case costs two requests, so 100 requests a minute means roughly 50 test cases a minute. The sleep 1.2 above is what keeps a 200-case suite from getting a wall of 429s halfway through. For anything larger, read Bulk work without hitting the limit.
If the test case is not in the cycle
The loop above skips cases that were never planned into the cycle. To add them programmatically, use the addExecutionsToCycle action, which takes the cycle and a list of plans:
curl -s -X POST "$BESTEST_BASE/api/v1/rpc/bestest_domain_addExecutionsToCycle" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"testCycleId": "3f2b9c14-8d5e-4a71-9f60-2c1e7b4a8d33",
"plans": [
{ "testCaseId": "8d5e4a71-9f60-2c1e-7b4a-8d333f2b9c14" }
]
}'Gate a release from CI
Read-only token is enough. Two checks, each one request, each returning a count you can compare against zero.
Aggregates exist so you never page through 10,000 rows to count them.
Did anything fail in this cycle?
curl -sG "$BESTEST_BASE/api/v1/test_case_executions/aggregate" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "aggregations[count]=true" \
--data-urlencode "filter=testCycle.testCycleKey = 'KAN-CY-38' AND result = 'FAILED'" \
| jq '.meta.aggregations.count'Is every critical requirement covered?
curl -sG "$BESTEST_BASE/api/v1/requirements/aggregate" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
--data-urlencode "aggregations[count]=true" \
--data-urlencode "filter=significance = 'CRITICAL' AND status != 'COVERED'" \
| jq '.meta.aggregations.count'As a pipeline step
#!/usr/bin/env bash
set -euo pipefail
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")
failed=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions/aggregate" "${AUTH[@]}" \
--data-urlencode "aggregations[count]=true" \
--data-urlencode "filter=testCycle.testCycleKey = '$CYCLE_KEY' AND result = 'FAILED'" \
| jq '.meta.aggregations.count')
unrun=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions/aggregate" "${AUTH[@]}" \
--data-urlencode "aggregations[count]=true" \
--data-urlencode "filter=testCycle.testCycleKey = '$CYCLE_KEY' AND result = 'NOT_EXECUTED'" \
| jq '.meta.aggregations.count')
echo "failed=$failed not-yet-run=$unrun"
[ "$failed" -eq 0 ] && [ "$unrun" -eq 0 ] || { echo "Release gate: blocked."; exit 1; }
echo "Release gate: clear."Checking NOT_EXECUTED as well as FAILED is the point. A cycle with no failures because nobody ran it is not a green cycle, and a gate that only counts failures will wave it through.
Import test cases in bulk
For a migration or a spreadsheet, do not loop POST /test_cases: at 100 requests a minute, 2,000 cases is a twenty-minute job that dies partway. Use the import action, which takes up to 10,000 rows in one request.
Needs a read-and-write token.
curl -s -X POST "$BESTEST_BASE/api/v1/rpc/bestest_domain_importTestCases" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"skipExisting": true,
"rows": [
{
"source_row": 1,
"test_case_name": "Checkout rejects an expired card",
"description": "Payment must be refused when the card is past its expiry date.",
"preconditions": "A registered account with an expired card on file.",
"folder_path": "Regression/Checkout",
"format_mode": "TRADITIONAL",
"automation_status": "AUTOMATED",
"automation_key": "checkout.spec.ts:expired-card",
"linked_requirement_keys": "KAN-RQ-7",
"steps": [
{ "action": "Open the checkout page", "expected": "The payment form is shown" },
{ "action": "Pay with the expired card", "data": "4111 1111 1111 1111, 01/20", "expected": "Payment is refused with a clear message" }
]
}
]
}'Row fields, all optional except test_case_name:
| Field | Notes |
|---|---|
source_row | Your own row number. Errors quote it, so a failed import tells you which spreadsheet line to fix. |
test_case_name | Required. |
description, preconditions, objective | Free text. |
folder_path | Regression/Checkout creates the folders as needed. |
format_mode | TRADITIONAL or BDD. |
priority, type, status, automation_status | Match the values your Space uses. |
automation_key | The identifier your CI knows the test by. Set this and later result uploads can find the case without a human mapping table. |
planned_time | Whole number. |
owner_external_id | Atlassian account id. |
linked_requirement_keys | Comma-separated, e.g. KAN-RQ-7,KAN-RQ-9. Keys that do not resolve are skipped. |
steps | Array of { action, data, expected }, or { bdd_content } for BDD. |
skipExisting: true makes the import re-runnable: run it again after fixing three rows and it will not duplicate the other 1,997. Pass baseFolderId to drop everything under one folder.
Import requirements in bulk
Same shape, different columns:
curl -s -X POST "$BESTEST_BASE/api/v1/rpc/bestest_domain_importRequirements" \
-H "Authorization: Bearer $BESTEST_TOKEN" \
-H "Accept: application/vnd.api+json" \
-H "Content-Type: application/vnd.api+json" \
-d '{
"skipExisting": true,
"rows": [
{
"source_row": 1,
"requirement_name": "Expired cards must be refused at checkout",
"description": "Any card past its expiry date is refused before payment is attempted.",
"reference": "PCI-4.2",
"complexity": "MEDIUM",
"impact": "HIGH",
"folder_path": "Payments",
"linked_test_case_keys": "KAN-TC-42"
}
]
}'complexity and impact are LOW, MEDIUM or HIGH, and together they produce the requirement's significance, which is what coverage is judged against. Getting them right at import time is what makes the coverage number meaningful later.
Export everything for a dashboard
Read-only token. Pages are capped at 100, so anything real needs a loop. Read meta.totalCount and walk.
#!/usr/bin/env bash
set -euo pipefail
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")
page=1
: > requirements.json
while : ; do
body=$(curl -sG "$BESTEST_BASE/api/v1/requirements" "${AUTH[@]}" \
--data-urlencode "page[size]=100" \
--data-urlencode "page[number]=$page" \
--data-urlencode "sort=createdAt" \
--data-urlencode "fields[requirements]=requirementKey,name,status,significance")
echo "$body" | jq -c '.data[]' >> requirements.json
total=$(echo "$body" | jq '.meta.totalCount')
count=$(wc -l < requirements.json)
echo "$count / $total"
[ "$count" -ge "$total" ] && break
page=$((page + 1))
doneTwo things make this cheap. fields[requirements]=... returns only the four columns you asked for instead of all 20-odd, and sort=createdAt gives a stable order so page 2 does not contain a row you already saw on page 1.
Bulk work without hitting the limit
The limit is 100 requests a minute per token, shared with MCP. Over it you get 429. In rough order of preference:
- Use an import action. 10,000 rows in one request beats 10,000 requests. This is the whole reason the actions exist.
- Use
/aggregatewhen you only want a count. One request, no paging. - Ask for bigger pages.
page[size]=100is a hundredth of the requests of the default 25. - Use
include=to pull related records in the same response rather than a follow-up request each. - Only then, pace your loop. Roughly 0.6s between requests keeps a single-request-per-item loop inside the budget.
If you get a 429, back off and retry rather than hammering: the budget refills over the minute.
Related
- REST API - setup, access and the core ideas
- Full API reference - every endpoint and field
- MCP - the same jobs, asked in plain English
- API tokens - create, rotate and revoke
