Managing State
Many APIs require specific system state to test properly. For example, testing "delete product" requires a product to exist first. This tutorial walks you through a pattern for managing state in your Drift tests.
Prerequisites
Before starting, you should have:
Completed the Getting Started tutorial
A basic understanding of writing test cases
Access to modify your API server code (to add test routes)
The Problem
You want to test fetching a product by ID:
operations:
getProductByID_Success:
target: source-oas:getProductByID
parameters:
path:
id: 10
expected:
response:
statusCode: 200This test assumes product ID 10 exists. If it doesn't, the test fails unpredictably.
Solutions:
Avoid pre-populating a database with fixed test data because it is brittle and hard to maintain.
Avoid running tests in a specific order because it is fragile and does not scale.
Prefer lifecycle hooks to manage state dynamically.
The Pattern
Use a three-layer approach.
Lua hooks intercept test lifecycle events (
operation:started,operation:finished).HTTP calls to test-only routes on your API.
Test routes manipulate system state (database, mocks, etc.)
┌─────────────┐ │ Drift Test │ └──────┬──────┘ │ operation:started ▼ ┌──────────────┐ HTTP POST /test/setup/{operationId} │ Lua Script ├─────────────────────────────────────────┐ └──────────────┘ │ ▼ ┌──────────────────┐ │ Test Routes │ │ (Your API) │ └────────┬─────────┘ │ ▼ ┌──────────────────┐ │ Setup State │ │ (Database, etc) │ └──────────────────┘
Create the Lua Script
Create a file called state-management.lua with lifecycle hooks:
-- Helper function to get the operation ID from Drift's event data
local function get_operation_id(data)
if data and data[2] then
return tostring(data[2])
end
return nil
end
-- Generate auth token (customize based on your API)
local function bearer_token()
return "test-token-" .. os.date("%Y%m%d")
end
local exports = {
event_handlers = {
-- Called before each operation runs
["operation:started"] = function(event, data)
local operation_id = get_operation_id(data)
if operation_id then
print("Setting up state for: " .. operation_id)
local res = http({
url = "http://localhost:8080/test/setup/" .. operation_id,
method = "POST",
headers = {
Authorization = "Bearer " .. bearer_token(),
["Content-Type"] = "application/json"
},
body = ""
})
if res.status ~= 200 then
error("State setup failed for " .. operation_id)
end
end
end,
-- Called after each operation completes
["operation:finished"] = function(event, data)
local operation_id = get_operation_id(data)
if operation_id then
print("Cleaning up state for: " .. operation_id)
http({
url = "http://localhost:8080/test/reset",
method = "POST",
headers = {
Authorization = "Bearer " .. bearer_token()
},
body = ""
})
end
end
},
exported_functions = {
bearer_token = bearer_token
}
}
return exportsWhat this does:
Extracts the operation ID (e.g.,
getProductByID_Success) from each testCalls
/test/setup/{operationId}before the test runsCalls
/test/resetafter the test completes
Add Test Routes to Your API
Add test-only routes to your API server. These routes should only be enabled in test environments.
Create test-routes.js:
const express = require('express');
const router = express.Router();
// State setup handlers - one per operation
const setupHandlers = {
// Setup: Ensure product 10 exists
getProductByID_Success: async (db) => {
await db.products.insert({
id: 10,
name: "Test Product",
type: "CREDIT_CARD",
version: "v1"
});
},
// Setup: Ensure database is empty (so 99999 isn't found)
getProductByID_NotFound: async (db) => {
await db.products.clear();
},
// Setup: No products exist (to test 404)
deleteProduct_Success: async (db) => {
await db.products.insert({
id: 10,
name: "Product to Delete"
});
}
};
// POST /test/setup/:operationId
router.post('/test/setup/:operationId', async (req, res) => {
const { operationId } = req.params;
const handler = setupHandlers[operationId];
if (!handler) {
return res.status(400).json({
error: `No setup handler for: ${operationId}`
});
}
try {
await handler(req.app.locals.db);
res.status(200).json({
message: `State ready for ${operationId}`
});
} catch (error) {
res.status(500).json({
error: error.message
});
}
});
// POST /test/reset
router.post('/test/reset', async (req, res) => {
try {
// Clear all test data
await req.app.locals.db.products.clear();
res.status(200).json({ message: "State reset" });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;Mounting the Test Routes
In your main server file:
const app = express();
// Only enable test routes in test environment
if (process.env.NODE_ENV === 'test') {
const testRoutes = require('./test-routes');
app.use(testRoutes);
}
app.listen(8080);Wire It Up in Your Test Suite
Update your drift.yaml to use the state management script:
# yaml-language-server: $schema=https://download.pactflow.io/drift/schemas/drift.testcases.v1.schema.json
drift-testcase-file: v1
title: "Product API Tests with State Management"
sources:
- name: source-oas
path: ./openapi.yaml
- name: state-mgmt
path: ./state-management.lua
plugins:
- name: oas
- name: json
global:
auth:
apply: true
parameters:
authentication:
scheme: bearer
token: ${state-mgmt:bearer_token}
operations:
# This test will automatically have product 10 created before running
getProductByID_Success:
target: source-oas:getProductByID
parameters:
path:
id: 10
expected:
response:
statusCode: 200
# This test will have an empty database (product 99999 won't exist)
getProductByID_NotFound:
target: source-oas:getProductByID
parameters:
path:
id: 99999
expected:
response:
statusCode: 404
# This test will have product 10 created, then delete it
deleteProduct_Success:
target: source-oas:deleteProduct
parameters:
path:
id: 10
expected:
response:
statusCode: 204Run and Verify
Start your API server in test mode:
NODE_ENV=test node server.js
Run Drift tests:
drift verifier --test-files drift.yaml --server-url http://localhost:8080
Watch the output:
Setting up state for: getProductByID_Success ✓ getProductByID_Success (200ms) Cleaning up state for: getProductByID_Success Setting up state for: getProductByID_NotFound ✓ getProductByID_NotFound (150ms) Cleaning up state for: getProductByID_NotFound
What's Happening Behind the Scenes
Trace what happens for getProductByID_Success:
Drift starts the operation. Triggers
operation:startedevent withgetProductByID_Successas the operation ID.Lua hook calls setup route:
POST http://localhost:8080/test/setup/getProductByID_Success.Test route creates state. Handler inserts product with ID 10 into the database.
Drift executes the test:
GET http://localhost:8080/products/10. Product exists, returns 200.Lua hook calls reset route:
POST http://localhost:8080/test/reset. Database is cleared for the next test
Best Practices
Do's
Name operations descriptively. The operation ID becomes the state setup key.
Keep handlers simple. Each handler should do one thing.
Test in isolation. Always reset state between tests.
Guard test routes. Only enable in test environments.
Log state changes. Makes debugging much easier.
Don'ts
Don't share state between tests. Each test should be independent.
Don't skip cleanup. Always implement
operation:finished.Don't expose test routes in production. Use environment guards.
Don't make assumptions. Explicitly set up all required state.
Real-World Variations
Using Database Transactions
For SQL databases, use transactions for faster cleanup:
router.post('/test/setup/:operationId', async (req, res) => {
const transaction = await db.transaction();
req.app.locals.testTransaction = transaction;
await setupHandlers[operationId](transaction);
res.json({ message: "Ready" });
});
router.post('/test/reset', async (req, res) => {
await req.app.locals.testTransaction.rollback();
res.json({ message: "Rolled back" });
});Using Docker Containers
For complex state, spin up fresh containers:
["operation:started"] = function(event, data)
os.execute("docker-compose up -d test-db")
os.execute("docker exec test-db ./seed-data.sh")
endUsing Mock Services
For external dependencies, configure mocks:
setupHandlers = {
createPayment_Success: async (mockServer) => {
await mockServer.stub({
endpoint: "/charge",
response: { status: "approved" }
});
}
};Complete Working Example
The full implementation of this pattern is available in our example repository:
View the complete example project
This includes
Complete Lua state management script
Full test route implementation
Multiple test scenarios
Database setup/teardown examples
CI/CD integration
Next Steps
Now that you understand state management, you can:
Explore lifecycle hooks. Learn about all available events.
Use datasets. Combine state management with test data.
Set up CI/CD. Run these tests in your pipeline.
Try debugging. Troubleshoot state setup issues.
Troubleshooting
State setup fails silently.
Check that your test routes are mounted:
curl -X POST http://localhost:8080/test/setup/getProductByID_Success
State isn't cleaning up.
Verify operation:finished is being called:
["operation:finished"] = function(event, data)
print("Cleanup called for: " .. get_operation_id(data))
-- ... rest of cleanup
end