14 KiB
Qualify Mode
Run scenario, stateful, and chaos checks against non-production Fastify services.
Qualify extends invariant-driven testing with multi-step protocol flows, stateful sequences, and controlled fault injection.
What Qualify Does
apophis qualify runs deeper testing than verify:
- Scenario execution: Multi-step protocol flows with capture/rebind
- Stateful testing: Constructor/mutator/observer/destructor sequences
- Chaos engineering: Controlled fault injection
- Adversity checks: Failure-path and edge-case validation
When to Use It
Qualify is heavier than verify. Use it where the depth is worth the runtime cost:
| Workflow | Recommended | Why |
|---|---|---|
| Pull request | No — use verify |
verify is fast (<5s for typical services) and catches behavioral regressions per-route. Qualify adds multi-minute scenario/stateful/chaos runs that are too slow for PR feedback loops. |
| Nightly | Yes | Full scenario, stateful, and chaos execution against staging. Catch protocol-level regressions that single-route verification cannot see. |
| Pre-release | Yes | Run qualify against the exact artifact that will be promoted to production. Treat a passing qualify run as a release gate for critical flows. |
| Specialist workflows | Yes | Auth flows, billing sequences, idempotency guarantees, and pagination consistency need multi-step qualification that verify cannot express. |
| Chaos engineering | Nightly or ad-hoc | Chaos injection increases latency. Run it in dedicated CI slots, not on every commit. |
Quick workflow setup
// apophis.config.js — two profiles for different cadences
export default {
mode: 'qualify',
profiles: {
'nightly': {
name: 'nightly',
mode: 'qualify',
preset: 'deep',
features: ['scenario', 'stateful', 'chaos'],
routes: [],
},
'pre-release': {
name: 'pre-release',
mode: 'qualify',
preset: 'deep',
features: ['scenario', 'stateful'],
routes: [],
},
},
presets: {
deep: { timeout: 15000, chaos: false },
},
}
Run nightly: apophis qualify --profile nightly
Run pre-release: apophis qualify --profile pre-release --format json-summary
For pull requests, use verify instead:
apophis verify --profile ci
Scenario Examples
OAuth Flow
profiles: {
'oauth-nightly': {
name: 'oauth-nightly',
mode: 'qualify',
preset: 'protocol-lab',
routes: [],
seed: 42
}
}
Run with: apophis qualify --profile oauth-nightly --seed 42
Lifecycle Deep
profiles: {
'lifecycle-deep': {
name: 'lifecycle-deep',
mode: 'qualify',
preset: 'protocol-lab',
routes: [],
seed: 42
}
}
Scenario Definitions
Scenarios are multi-step flows with capture and rebind:
await fastify.apophis.scenario({
name: 'oauth-basic',
steps: [
{
name: 'authorize',
request: { method: 'GET', url: '/oauth/authorize?client_id=web&response_type=code&state=abc123' },
// Behavioral: state parameter round-trips for CSRF protection
expect: ['response_payload(this).state == request_query(this).state'],
capture: { code: 'response_payload(this).code' }
},
{
name: 'token',
request: {
method: 'POST',
url: '/oauth/token',
form: { grant_type: 'authorization_code', code: '$authorize.code', scope: 'read' }
},
// Behavioral: issued token preserves the requested scope
expect: ['response_payload(this).scope == request_body(this).scope']
}
]
})
Scenario behavior:
- Cookie jar persists
Set-Cookievalues across steps. - Step-level
headers.cookieoverrides jar values for that step. formsendsapplication/x-www-form-urlencodedpayloads.
Stateful Testing
Stateful tests generate sequences of operations and track resources:
- Constructor: Create resources (POST)
- Mutator: Modify resources (PUT, PATCH)
- Observer: Read resources (GET)
- Destructor: Remove resources (DELETE)
APOPHIS tracks created resources and runs cleanup after test completion.
Run stateful tests via the API:
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
console.log('Stateful tests:', stateful.summary)
Route Transparency
Artifacts include executedRoutes and skippedRoutes arrays. skippedRoutes contains reasons such as mode mismatch, environment policy, or route filter exclusion.
Chaos and Adversity
Chaos testing injects controlled failures:
- Delay: Slow responses
- Error: Return error status codes
- Dropout: Connection failures
- Truncate: Truncated response bodies
- Malformed: Invalid JSON or content-type
- Field-corrupt: Random field mutation in response objects
Configure chaos in your preset:
presets: {
'protocol-lab': {
name: 'protocol-lab',
depth: 'deep',
timeout: 15000,
parallel: false,
chaos: true,
observe: false
}
}
Non-Prod Boundaries
Qualify mode is gated away from production by default:
| Environment | Scenario | Stateful | Chaos |
|---|---|---|---|
| local | enabled | enabled | enabled |
| test/CI | enabled | enabled | enabled |
| staging | enabled (blockQualify: false) | enabled (blockQualify: false) | blocked on protected routes (allowChaosOnProtected: false) |
Qualify gates are not individually gated per environment. The blockQualify flag controls all qualify execution, and allowChaosOnProtected controls chaos on protected routes.
| production | disabled by default | disabled by default | disabled by default |
Machine Output for CI
Qualify can produce large output. Use machine-readable formats and event filtering to keep CI logs manageable:
Concise formats
--format json-summary— emits a single JSON document with summary, failures, and warnings. Omits per-step traces and cleanup outcomes.--format ndjson-summary— emits three NDJSON lines:run.started,run.summary,run.completed. No per-route events.
Filtering examples
# Extract only failed routes from full ndjson
apophis qualify --profile oauth-nightly --format ndjson | jq 'select(.type == "route.failed")'
# Write artifact to disk and parse the file instead of stdout
apophis qualify --profile oauth-nightly --format json --artifact-dir reports/apophis
Recommended CI retention strategy
- Keep artifacts for 30 days in CI storage (S3, GCS, Artifactory).
- Use
--artifact-dirto write artifacts automatically. - Parse
json-summaryoutput for dashboards; keep fulljsonartifacts for debugging.
Exit Codes
| Code | Meaning |
|---|---|
| 0 | All qualifications passed |
| 1 | One or more qualifications failed |
| 2 | Safety violation or invalid config |
| 3 | Internal APOPHIS error |
| 130 | Interrupted (SIGINT) |
Config Example
// apophis.config.js
export default {
mode: 'qualify',
profile: 'oauth-nightly',
profiles: {
'oauth-nightly': {
name: 'oauth-nightly',
mode: 'qualify',
preset: 'protocol-lab',
routes: [],
seed: 42
},
'lifecycle-deep': {
name: 'lifecycle-deep',
mode: 'qualify',
preset: 'protocol-lab',
routes: [],
seed: 42
}
},
presets: {
'protocol-lab': {
name: 'protocol-lab',
runs: 200,
timeout: 15000,
parallel: false,
chaos: true,
observe: false
}
},
environments: {
local: {
name: 'local',
allowVerify: true,
allowObserve: true,
allowQualify: true,
allowChaos: true,
allowBlocking: true,
requireSink: false
},
test: {
name: 'test',
allowVerify: true,
allowObserve: true,
allowQualify: true,
allowChaos: true,
allowBlocking: true,
requireSink: false
},
staging: {
name: 'staging',
allowVerify: true,
allowObserve: true,
allowQualify: true,
allowChaos: false,
allowBlocking: false,
requireSink: true
}
}
};
Gate Execution Counts
Human output shows per-gate execution counts (scenario, stateful, chaos) so you can verify which gates actually ran.
Custom Scenarios (config-defined)
Define arbitrary multi-step scenarios directly in your apophis.config.js without writing code:
// apophis.config.js
export default {
mode: 'qualify',
scenarios: [
{
name: 'idempotency-check',
steps: [
{
name: 'create-order',
request: {
method: 'POST',
url: '/orders',
body: { product: 'widget', quantity: 3 },
},
expect: ['status:201', 'response_body(this).id != null'],
capture: { orderId: 'response_body(this).id' },
},
{
name: 'duplicate-create',
request: {
method: 'POST',
url: '/orders',
headers: { 'x-idempotency-key': 'dup-001' },
body: { product: 'widget', quantity: 3 },
},
expect: ['status:200', 'response_body(this).id == "$create-order.orderId"'],
},
],
},
{
name: 'pagination-flow',
steps: [
{
name: 'list-page-1',
request: { method: 'GET', url: '/items?page=1&limit=5' },
expect: ['status:200', 'response_body(this).items != null'],
capture: { firstPageCount: 'response_body(this).items.length' },
},
{
name: 'list-page-2',
request: { method: 'GET', url: '/items?page=2&limit=5' },
expect: ['status:200'],
},
],
},
],
profiles: {
'nightly': {
name: 'nightly',
mode: 'qualify',
preset: 'deep',
routes: ['POST /orders', 'GET /orders', 'GET /items'],
},
},
presets: {
deep: { name: 'deep', timeout: 15000, chaos: true },
},
environments: {
local: { name: 'local', allowQualify: true, allowChaos: true },
},
};
Scenario step fields:
| Field | Required | Description |
|---|---|---|
name |
yes | Human-readable step label |
request.method |
yes | HTTP method (GET, POST, PUT, DELETE, PATCH) |
request.url |
yes | URL path (e.g. /orders, /items?page=1) |
request.body |
no | JSON request body |
request.headers |
no | Custom headers (e.g. x-idempotency-key) |
expect |
yes | APOSTL formulas that must return truthy for step to pass |
capture |
no | Map of { key: "apostl_formula" } — captured values are substituted via $stepName.key in later steps |
Captured values are interpolated in subsequent step URLs, bodies, and headers using $stepName.key syntax.
Chaos Configuration
Fine-tune chaos behavior via preset fields:
presets: {
'chaos-lab': {
name: 'chaos-lab',
timeout: 10000,
chaos: true,
chaosStrategy: 'sample', // 'one' | 'all' | 'sample' | 'routes'
chaosSampleSize: 5, // routes to sample when strategy = 'sample'
chaosSampleRoutes: [ // explicit routes when strategy = 'routes'
'GET /api/users',
'POST /api/orders',
],
},
}
| Field | Default | Description |
|---|---|---|
chaosStrategy |
'one' |
Route selection strategy |
chaosSampleSize |
3 |
Routes to sample (strategy sample) |
chaosSampleRoutes |
— | Explicit route list (strategy routes) |
Artifact Interpretation
Each qualify run produces an artifact JSON document. Key sections:
executionSummary
{
"executionSummary": {
"totalPlanned": 15,
"totalExecuted": 12,
"totalPassed": 10,
"totalFailed": 2,
"scenariosRun": 3,
"statefulTestsRun": 5,
"chaosRunsRun": 4,
"chaosRoutesPlanned": 2,
"chaosRoutesExecuted": 2,
"totalSteps": 12
}
}
Use totalExecuted vs totalPlanned to see how many checks actually ran (gate gating, route filtering, chaos selection). A non-zero totalPlanned with zero totalExecuted means all gates were disabled or no routes matched.
executedRoutes / skippedRoutes
{
"executedRoutes": ["POST /orders", "GET /orders/:id", "GET /items"],
"skippedRoutes": [
{ "route": "DELETE /items/:id", "reason": "No scenario covers this route" },
{ "route": "GET /health", "reason": "Not selected by chaos strategy: one" }
]
}
executedRoutes lists every route that had at least one scenario step, stateful command, or chaos injection. skippedRoutes explains why every other discovered route was excluded.
profileGates
{
"profileGates": {
"scenario": true,
"stateful": true,
"chaos": false
}
}
Shows which gates were active. Combine with executionSummary per-gate counts to verify each active gate produced results.
stepTraces
Each entry records an individual step execution:
{
"stepTraces": [
{
"step": 0,
"name": "create-order",
"route": "POST /orders",
"durationMs": 12,
"status": "passed"
}
]
}
Filter by status to isolate failures. Look at durationMs for performance regressions.
failures
{
"failures": [
{
"route": "POST /orders",
"contract": "status:201",
"category": "runtime",
"replayCommand": "apophis replay --artifact reports/apophis/qualify-2026-05-21T...json"
}
]
}
replayCommand gives a copy-pasteable command to re-run the exact same seed with the stored artifact for triage.
Zero-Execution Guardrail
Qualify exits with code 1 if zero checks executed. This prevents silent passes when all routes are filtered out or gates are disabled.
Test Budget
The runs field in your preset controls how many property-based tests execute per route. Default is 50. Lower for faster CI feedback, higher for deeper exploration:
presets: {
'protocol-lab': {
runs: 200,
timeout: 15000
}
}