Skip to main content

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:

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:

  1. Lua hooks intercept test lifecycle events (operation:started, operation:finished)
  2. HTTP calls to test-only routes on your API
  3. 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/reset after 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:

  1. Drift starts the operation

    • Triggers operation:started event with getProductByID_Success as the operation ID
  2. Lua hook calls setup route

    • POST http://localhost:8080/test/setup/getProductByID_Success
  3. Test route creates state

    • Handler inserts product with ID 10 into the database
  4. Drift executes the test

    • GET http://localhost:8080/products/10
    • Product exists, returns 200 ✓
  5. 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:

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