This guide walks you through writing tests with scrutineer, from your first test file to advanced patterns like parameterized tests and multi-step workflows with captures.
Tests in scrutineer are declarative YAML files. You describe what to test and what to assert -- the engine handles execution. Each test file is a suite containing one or more tests, and each test contains one or more steps. Steps are executed in order by the appropriate connector (HTTP, CLI, SSH, gRPC, or browser).
A complete test file has this structure:
suite: "User API" # Suite name (required)
tags: [api, smoke] # Suite-level tags (optional)
fixtures: # Reusable data (optional)
admin:
username: "admin"
password: "secret"
setup: # Runs before all tests (optional)
- connector: http
action: request
method: POST
path: /test/reset
teardown: # Runs after all tests (optional)
- connector: http
action: request
method: POST
path: /test/cleanup
tests: # Test cases (required, at least one)
- name: "Get user list"
connector: http
tags: [smoke]
steps:
- action: request
method: GET
path: /users
assert:
- field: status
operator: equal
expected: 200The suite field is a human-readable name for the test file. It appears in test output and telemetry.
Tags let you filter which tests to run. Tags can be set at the suite level (applied to all tests) and at the individual test level. Use --tags on the command line to filter:
scrutineer run --tags smoke,apiFixtures are reusable data defined once and referenced throughout the suite using ${fixture.path.to.value} syntax:
fixtures:
user:
name: "Alice"
email: "alice@example.com"
endpoints:
base: "/api/v1"
tests:
- name: "Create user"
connector: http
steps:
- action: request
method: POST
path: ${fixture.endpoints.base}/users
body:
name: ${fixture.user.name}
email: ${fixture.user.email}Setup steps run once before all tests in the suite. Teardown steps run once after all tests, even if tests fail. Both use the same step syntax as test steps.
Common uses:
- Setup: seed a database, create test users, start a service
- Teardown: clean up test data, reset state
Each test has:
name(required): a descriptive nameconnector(required at test or step level): which connector to usetags(optional): test-level tags for filteringskip(optional): set totrueto skip the teststeps(required): at least one step
A step is a single action executed by a connector. Every step requires an action field. All other fields (except assert, capture, connector, and timeout) are passed as parameters to the connector.
- action: request
method: GET
path: /healthIf a step uses a different connector than the test-level default, specify it explicitly:
tests:
- name: "Full workflow"
connector: http
steps:
- action: request # Uses http connector (test default)
method: POST
path: /users
body:
name: "Alice"
- connector: cli # Override: uses cli connector
action: exec
command: "echo 'User created'"Override the default timeout for a specific step:
- action: request
method: GET
path: /slow-endpoint
timeout: 60sAssertions verify that step results match expectations. Each assertion is a map with three fields:
| Field | Description |
|---|---|
field |
Dot-notation path into the result data |
operator |
The comparison operator |
expected |
The expected value |
assert:
- field: status
operator: equal
expected: 200
- field: body.name
operator: equal
expected: "Alice"Equality:
equal/eq-- exact equalitynot_equal/neq-- not equaldeep_equal-- deep structural equality for maps and slices
String:
contains-- substring matchnot_contains-- substring absencehas_prefix-- starts withhas_suffix-- ends withmatches-- regular expression match
Numeric:
greater_than/gt-- strictly greaterless_than/lt-- strictly lessgreater_or_equal/gte-- greater or equalless_or_equal/lte-- less or equalin_range-- within a range (requiresminandmaxoptions)
Collections:
length-- exact length of a string, slice, or mapempty-- value is emptynot_empty-- value is not emptycollection_not_empty-- collection has at least one element
HTTP-specific:
status_code-- HTTP status code checkstatus_class-- HTTP status class (e.g. "2xx", "4xx")header_equals-- header value equality (requiresheaderoption)header_contains-- header value substring (requiresheaderoption)header_exists-- header presence check
JSON:
json_path-- extract and assert a JSON path value (requiresexpectedoption)
Timing:
response_time_below-- response completed within duration
Some operators take additional options beyond field, operator, and expected:
# Range check
- field: body.age
operator: in_range
expected: null
min: 18
max: 65
# Header check
- field: headers
operator: header_equals
expected: "application/json"
header: "Content-Type"
# JSON path
- field: body
operator: json_path
expected: "$.user.name"
expected: "Alice"Captures extract values from step results and store them for use in later steps. This is how you chain multi-step workflows.
- action: request
method: POST
path: /users
body:
name: "Alice"
capture:
user_id: body.id
auth_token: body.tokenThe capture field is a map where keys are variable names and values are dot-notation paths into the result data.
Reference captured values with ${capture.variable_name}:
- action: request
method: GET
path: /users/${capture.user_id}
headers:
Authorization: "Bearer ${capture.auth_token}"
assert:
- field: status
operator: equal
expected: 200Three variable sources are available:
| Prefix | Source | Example |
|---|---|---|
fixture |
Suite fixtures section | ${fixture.user.email} |
capture |
Captured from previous steps | ${capture.user_id} |
env |
Environment variables | ${env.API_KEY} |
Variables are interpolated recursively in all string values within step parameters, including nested maps and lists.
To include a literal ${ in a value, escape with a backslash:
body: "The syntax is \${variable}"Parameterized tests let you run the same test logic with different inputs. Define parameter sets and scrutineer expands them into separate test executions.
Each parameter set has a name (used in the expanded test name) and values (a map of parameters). The expanded test name follows the pattern "Original Name [parameter set name]".
This feature uses deep copying so each expanded test instance operates on independent data.
Each connector produces a specific set of result data keys that you can assert on and capture from.
| Key | Type | Description |
|---|---|---|
status |
int | HTTP status code |
status_text |
string | Full status text (e.g. "200 OK") |
headers |
map[string][]string | Response headers |
body |
any | Parsed JSON body (or raw string) |
body_raw |
string | Raw response body string |
elapsed_ms |
float64 | Request duration in milliseconds |
| Key | Type | Description |
|---|---|---|
stdout |
string | Standard output |
stderr |
string | Standard error |
exit_code |
int | Process exit code |
command |
string | The command that was executed |
| Key | Type | Description |
|---|---|---|
exists |
bool | Whether the path exists |
size |
int64 | File size in bytes |
is_dir |
bool | Whether the path is a directory |
content |
string | File content (if regular file) |
contains |
bool | Whether content has substring |
| Key | Type | Description |
|---|---|---|
status_code |
int | gRPC status code |
status_message |
string | gRPC status message |
status_name |
string | gRPC status code name |
response |
map | Response message (unary) |
responses |
[]map | Response messages (streaming) |
metadata |
map[string][]string | Response metadata (headers) |
trailers |
map[string][]string | Response trailers |
Result data varies by action:
| Action | Key | Description |
|---|---|---|
navigate |
url |
Navigated URL |
evaluate |
value |
JavaScript return value |
get_text |
text |
Element inner text |
get_attribute |
value |
Attribute value |
screenshot |
data |
Base64-encoded image data |
screenshot |
path |
File path (if saved to disk) |
Use descriptive names with a .test.yaml suffix:
tests/
api-users.test.yaml
api-auth.test.yaml
cli-commands.test.yaml
browser-login.test.yaml
Use clear, hierarchical names:
suite: "API / Users / CRUD"
suite: "CLI / File Operations"
suite: "Browser / Authentication"Name tests after the behavior being verified:
# Good
- name: "Returns 404 for non-existent user"
- name: "Creates user with valid input"
- name: "Rejects duplicate email"
# Avoid
- name: "Test 1"
- name: "GET /users"Each test should verify one behavior. If you find a test with many unrelated assertions, split it:
# Prefer this: separate tests for separate concerns
- name: "Returns correct user data"
steps:
- action: request
method: GET
path: /users/1
assert:
- field: body.name
operator: equal
expected: "Alice"
- name: "Returns correct content type"
steps:
- action: request
method: GET
path: /users/1
assert:
- field: headers
operator: header_contains
expected: "application/json"
header: "Content-Type"# Manifest in scrutineer.yaml
tests:
- tests/api-users.test.yaml
- tests/api-auth.test.yaml
- tests/browser-login.test.yaml
# Run subsets
scrutineer run --tags smoke # just smoke tests
scrutineer run --tags api # just API tests
scrutineer run --tags browser # just browser testsLet us build a complete test suite for a user management API step by step.
Create scrutineer.yaml in your project root:
version: "0.0.1"
tests:
- tests/users.test.yaml
parallelism: 1
timeout: 10s
reporters:
- type: ansi
connectors:
http:
base_url: "http://localhost:8080"
default_headers:
Content-Type: "application/json"Create tests/users.test.yaml:
suite: "User Management API"
tags: [api, users]
fixtures:
new_user:
name: "Alice Smith"
email: "alice@example.com"
tests:
- name: "Create a new user"
connector: http
tags: [smoke]
steps:
- action: request
method: POST
path: /api/users
body:
name: ${fixture.new_user.name}
email: ${fixture.new_user.email}
assert:
- field: status
operator: equal
expected: 201
- field: body.name
operator: equal
expected: "Alice Smith"
- field: body.id
operator: not_empty
capture:
user_id: body.id
- name: "Retrieve the created user"
connector: http
steps:
- action: request
method: POST
path: /api/users
body:
name: ${fixture.new_user.name}
email: ${fixture.new_user.email}
capture:
user_id: body.id
- action: request
method: GET
path: /api/users/${capture.user_id}
assert:
- field: status
operator: equal
expected: 200
- field: body.name
operator: equal
expected: "Alice Smith"
- field: body.email
operator: equal
expected: "alice@example.com"
- name: "Delete returns 204"
connector: http
steps:
- action: request
method: POST
path: /api/users
body:
name: "Temp User"
email: "temp@example.com"
capture:
user_id: body.id
- action: request
method: DELETE
path: /api/users/${capture.user_id}
assert:
- field: status
operator: equal
expected: 204
- name: "Get non-existent user returns 404"
connector: http
tags: [smoke]
steps:
- action: request
method: GET
path: /api/users/999999
assert:
- field: status
operator: equal
expected: 404scrutineer runOr with options:
# Run only smoke tests
scrutineer run --tags smoke
# JSON output for CI
scrutineer run --format json
# Verbose with telemetry
scrutineer run --verbose- Load Testing -- run load tests against your API
- Fuzz Testing -- find edge cases with fuzz testing
- Browser Testing -- test web UIs
- CI Integration -- automate tests in your pipeline