Guide

Writing Test Files

Complete guide to Tarn's YAML test format: test file structure, requests, variables, assertions, captures, cookies, multipart, and includes.

1. Test File Format

A .tarn.yaml file has this structure:

# yaml-language-server: $schema=https://raw.githubusercontent.com/NazarKalytiuk/tarn/main/schemas/v1/testfile.json
name: User API
description: CRUD tests for the user endpoint
tags: [smoke, regression]
env:
  base_url: "http://localhost:3000"

cookies: "auto"

setup:
  - include: ./shared/auth.tarn.yaml

tests:
  - name: Create user
    steps:
      - name: POST /users
        request:
          method: POST
          url: "{{ env.base_url }}/users"
          headers:
            Content-Type: "application/json"
          body:
            name: "Jane Doe"
        capture:
          user_id: "$.id"
        assert:
          status: 201
          body:
            "$.name": "Jane Doe"

teardown:
  - name: Cleanup
    request:
      method: DELETE
      url: "{{ env.base_url }}/users/{{ capture.user_id }}"

Key structural elements:

  • name — Suite name (recommended for readability)
  • description — Optional suite description
  • tags — Tags for filtering with --tag
  • env — Inline environment variables
  • cookies — Cookie handling policy: "auto" (default), "off", or "per-test"
  • setup — Steps run before all tests
  • teardown — Steps run after all tests
  • tests — Named test groups with their own steps
  • steps — Flat (unnamed) steps (use this OR tests)
Best practice

Use tests (named test groups) for most suites. Use flat steps for simple health-check files.

2. Requests

HTTP method and URL:

request:
  method: GET
  url: "{{ env.base_url }}/health"

With headers:

request:
  method: POST
  url: "{{ env.base_url }}/users"
  headers:
    Content-Type: "application/json"
    Authorization: "Bearer {{ capture.token }}"

JSON body:

request:
  method: POST
  url: "{{ env.base_url }}/users"
  body:
    name: "Jane Doe"
    email: "jane@example.com"
    role: "admin"

JSON body from a file (body_file): keep larger or shared payloads in their own .json file. The path resolves relative to the test file, the content is parsed as JSON, and it is interpolated just like an inline body. Mutually exclusive with body.

request:
  method: POST
  url: "{{ env.base_url }}/users"
  body_file: "payloads/create-user.json"

Plain text body:

request:
  method: POST
  url: "{{ env.base_url }}/echo"
  headers:
    Content-Type: "text/plain"
  body: "Hello, world!"

Form-encoded body:

request:
  method: POST
  url: "{{ env.base_url }}/login"
  form:
    username: "admin"
    password: "secret123"

GraphQL:

request:
  method: POST
  url: "{{ env.base_url }}/graphql"
  graphql:
    query: |
      query GetUser($id: ID!) {
        user(id: $id) { name email }
      }
    variables:
      id: "{{ capture.user_id }}"

Auth (Bearer token):

request:
  method: GET
  url: "{{ env.base_url }}/protected"
  auth:
    bearer: "{{ capture.token }}"

Auth (Basic):

request:
  method: GET
  url: "{{ env.base_url }}/basic-auth"
  auth:
    basic:
      username: "admin"
      password: "pass"

Query parameters are embedded in the URL:

url: "{{ env.base_url }}/users?page=1&limit=50"

Request methods: Any valid HTTP method works: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS, etc.

3. Variables & Interpolation

Variables are referenced with {{ }} syntax:

url: "{{ env.base_url }}/users/{{ capture.user_id }}"

Variable types:

  • {{ env.x }} — Environment variable from any env source
  • {{ capture.x }} — Captured value from a previous step
  • {{ params.x }} — Parameter from an include directive
  • {{ $builtin }} — Built-in function

Priority chain (highest to lowest):

  1. --var flag (CLI)
  2. Shell environment variables
  3. tarn.env.local.yaml
  4. tarn.env.{name}.yaml (with --env name)
  5. tarn.env.yaml
  6. Inline env: block in the test file

Capture transforms (pipe syntax):

url: "{{ capture.tags | first }}"
url: "{{ capture.tags | join('|') }}"
url: "{{ capture.message | split(' ') | last }}"
url: "{{ capture.count | to_int }}"
url: "{{ capture.id | to_string }}"
url: "{{ capture.text | replace('old', 'new') }}"

Available transforms: first, last, count, join(delimiter), split(delimiter), replace(from, to), to_int, to_string.

4. Environment Files

tarn.env.yaml (committed to repo, non-sensitive defaults):

base_url: "http://localhost:3000"
timeout: 10000

tarn.env.local.yaml (NOT committed, local overrides):

base_url: "http://localhost:3000"
api_key: "local-dev-key-123"

Named environments (tarn.env.prod.yaml, tarn.env.staging.yaml):

# tarn.env.prod.yaml
base_url: "https://api.example.com"
api_key: "${PROD_API_KEY}"

Run with: tarn run --env prod

Secrets management:

  • NEVER commit secrets to tarn.env.yaml
  • Use tarn.env.local.yaml for local dev secrets (add to .gitignore)
  • Pass secrets via CI env vars or --var
  • Reference shell env vars in YAML: use $VAR_NAME or ${VAR_NAME} in values

Configuration (tarn.config.yaml):

test_dir: "tests"
timeout: 10000
retries: 0
parallel: false
fail_fast_within_test: false
faker:
  seed: 42

5. Captures

Captures extract values from responses for use in subsequent steps.

JSONPath capture (shorthand):

capture:
  user_id: "$.id"
  user_name: "$.name"
  roles: "$.roles[*].name"

Extended capture sources:

capture:
  # From response header
  session_id:
    header: "set-cookie"
    regex: "session=([^;]+)"

  # From cookie
  auth_token:
    cookie: "token"

  # From whole body (text/HTML responses)
  page_content:
    body: true

  # From status code
  response_status:
    status: true

  # From final URL (after redirects)
  final_url:
    url: true

  # Explicit JSONPath
  created_at:
    jsonpath: "$.createdAt"

Optional captures and defaults:

capture:
  user_id:
    jsonpath: "$.id"
    optional: true
    default: "fallback-id"

Status-gated captures:

capture:
  user_id:
    jsonpath: "$.id"
    when:
      status: 201

Type-preserving behavior: Numbers are captured as numbers, booleans as booleans, strings as strings. JSONPath capture preserves the JSON type of the matched value.

Common mistakes
  • JSONPath $.id on an array response — use $[0].id or $[?(@.name=="Alice")].id
  • Header names are case-insensitive in lookup but should match the actual header name
  • regex: uses capture group 1 — make sure your regex has a capturing group in parentheses

6. Assertions

Full reference: Assertion Operators Reference

Status assertions:

assert:
  status: 200       # exact
  status: "2xx"      # any 2xx
  status: { in: [200, 201, 204] }
  status: { gte: 200, lt: 300 }

Body assertions via JSONPath:

assert:
  body:
    "$.name": "Jane Doe"                        # exact match
    "$.id": { type: string, not_empty: true }   # multiple operators (AND)
    "$.email": { contains: "@" }                # substring
    "$.role": { in: ["admin", "user"] }         # value in list
    "$.createdAt": { is_date: true }            # format validator
    "$.count": { gt: 0 }                        # numeric comparison
    "$.tags": { length: 3 }                     # array length
    "$.id": { is_uuid: true }                   # UUID format

Header assertions:

assert:
  headers:
    content-type: "application/json"
    x-request-id: { matches: "^[a-f0-9-]+$" }

Duration assertions:

assert:
  duration: "< 500ms"
  duration: "<= 1s"
  duration: "> 200ms"

Plain text / HTML response:

assert:
  body:
    "$": "expected plain text"
    "$": { contains: "partial match" }

Detailed reference of all 30+ assertion operators is on the Assertion Operators Reference page.

7. Built-in Functions

Full reference: Built-in Functions Reference

body:
  idempotency_key: "{{ $uuid }}"
  transaction_id: "{{ $uuid_v7 }}"
  name: "{{ $name }}"
  email: "{{ $email }}"
  random_tag: "{{ $random_hex(8) }}"
  count: "{{ $random_int(1, 100) }}"
  role: "{{ $choice(admin, user, viewer) }}"
  slug: "{{ $slug }}"
  timestamp: "{{ $now_iso }}"

Deterministic seeding:

$ TARN_FAKER_SEED=42 tarn run

Or in tarn.config.yaml:

faker:
  seed: 42

See Built-in Functions Reference for the full list.

8. Cookies

Automatic cookie handling is ON by default:

  • Set-Cookie headers are captured automatically
  • Cookie headers are sent on subsequent requests
  • The cookie jar is shared across all steps in the file

Disable cookies:

cookies: "off"

Named cookie jars (for multi-user sessions):

tests:
  - name: Admin session
    steps:
      - name: Login as admin
        request:
          method: POST
          url: "{{ env.base_url }}/login"
          body: { username: "admin", password: "admin123" }
        cookies: "admin"
      - name: Admin action
        request:
          method: GET
          url: "{{ env.base_url }}/admin/dashboard"
        cookies: "admin"

  - name: User session
    steps:
      - name: Login as user
        request:
          method: POST
          url: "{{ env.base_url }}/login"
          body: { username: "user", password: "user123" }
        cookies: "user"

Per-step override:

steps:
  - name: No cookies for this step
    request:
      method: GET
      url: "{{ env.base_url }}/public"
    cookies: "off"

Per-test reset:

cookies: "per-test"

9. Multipart Upload

steps:
  - name: Upload file
    request:
      method: POST
      url: "{{ env.base_url }}/upload"
      multipart:
        fields:
          - name: "title"
            value: "My Photo"
          - name: "description"
            value: "A sample image upload"
        files:
          - name: "file"
            path: "./fixtures/test.jpg"
            content_type: "image/jpeg"
          - name: "thumbnail"
            path: "./fixtures/thumb.png"
            content_type: "image/png"
      headers:
        Authorization: "Bearer {{ capture.token }}"

Field reference:

  • fields — Array of text fields with name and value
  • files — Array of file uploads with name, path (relative to test file), and optional content_type
Common mistakes
  • File paths are relative to the test file, NOT the working directory
  • Missing content_type defaults to application/octet-stream
  • File must exist at the specified path

10. Includes

Share reusable steps across test files:

shared/auth.tarn.yaml:

name: Auth
steps:
  - name: Login
    request:
      method: POST
      url: "{{ env.base_url }}/auth/login"
      body:
        username: "{{ params.username }}"
        password: "{{ params.password }}"
    capture:
      token: "$.token"
    assert:
      status: 200
      body:
        "$.token": { not_empty: true }

Usage in a test file:

setup:
  - include: ./shared/auth.tarn.yaml
    with:
      username: "admin"
      password: "{{ env.admin_password }}"

tests:
  - name: Authenticated flow
    steps:
      - name: Get profile
        request:
          method: GET
          url: "{{ env.base_url }}/profile"
          auth:
            bearer: "{{ capture.token }}"
        assert:
          status: 200

Include placement:

  • In setup — for shared auth, data seeding
  • In teardown — for shared cleanup
  • In steps / tests — for reusable request patterns

Parameters: Use with: to pass {{ params.x }} values to the included file.

Override: Use override: to override env/cookies/defaults from the included file.

Folder structure best practice:

tests/
  health.tarn.yaml
  users.tarn.yaml
shared/
  auth.tarn.yaml
  cleanup.tarn.yaml