Recipes

Complete working flows for the BesTest REST API: record automated test results into a cycle, gate a release from CI, export to a dashboard, link tests to requirements, and poll for changes.

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 recipe assumes these three lines, plus jq installed:

export BESTEST_BASE="https://prod-eu.getbestest.com"   # your region
export BESTEST_TOKEN="eyJ..."                           # your token
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")

Swap the base URL for your region, and create the token from API & MCP tokens in the app menu. Recipes that write need a read-and-write token; the read-only ones say so.

Two habits used throughout

curl -G with --data-urlencode builds the query string for you. Filters contain spaces, quotes and comparison operators, and encoding those by hand is where most first attempts break. And FiltrQL strings go in double quotes, so a filter written inside a double-quoted shell string escapes them: "filter=testCycleKey = \"$KEY\"".

For lower-level, one-pattern-at-a-time examples (cursor paging, soft delete and restore, relation quantifiers), see the worked workflows in the reference.

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-TC-42, KAN-CY-38, KAN-REQ-7. The API addresses everything by id, a UUID. The bridge is a filter, because every record carries its key as an ordinary field:

You haveFilter onLives on
KAN-TC-42testCaseKey/test_cases
KAN-CY-38testCycleKey/test_cycles
KAN-REQ-7requirementKey/requirements
The name your test runner usesautomationKey/test_cases
curl -sG "$BESTEST_BASE/api/v1/test_cycles" "${AUTH[@]}" \
  --data-urlencode 'filter=testCycleKey = "KAN-CY-38"' \
  --data-urlencode 'fields[test_cycles]=testCycleKey,name,status'
{
  "data": [
    {
      "type": "test_cycles",
      "id": "3f2b9c14-8d5e-4a71-9f60-2c1e7b4a8d33",
      "attributes": { "testCycleKey": "KAN-CY-38", "name": "Release 4.2 regression", "status": "ACTIVE" },
      "links": { "self": "/test_cycles/3f2b9c14-8d5e-4a71-9f60-2c1e7b4a8d33" }
    }
  ],
  "meta": { "totalCount": 1 }
}

A helper that returns just the id, used below:

id_of() {   # id_of test_cycles testCycleKey KAN-CY-38
  curl -sG "$BESTEST_BASE/api/v1/$1" "${AUTH[@]}" \
    --data-urlencode "filter=$2 = \"$3\"" \
    --data-urlencode "fields[$1]=id" \
  | jq -r '.data[0].id // empty'
}

You can often skip the lookup by filtering through a relation with dot notation:

# The execution of one test case in one cycle, in a single request
--data-urlencode 'filter=testCycle.testCycleKey = "KAN-CY-38" AND testCase.testCaseKey = "KAN-TC-42"'

Your Space's id

Every create needs projectId, the id of the one Space your token reaches. One call, and it never changes, so look it up once:

PROJECT_ID=$(curl -s "$BESTEST_BASE/api/v1/projects" "${AUTH[@]}" | jq -r '.data[0].id')

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, and you want it beside the manual runs.

Needs a read-and-write token.

The shape of it: a cycle holds one execution per planned test case, sitting at NOT_EXECUTED until someone records a result. For each test, you find its execution in the cycle (or add one if the test was not planned), then record the result.

Match tests by automationKey, the identifier your test runner already uses (a spec file and title, a JUnit class and method). Set it once on each automated test case, and from then on no one maintains a mapping table.

Put the cycle in Ready to Execute first

In the app, a cycle in DRAFT does not take results. Set it to ACTIVE (Ready to Execute) before your pipeline writes into it, so what the API records matches what testers see.

1. Find the execution

CYCLE_KEY="KAN-CY-38"
TEST="checkout.spec.ts:expired-card"   # the test case's automationKey

EXEC_ID=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions" "${AUTH[@]}" \
  --data-urlencode "filter=testCycle.testCycleKey = \"$CYCLE_KEY\" AND testCase.automationKey = \"$TEST\"" \
  --data-urlencode 'fields[test_case_executions]=id' \
  | jq -r '.data[0].id // empty')

2. Read its ETag, then write the result

Updates are version-locked: you send back the ETag from your read as If-Match, so a pipeline can never silently overwrite a result someone just recorded by hand.

ETAG=$(curl -s -D - -o /dev/null "$BESTEST_BASE/api/v1/test_case_executions/$EXEC_ID" "${AUTH[@]}" \
  | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')

curl -s -X PATCH "$BESTEST_BASE/api/v1/test_case_executions/$EXEC_ID" "${AUTH[@]}" \
  -H "Content-Type: application/vnd.api+json" \
  -H "If-Match: $ETAG" \
  -d @- <<JSON
{
  "data": {
    "type": "test_case_executions",
    "id": "$EXEC_ID",
    "attributes": {
      "result": "PASSED",
      "comments": "Playwright run #1482, commit a1b2c3d"
    }
  }
}
JSON

result is one of NOT_EXECUTED, IN_PROGRESS, PASSED, FAILED, BLOCKED, SKIPPED. The server stamps the run times itself when the result changes; do not send them.

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. If the test was not planned into the cycle

Add an execution for it. The server stamps the test case's current version, so you send only the three ids:

CASE_ID=$(id_of test_cases automationKey "$TEST")
CYCLE_ID=$(id_of test_cycles testCycleKey "$CYCLE_KEY")

EXEC_ID=$(curl -s -X POST "$BESTEST_BASE/api/v1/test_case_executions" "${AUTH[@]}" \
  -H "Content-Type: application/vnd.api+json" \
  -d "{\"data\":{\"type\":\"test_case_executions\",\"attributes\":{\"projectId\":\"$PROJECT_ID\",\"testCycleId\":\"$CYCLE_ID\",\"testCaseId\":\"$CASE_ID\"}}}" \
  | jq -r '.data.id')

Then record the result exactly as in step 2.

The whole loop

Given a results.txt of automationKey result lines produced by your test reporter:

#!/usr/bin/env bash
set -euo pipefail
: "${BESTEST_BASE:?}" "${BESTEST_TOKEN:?}"
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")
API="$BESTEST_BASE/api/v1"

CYCLE_KEY="KAN-CY-38"
RUN_NOTE="CI run ${CI_PIPELINE_ID:-local}, commit ${CI_COMMIT_SHA:-unknown}"

PROJECT_ID=$(curl -s "$API/projects" "${AUTH[@]}" | jq -r '.data[0].id')
CYCLE_ID=$(curl -sG "$API/test_cycles" "${AUTH[@]}" \
  --data-urlencode "filter=testCycleKey = \"$CYCLE_KEY\"" | jq -r '.data[0].id // empty')
[ -n "$CYCLE_ID" ] || { echo "No cycle $CYCLE_KEY" >&2; exit 1; }

while read -r test result; do
  [ -z "$test" ] && continue

  exec_id=$(curl -sG "$API/test_case_executions" "${AUTH[@]}" \
    --data-urlencode "filter=testCycleId = \"$CYCLE_ID\" AND testCase.automationKey = \"$test\"" \
    --data-urlencode 'fields[test_case_executions]=id' | jq -r '.data[0].id // empty')

  if [ -z "$exec_id" ]; then
    case_id=$(curl -sG "$API/test_cases" "${AUTH[@]}" \
      --data-urlencode "filter=automationKey = \"$test\"" | jq -r '.data[0].id // empty')
    if [ -z "$case_id" ]; then
      echo "skip $test: no test case has this automationKey" >&2
      continue
    fi
    exec_id=$(curl -s -X POST "$API/test_case_executions" "${AUTH[@]}" \
      -H "Content-Type: application/vnd.api+json" \
      -d "{\"data\":{\"type\":\"test_case_executions\",\"attributes\":{\"projectId\":\"$PROJECT_ID\",\"testCycleId\":\"$CYCLE_ID\",\"testCaseId\":\"$case_id\"}}}" \
      | jq -r '.data.id')
  fi

  etag=$(curl -s -D - -o /dev/null "$API/test_case_executions/$exec_id" "${AUTH[@]}" \
    | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')

  status=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$API/test_case_executions/$exec_id" "${AUTH[@]}" \
    -H "Content-Type: application/vnd.api+json" -H "If-Match: $etag" \
    -d "{\"data\":{\"type\":\"test_case_executions\",\"id\":\"$exec_id\",\"attributes\":{\"result\":\"$result\",\"comments\":\"$RUN_NOTE\"}}}")

  echo "$test -> $result ($status)"
done < results.txt

A 412 on the PATCH means someone changed that execution between your read and your write, usually a tester recording it by hand. The loop reports it rather than retrying, which is the right default: decide whether the pipeline or the person should win.

Gate a release from CI

A read-only token is enough. One request returns the count of every result in the cycle:

curl -sG "$BESTEST_BASE/api/v1/test_case_executions/aggregate" "${AUTH[@]}" \
  --data-urlencode 'aggregations[count]=true' \
  --data-urlencode 'aggregations[groupBy][]=result' \
  --data-urlencode 'filter=testCycle.testCycleKey = "KAN-CY-38"'
{
  "data": {
    "type": "testCaseExecutions_aggregate",
    "id": "(aggregate)",
    "attributes": {
      "groups": [
        { "key": { "result": "PASSED" }, "aggregate": { "count": 412 } },
        { "key": { "result": "FAILED" }, "aggregate": { "count": 3 } },
        { "key": { "result": "NOT_EXECUTED" }, "aggregate": { "count": 18 } }
      ]
    }
  }
}

A result with no executions is simply missing from groups, so read missing as zero.

As a pipeline step

#!/usr/bin/env bash
set -euo pipefail
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")
CYCLE_KEY="${CYCLE_KEY:-KAN-CY-38}"

counts=$(curl -sG "$BESTEST_BASE/api/v1/test_case_executions/aggregate" "${AUTH[@]}" \
  --data-urlencode 'aggregations[count]=true' \
  --data-urlencode 'aggregations[groupBy][]=result' \
  --data-urlencode "filter=testCycle.testCycleKey = \"$CYCLE_KEY\"")

count() { echo "$counts" | jq "[.data.attributes.groups[] | select(.key.result == \"$1\") | .aggregate.count] | add // 0"; }

failed=$(count FAILED); blocked=$(count BLOCKED)
unrun=$(( $(count NOT_EXECUTED) + $(count IN_PROGRESS) ))

echo "$CYCLE_KEY: failed=$failed blocked=$blocked not-finished=$unrun"
if [ "$failed" -gt 0 ] || [ "$blocked" -gt 0 ] || [ "$unrun" -gt 0 ]; then
  echo "Release gate: blocked."; exit 1
fi
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 waves it through.

Requirement coverage is computed in the app and is not exposed to tokens during the beta, so build gates on executions as above, and use the app's reports for the coverage view.

Export everything for a dashboard

Read-only token. Cursor paging is the right tool for exports: it does not skip or repeat records when data changes while you read. Send an empty page[after]= to start, then follow links.next until it is gone:

#!/usr/bin/env bash
set -euo pipefail
AUTH=(-H "Authorization: Bearer $BESTEST_TOKEN" -H "Accept: application/vnd.api+json")

url="$BESTEST_BASE/api/v1/test_case_executions?page%5Bsize%5D=100&page%5Bafter%5D=&fields%5Btest_case_executions%5D=testCaseExecutionKey,result,testCaseId,testCycleId,executedAt"
: > executions.jsonl

while [ -n "$url" ]; do
  body=$(curl -sg "$url" "${AUTH[@]}")   # -g: the cursor links contain [ ]
  echo "$body" | jq -c '.data[] | {id} + .attributes' >> executions.jsonl
  next=$(echo "$body" | jq -r '.links.next // empty')
  url=${next:+$BESTEST_BASE/api/v1$next}
  echo "$(wc -l < executions.jsonl) rows"
done

fields[...] keeps each row to the five columns you asked for instead of all 25, and page[size]=100 is a quarter of the requests of the default. Each links.next is a path relative to /api/v1, used exactly as given. Its brackets are not url-encoded, which is why curl gets -g: without it, curl reads [...] as a pattern and fails.

Needs a read-and-write token. Traceability links name a source and a target by type and id:

REQ_ID=$(id_of requirements requirementKey KAN-REQ-7)
CASE_ID=$(id_of test_cases testCaseKey KAN-TC-42)

curl -s -X POST "$BESTEST_BASE/api/v1/links" "${AUTH[@]}" \
  -H "Content-Type: application/vnd.api+json" \
  -d "{\"data\":{\"type\":\"links\",\"attributes\":{\"projectId\":\"$PROJECT_ID\",\"sourceType\":\"REQUIREMENT\",\"sourceId\":\"$REQ_ID\",\"targetType\":\"TEST_CASE\",\"targetId\":\"$CASE_ID\"}}}"

The types are REQUIREMENT, TEST_CASE, TEST_CYCLE, TEST_CASE_EXECUTION, TEST_STEP_EXECUTION and ISSUE. To list a requirement's links, filter on its id as either end:

--data-urlencode "filter=sourceId = \"$REQ_ID\" OR targetId = \"$REQ_ID\""

Keep test cases in step with your repo

Needs a read-and-write token. Look the case up by automationKey; if it is missing, create it. A test case is created first and its steps after, each step carrying the case's id and its position:

TEST="checkout.spec.ts:expired-card"
CASE_ID=$(id_of test_cases automationKey "$TEST")

if [ -z "$CASE_ID" ]; then
  CASE_ID=$(curl -s -X POST "$BESTEST_BASE/api/v1/test_cases" "${AUTH[@]}" \
    -H "Content-Type: application/vnd.api+json" \
    -d "{\"data\":{\"type\":\"test_cases\",\"attributes\":{\"projectId\":\"$PROJECT_ID\",\"rank\":1,\"name\":\"Checkout rejects an expired card\",\"automationStatus\":\"AUTOMATED\",\"automationKey\":\"$TEST\"}}}" \
    | jq -r '.data.id')

  curl -s -X POST "$BESTEST_BASE/api/v1/test_steps" "${AUTH[@]}" \
    -H "Content-Type: application/vnd.api+json" \
    -d "{\"data\":{\"type\":\"test_steps\",\"attributes\":{\"projectId\":\"$PROJECT_ID\",\"testCaseId\":\"$CASE_ID\",\"sequenceNumber\":1,\"action\":\"Pay with a card that expired last month\",\"expectedResult\":\"Payment is refused with a clear message\"}}}"
fi

New test cases start in DRAFT, like cases created in the app. The worked workflow does the same with several steps and reads them back.

Poll for what changed

There are no webhooks yet, so poll. Every record has updatedAt, which turns "what changed since my last run" into a filter:

curl -sG "$BESTEST_BASE/api/v1/test_case_executions" "${AUTH[@]}" \
  --data-urlencode 'filter=updatedAt > now - 1h AND result = "FAILED"' \
  --data-urlencode 'sort=-executedAt' \
  --data-urlencode 'fields[test_case_executions]=testCaseExecutionKey,result,executedAt'

Relative times (now - 1h, now - 7d) and calendar functions (startOfDay(), startOfWeek()) keep the filter the same on every run. Not every field is sortable on every entity; the reference lists the sortable fields per endpoint, and an unknown one returns 400 naming the valid ones.

Bulk work without hitting the limit

The rate limits sit well above what the loops above need, so they do not sleep between requests. If you ever get 429, back off a few seconds and retry rather than hammering.

In rough order of preference:

  1. Use /aggregate when you only want a count. One request, no paging, grouped if you like.
  2. Ask for bigger pages. page[size]=100 is a quarter of the requests of the default 25.
  3. Ask for fewer fields. fields[type]=... shrinks every row.
  4. Use include= to pull related to-one records into the same response.
  5. For a one-off migration, use Import, Export in the app, or ask an agent over MCP, which can create records in batches.
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