Initial public release of Apophis — invariant-driven automated API testing
This commit is contained in:
+44
-29
@@ -1,24 +1,29 @@
|
||||
import Fastify from 'fastify'
|
||||
import apophisPlugin from 'apophis-fastify'
|
||||
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',
|
||||
@@ -40,7 +45,7 @@ fastify.post('/users', {
|
||||
}
|
||||
}
|
||||
}, async (req, reply) => {
|
||||
const id = `usr-${Date.now()}`
|
||||
const id = `usr-${crypto.createHash('sha256').update(req.body.email).digest('hex').slice(0, 8)}`
|
||||
const user = { id, email: req.body.email, name: req.body.name }
|
||||
users.set(id, user)
|
||||
reply.status(201)
|
||||
@@ -48,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: {
|
||||
@@ -83,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: {
|
||||
@@ -131,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({ depth: 'standard' })
|
||||
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({ depth: 'standard', seed: 42 })
|
||||
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,21 +6,30 @@ 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()
|
||||
|
||||
// Run contract tests
|
||||
const result = await fastify.apophis.contract({ depth: 'quick' })
|
||||
const result = await fastify.apophis.contract({ runs: 10 })
|
||||
console.log(result.summary)
|
||||
|
||||
Reference in New Issue
Block a user