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:

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: 200

This 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.

  1. Lua hooks intercept test lifecycle events (operation:startedoperation: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) │
                                                  └──────────────────┘

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

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: 204

Run 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:

  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

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
Publication date: