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.

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.

A shorthand used throughout

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 haveFilter onLives on
KAN-TC-42testCaseKey/test_cases
KAN-CY-38testCycleKey/test_cycles
KAN-RQ-7requirementKey/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"
    }
  }
}
JSON

result 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.txt
Watch the rate limit on this one

Each 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:

FieldNotes
source_rowYour own row number. Errors quote it, so a failed import tells you which spreadsheet line to fix.
test_case_nameRequired.
description, preconditions, objectiveFree text.
folder_pathRegression/Checkout creates the folders as needed.
format_modeTRADITIONAL or BDD.
priority, type, status, automation_statusMatch the values your Space uses.
automation_keyThe identifier your CI knows the test by. Set this and later result uploads can find the case without a human mapping table.
planned_timeWhole number.
owner_external_idAtlassian account id.
linked_requirement_keysComma-separated, e.g. KAN-RQ-7,KAN-RQ-9. Keys that do not resolve are skipped.
stepsArray 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))
done

Two 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:

  1. Use an import action. 10,000 rows in one request beats 10,000 requests. This is the whole reason the actions exist.
  2. Use /aggregate when you only want a count. One request, no paging.
  3. Ask for bigger pages. page[size]=100 is a hundredth of the requests of the default 25.
  4. Use include= to pull related records in the same response rather than a follow-up request each.
  5. 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.

Getting started

Live in about a minute.

  1. ~30 seconds
    1.Install from the Marketplace

    One click on "Get it now" - no sales call, no signup form, no separate login.

  2. ~1 minute
    2.Enable it on a Space

    Flip it on in Space settings. BesTest shows up in the Space menu, where your team already works.

  3. right away
    3.Run your first test

    Create a requirement, link a test case, hit run. No training course required.

Host your data in the EU, US, or IndiaNo Jira issue bloat - your library stays out of Jira’s wayBuilt on Atlassian Forge