(mess) Stuffing commit.
This commit is contained in:
+14
-14
@@ -10,11 +10,10 @@ Chaos testing applies the invariant-driven verification approach from [Invariant
|
||||
const result = await fastify.apophis.contract({
|
||||
runs: 50,
|
||||
chaos: {
|
||||
probability: 0.1, // 10% of requests get chaos
|
||||
delay: { probability: 1, minMs: 100, maxMs: 500 },
|
||||
error: { probability: 1, statusCode: 503 },
|
||||
dropout: { probability: 1 },
|
||||
corruption: { probability: 1 },
|
||||
delay: { probability: 0.1, minMs: 100, maxMs: 500 },
|
||||
error: { probability: 0.1, statusCode: 503 },
|
||||
dropout: { probability: 0.05 },
|
||||
corruption: { probability: 0.1 },
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -26,7 +25,6 @@ const result = await fastify.apophis.contract({
|
||||
Adds artificial latency. Tests timeout contracts:
|
||||
|
||||
```apostl
|
||||
timeout_occurred(this) == false
|
||||
response_time(this) < 1000
|
||||
```
|
||||
|
||||
@@ -37,7 +35,8 @@ response_time(this) < 1000
|
||||
Forces HTTP status codes. Tests error-handling contracts:
|
||||
|
||||
```apostl
|
||||
if status:503 then response_body(this).retry_after != null
|
||||
// Behavioral: when the service is unavailable, the client receives a valid retry signal
|
||||
if status:503 then response_headers(this).retry-after > 0
|
||||
```
|
||||
|
||||
### Dropout
|
||||
@@ -45,7 +44,8 @@ if status:503 then response_body(this).retry_after != null
|
||||
Simulates network failure (status 0). Tests fallback contracts:
|
||||
|
||||
```apostl
|
||||
status:200 || status:0
|
||||
// Behavioral: partial failure must still return previously cached data
|
||||
if status:0 then response_body(this).cached_data == previous(response_body(GET /cache/{request_params(this).key}))
|
||||
```
|
||||
|
||||
### Corruption
|
||||
@@ -53,7 +53,8 @@ status:200 || status:0
|
||||
Mutates response bodies. Tests parsing robustness:
|
||||
|
||||
```apostl
|
||||
response_body(this).id != null
|
||||
// Behavioral: corrupted requests maintain traceability for debugging
|
||||
if status:400 then response_body(this).request_id == request_headers(this).x-request-id
|
||||
```
|
||||
|
||||
## Corruption Strategies
|
||||
@@ -104,7 +105,7 @@ Failed tests include chaos events in diagnostics:
|
||||
```json
|
||||
{
|
||||
"statusCode": 503,
|
||||
"error": "Contract violation: status:200",
|
||||
"error": "Contract violation: if status:503 then response_headers(this).retry-after > 0",
|
||||
"chaosEvents": [
|
||||
{
|
||||
"type": "error",
|
||||
@@ -122,7 +123,7 @@ Failed tests include chaos events in diagnostics:
|
||||
|
||||
1. **Start small**: `probability: 0.05` (5% of requests)
|
||||
2. **Test one failure mode at a time**: Comment out other chaos types
|
||||
3. **Verify contracts handle chaos**: `if status:503 then response_body(this).error != null`
|
||||
3. **Verify contracts handle chaos**: `if status:503 then response_code(GET /health) == 200`
|
||||
4. **Use seeds for reproducibility**: `seed: 42` makes chaos deterministic
|
||||
|
||||
## Example: Testing Retry Logic
|
||||
@@ -131,7 +132,7 @@ Failed tests include chaos events in diagnostics:
|
||||
fastify.get('/data', {
|
||||
schema: {
|
||||
'x-ensures': [
|
||||
'if status:503 then response_headers(this).retry-after != null',
|
||||
'if status:503 then response_headers(this).retry-after > 0',
|
||||
'redirect_count(this) <= 3',
|
||||
],
|
||||
},
|
||||
@@ -140,8 +141,7 @@ fastify.get('/data', {
|
||||
// Test
|
||||
const result = await fastify.apophis.contract({
|
||||
chaos: {
|
||||
probability: 0.2,
|
||||
error: { probability: 1, statusCode: 503 },
|
||||
error: { probability: 0.2, statusCode: 503 },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
+40
-26
@@ -5,21 +5,25 @@ import crypto from 'crypto'
|
||||
const fastify = Fastify()
|
||||
|
||||
await fastify.register(apophisPlugin, {
|
||||
runtime: 'error', // Validate contracts on every request
|
||||
cleanup: true, // Auto-cleanup resources on exit
|
||||
runtime: 'error',
|
||||
cleanup: true,
|
||||
})
|
||||
|
||||
// In-memory store for demo
|
||||
const users = new Map<string, { id: string; email: string; name: string }>()
|
||||
|
||||
// CREATE — constructor
|
||||
// Behavioral: the created user must be retrievable.
|
||||
// Note: we do not write 'status:201' or 'response_body(this).id != null'.
|
||||
// The schema already validates status codes and required fields.
|
||||
// Contracts should test behavior across operations, not structure.
|
||||
fastify.post('/users', {
|
||||
schema: {
|
||||
'x-category': 'constructor',
|
||||
'x-ensures': [
|
||||
'status:201',
|
||||
'response_body(this).id != null',
|
||||
'response_body(this).email == request_body(this).email',
|
||||
// Round-trip: the server returns exactly what we sent (no mutation, no drops)
|
||||
'response_body(this) == request_body(this)',
|
||||
// Cross-route: the created user must be retrievable
|
||||
'response_code(GET /users/{response_body(this).id}) == 200',
|
||||
],
|
||||
body: {
|
||||
type: 'object',
|
||||
@@ -49,19 +53,21 @@ fastify.post('/users', {
|
||||
})
|
||||
|
||||
// READ — observer
|
||||
// Behavioral: the returned user must match the requested id.
|
||||
fastify.get('/users/:id', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-requires': ['users:id'],
|
||||
'x-requires': [
|
||||
// Precondition: the user must exist for this read to be valid
|
||||
'response_code(GET /users/{request_params(this).id}) == 200'
|
||||
],
|
||||
'x-ensures': [
|
||||
'status:200',
|
||||
// The returned id must match the requested id (no mix-up)
|
||||
'response_body(this).id == request_params(this).id',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id']
|
||||
},
|
||||
response: {
|
||||
@@ -84,19 +90,21 @@ fastify.get('/users/:id', {
|
||||
})
|
||||
|
||||
// UPDATE — mutator
|
||||
// Behavioral: after update, the change must be visible on read.
|
||||
fastify.put('/users/:id', {
|
||||
schema: {
|
||||
'x-category': 'mutator',
|
||||
'x-requires': ['users:id'],
|
||||
'x-requires': [
|
||||
// The user must exist before updating
|
||||
'response_code(GET /users/{request_params(this).id}) == 200'
|
||||
],
|
||||
'x-ensures': [
|
||||
'status:200',
|
||||
'response_body(this).id == request_params(this).id',
|
||||
// Cross-route: after update, reading the user shows the new data
|
||||
'response_body(GET /users/{request_params(this).id}).email == request_body(this).email',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id']
|
||||
},
|
||||
body: {
|
||||
@@ -132,34 +140,40 @@ fastify.put('/users/:id', {
|
||||
})
|
||||
|
||||
// DELETE — destructor
|
||||
// Behavioral: after deletion, the user must no longer exist.
|
||||
fastify.delete('/users/:id', {
|
||||
schema: {
|
||||
'x-category': 'destructor',
|
||||
'x-requires': ['users:id'],
|
||||
'x-ensures': ['status:204'],
|
||||
'x-requires': [
|
||||
// The user must exist before deleting
|
||||
'response_code(GET /users/{request_params(this).id}) == 200'
|
||||
],
|
||||
'x-ensures': [
|
||||
// After deletion, the user is gone
|
||||
'response_code(GET /users/{request_params(this).id}) == 404',
|
||||
// The deleted user data is returned (matches pre-deletion read)
|
||||
'response_body(this) == previous(response_body(GET /users/{request_params(this).id}))',
|
||||
],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }
|
||||
},
|
||||
properties: { id: { type: 'string' } },
|
||||
required: ['id']
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const user = users.get(req.params.id)
|
||||
users.delete(req.params.id)
|
||||
reply.status(204)
|
||||
reply.status(200)
|
||||
return user
|
||||
})
|
||||
|
||||
await fastify.ready()
|
||||
|
||||
// Run contract tests (all non-utility routes, property-based)
|
||||
const result = await fastify.apophis.contract({ runs: 50 })
|
||||
console.log('Contract tests:', result.summary)
|
||||
|
||||
// Run stateful tests (constructor→mutator→destructor sequences)
|
||||
const stateful = await fastify.apophis.stateful({ runs: 50, seed: 42 })
|
||||
console.log('Stateful tests:', stateful.summary)
|
||||
|
||||
// Validate a single route
|
||||
const check = await fastify.apophis.check('POST', '/users')
|
||||
console.log('POST /users check:', check.ok ? 'PASS' : 'FAIL')
|
||||
|
||||
@@ -6,18 +6,27 @@ const fastify = Fastify()
|
||||
// APOPHIS auto-registers @fastify/swagger
|
||||
await fastify.register(apophisPlugin, {})
|
||||
|
||||
fastify.get('/health', {
|
||||
// Behavioral contract: what you send is what you get back.
|
||||
// This is not a structural test — the schema already validates shape.
|
||||
// This checks that the server does not mutate or drop fields.
|
||||
fastify.post('/echo', {
|
||||
schema: {
|
||||
'x-category': 'observer',
|
||||
'x-ensures': ['status:200'],
|
||||
'x-ensures': [
|
||||
'response_body(this) == request_body(this)'
|
||||
],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: { message: { type: 'string' } }
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: 'object',
|
||||
properties: { status: { type: 'string' } }
|
||||
properties: { message: { type: 'string' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}, async () => ({ status: 'ok' }))
|
||||
}, async (req) => req.body)
|
||||
|
||||
await fastify.ready()
|
||||
|
||||
|
||||
@@ -280,8 +280,8 @@ app.get('/users/:id', {
|
||||
type: 'object',
|
||||
properties: { id: { type: 'string' } },
|
||||
'x-ensures': [
|
||||
// Standard APOSTL + extension predicates
|
||||
'status:200',
|
||||
// Behavioral: returned user must match the requested id
|
||||
'response_body(this).id == request_params(this).id',
|
||||
'graph_check(this).user.can_read_user == true',
|
||||
'partial_graph(this).tenant.accessible == true',
|
||||
],
|
||||
|
||||
@@ -284,8 +284,8 @@ fastify.get('/api/resource', {
|
||||
'x-ensures': [
|
||||
'timeout_occurred(this) == false',
|
||||
'redirect_count(this) == 0',
|
||||
'response_code(this) == 200',
|
||||
'response_body(this).id != null',
|
||||
// Behavioral: created resource must be retrievable
|
||||
'response_code(GET /api/resource/{response_body(this).id}) == 200',
|
||||
]
|
||||
}
|
||||
}, handler)
|
||||
|
||||
@@ -94,6 +94,48 @@ apophis replay --artifact reports/apophis/failure-2026-04-28T12-30-22Z.json
|
||||
|
||||
Fix the bug in your handler. Re-run verify. The failure should now pass.
|
||||
|
||||
## Behavioral vs Structural Contracts
|
||||
|
||||
APOPHIS contracts should verify **behavior**, not structure. Fastify and `@fastify/swagger` already enforce status codes, required fields, and types. Behavioral contracts catch what schemas cannot:
|
||||
|
||||
| Structural (avoid) | Behavioral (prefer) |
|
||||
|---|---|
|
||||
| `status:200` | `response_body(this) == request_body(this)` |
|
||||
| `response_body(this).id != null` | `response_code(GET /users/{response_body(this).id}) == 200` |
|
||||
| `response_body(this).name != null` | `response_body(GET /users/{id}).name == previous(response_body(this).name)` |
|
||||
|
||||
**Good behavioral patterns (from the paper):**
|
||||
- **Constructor precondition**: Resource must not exist before creation
|
||||
```apostl
|
||||
response_code(GET /users/{request_body(this).email}) == 404
|
||||
```
|
||||
- **Round-trip equality**: POST response matches the request body
|
||||
```apostl
|
||||
response_body(this) == request_body(this)
|
||||
```
|
||||
- **Cross-route retrievability**: Creating a resource makes it readable via GET
|
||||
```apostl
|
||||
response_code(GET /users/{response_body(this).id}) == 200
|
||||
```
|
||||
- **State-change verification**: DELETE causes subsequent GET to return 404
|
||||
```apostl
|
||||
response_code(GET /users/{request_params(this).id}) == 404
|
||||
```
|
||||
- **Previous state preservation**: DELETE returns the last known state
|
||||
```apostl
|
||||
response_body(this) == previous(response_body(GET /users/{request_params(this).id}))
|
||||
```
|
||||
- **Invariant over collections**: All resources satisfy a cross-resource constraint
|
||||
```apostl
|
||||
for t in response_body(GET /tournaments) :-
|
||||
response_body(GET /tournaments/{t.id}/players).length <= t.capacity
|
||||
```
|
||||
|
||||
**Anti-patterns to avoid:**
|
||||
- Checking status codes (handled by schema validation)
|
||||
- Checking field existence (handled by schema validation)
|
||||
- Checking field types (handled by schema validation)
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add more routes to your profile: `apophis verify --profile quick --routes "POST /users,PUT /users/:id"`
|
||||
|
||||
+7
-5
@@ -58,11 +58,12 @@ Scenarios are multi-step flows with capture and rebind:
|
||||
```javascript
|
||||
await fastify.apophis.scenario({
|
||||
name: 'oauth-basic',
|
||||
steps: [
|
||||
steps: [
|
||||
{
|
||||
name: 'authorize',
|
||||
request: { method: 'GET', url: '/oauth/authorize?client_id=web&response_type=code' },
|
||||
expect: ['status:200', 'response_payload(this).code != null'],
|
||||
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' }
|
||||
},
|
||||
{
|
||||
@@ -70,9 +71,10 @@ await fastify.apophis.scenario({
|
||||
request: {
|
||||
method: 'POST',
|
||||
url: '/oauth/token',
|
||||
form: { grant_type: 'authorization_code', code: '$authorize.code' }
|
||||
form: { grant_type: 'authorization_code', code: '$authorize.code', scope: 'read' }
|
||||
},
|
||||
expect: ['status:200', 'response_payload(this).access_token != null']
|
||||
// Behavioral: issued token preserves the requested scope
|
||||
expect: ['response_payload(this).scope == request_body(this).scope']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
# Quality Engines
|
||||
|
||||
APOPHIS includes three quality engines for advanced testing: chaos injection, flake detection, and mutation testing. All require `NODE_ENV=test`.
|
||||
|
||||
## Chaos Injection
|
||||
|
||||
Inject controlled failures into contract tests to validate resilience guarantees. Chaos events are generated by fast-check alongside test data, making them shrinkable — when a test fails, fast-check finds the minimal chaos event that causes the failure.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
const result = await fastify.apophis.contract({
|
||||
runs: 50,
|
||||
chaos: {
|
||||
delay: { probability: 0.1, minMs: 100, maxMs: 500 },
|
||||
error: { probability: 0.1, statusCode: 503 },
|
||||
dropout: { probability: 0.05 },
|
||||
corruption: { probability: 0.1 },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Event Types
|
||||
|
||||
| Type | Effect | Tests |
|
||||
|------|--------|-------|
|
||||
| `delay` | Artificial latency | `response_time(this) < 1000` |
|
||||
| `error` | Forces HTTP status code | Error-handling contracts |
|
||||
| `dropout` | Network failure (status 0 or 504) | Fallback contracts |
|
||||
| `corruption` | Mutates response bodies | Parsing robustness |
|
||||
|
||||
### Corruption Strategies
|
||||
|
||||
| Strategy | Effect |
|
||||
|----------|--------|
|
||||
| `truncate` | Cuts response body in half |
|
||||
| `malformed` | Returns invalid JSON (`{"broken":`) |
|
||||
| `field-corrupt` | Sets a random field to `null` |
|
||||
|
||||
### Programmatic API
|
||||
|
||||
```javascript
|
||||
import {
|
||||
applyChaosToExecution,
|
||||
createChaosEventArbitrary,
|
||||
formatChaosEvents,
|
||||
} from 'apophis-fastify'
|
||||
|
||||
// Apply pre-generated chaos events to a context
|
||||
const result = applyChaosToExecution(ctx, events)
|
||||
|
||||
// Generate deterministic chaos events
|
||||
const arb = createChaosEventArbitrary(config, contractNames)
|
||||
const events = fc.sample(arb, { numRuns: 1, seed: 42 })[0]
|
||||
|
||||
// Format for diagnostics
|
||||
console.log(formatChaosEvents(events))
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. Start small: `probability: 0.05` (5% of requests)
|
||||
2. Test one failure mode at a time
|
||||
3. Verify contracts handle chaos: `if status:503 then response_code(GET /health) == 200`
|
||||
4. Use seeds for reproducibility: `seed: 42`
|
||||
|
||||
## Flake Detection
|
||||
|
||||
Automatically rerun failing tests with varied seeds to detect non-deterministic contracts. A "flake" is a test that fails on one run but passes on another with the same or different seed.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
import { FlakeDetector } from 'apophis-fastify'
|
||||
|
||||
const detector = new FlakeDetector({
|
||||
sameSeedReruns: 1, // Rerun with same seed
|
||||
seedVariations: 3, // Try 3 additional seeds
|
||||
})
|
||||
|
||||
const report = await detector.detectFlake(
|
||||
originalFailingResult,
|
||||
async (seed) => {
|
||||
const suite = await fastify.apophis.contract({ seed })
|
||||
return { passed: suite.summary.failed === 0 }
|
||||
},
|
||||
originalSeed
|
||||
)
|
||||
|
||||
if (report.isFlaky) {
|
||||
console.log(`Flaky with ${report.confidence} confidence`)
|
||||
console.log('Reruns:', report.reruns)
|
||||
}
|
||||
```
|
||||
|
||||
### Report Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
isFlaky: true,
|
||||
confidence: 'high', // 'high' | 'medium' | 'low'
|
||||
reruns: [
|
||||
{ seed: 42, passed: false },
|
||||
{ seed: 43, passed: true },
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Confidence Scoring
|
||||
|
||||
| Pass Rate | Confidence |
|
||||
|-----------|------------|
|
||||
| 0% pass | `high` (deterministic failure) |
|
||||
| < 50% pass | `medium` |
|
||||
| >= 50% pass | `low` (likely flaky) |
|
||||
|
||||
## Mutation Testing
|
||||
|
||||
Measure contract strength by injecting synthetic bugs. A "mutation" is a small change to a contract (e.g., flip `==` to `!=`). If the test suite catches the mutation (fails), the mutation is "killed". If it passes, the mutation "survives" — indicating weak coverage.
|
||||
|
||||
### Usage
|
||||
|
||||
```javascript
|
||||
import { runMutationTesting } from 'apophis-fastify/quality/mutation'
|
||||
|
||||
const report = await runMutationTesting(fastify, {
|
||||
runs: 10,
|
||||
seed: 42,
|
||||
maxMutationsPerContract: 5,
|
||||
routes: ['/items'], // Optional: only test these routes
|
||||
})
|
||||
|
||||
console.log(`Mutation score: ${report.score}%`)
|
||||
console.log(`Killed: ${report.killed}, Survived: ${report.survived}`)
|
||||
console.log('Weak contracts:', report.weakContracts)
|
||||
```
|
||||
|
||||
### Mutation Operators
|
||||
|
||||
| Type | Example |
|
||||
|------|---------|
|
||||
| `flip-operator` | `== 201` → `!= 201` |
|
||||
| `change-number` | `== 200` → `== 201` |
|
||||
| `remove-clause` | `A && B` → `A` |
|
||||
| `negate-boolean` | `== true` → `== false` |
|
||||
| `swap-variable` | `response_body` → `request_body` |
|
||||
| `remove-ensures` | Remove one ensures clause entirely |
|
||||
|
||||
### Report Structure
|
||||
|
||||
```javascript
|
||||
{
|
||||
score: 85, // 0-100
|
||||
killed: 17,
|
||||
survived: 3,
|
||||
durationMs: 4500,
|
||||
weakContracts: ['POST /items'], // Routes where no mutations were killed
|
||||
mutations: [
|
||||
{
|
||||
mutation: {
|
||||
id: 'm0',
|
||||
route: 'POST /items',
|
||||
original: 'response_code(this) == 201',
|
||||
mutated: 'response_code(this) != 201',
|
||||
type: 'flip-operator',
|
||||
},
|
||||
killed: true,
|
||||
durationMs: 120,
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Single Mutation Test
|
||||
|
||||
Test a specific mutation without running the full suite:
|
||||
|
||||
```javascript
|
||||
import { testMutation } from 'apophis-fastify/quality/mutation'
|
||||
|
||||
const killed = await testMutation(fastify, contract, mutation, {
|
||||
runs: 10,
|
||||
seed: 42,
|
||||
})
|
||||
```
|
||||
|
||||
## Environment Guard
|
||||
|
||||
All quality engines require `NODE_ENV=test`:
|
||||
|
||||
```
|
||||
Error: chaos is only available in test environment.
|
||||
Set NODE_ENV=test to enable quality features.
|
||||
```
|
||||
|
||||
This prevents accidental execution in production or development.
|
||||
|
||||
## Integration Example
|
||||
|
||||
Run all three engines in a CI pipeline:
|
||||
|
||||
```javascript
|
||||
// 1. Standard contract tests
|
||||
const suite = await fastify.apophis.contract({ runs: 50, seed: 42 })
|
||||
|
||||
// 2. Chaos tests
|
||||
const chaosSuite = await fastify.apophis.contract({
|
||||
runs: 50,
|
||||
seed: 42,
|
||||
chaos: { error: { probability: 0.1, statusCode: 503 } },
|
||||
})
|
||||
|
||||
// 3. Flake detection on failures
|
||||
for (const test of suite.tests.filter(t => !t.ok)) {
|
||||
const report = await detector.detectFlake(test, rerunFn, 42)
|
||||
if (report.isFlaky) {
|
||||
console.warn(`Flaky test detected: ${test.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Mutation testing
|
||||
const mutationReport = await runMutationTesting(fastify, { runs: 10 })
|
||||
if (mutationReport.score < 80) {
|
||||
console.warn(`Low mutation score: ${mutationReport.score}%`)
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user