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.
What You'll Learn
By the end of this tutorial, you'll be able to:
- Set up test-specific state before each operation runs
- Clean up state after tests complete
- Build a reusable state management pattern for your API 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
Imagine you want to test fetching a product by ID:
operations:
getProductByID_Success:
target: source-oas:getProductByID
parameters:
path:
id: 10
expected:
response:
statusCode: 200
Problem: This test assumes product ID 10 exists. If it doesn't, the test fails unpredictably.
Traditional solutions:
- ❌ Pre-populate a database with fixed test data (brittle, hard to maintain)
- ❌ Run tests in a specific order (fragile, doesn't scale)
- ✅ Use lifecycle hooks to manage state dynamically
The Pattern
We'll 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) │
└──────────────────┘
Step 1: 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 exports
What this does:
- Extracts the operation ID (e.g.,
getProductByID_Success) from each test - Calls
/test/setup/{operationId}before the test runs - Calls
/test/resetafter the test completes
Step 2: Add Test Routes to Your API
Add test-only routes to your API server. These routes should only be enabled in test environments.
Node.js/Express Example
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);
Step 3: 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: 204
Step 4: Run and Verify
Start your API server in test mode:
NODE_ENV=test node server.js
Run Drift tests:
drift verify --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
Let's trace what happens for getProductByID_Success:
-
Drift starts the operation
- Triggers
operation:startedevent withgetProductByID_Successas the operation ID
- Triggers
-
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")
end
Using 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
Tests pass locally but fail in CI
Ensure NODE_ENV=test is set in your CI environment.
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