Provider Verification Strategies in Pact: State, Auth, and Middleware

Provider Verification Strategies in Pact: State, Auth, and Middleware

Writing consumer Pact tests is the easy part. Provider verification is where teams run into real friction — database state, authentication middleware, third-party dependencies, and slow test setup all become problems at once. This post covers practical strategies for handling each of these.

The Core Challenge: Provider State

Every Pact interaction includes a provider state — a description of what must be true for the provider to respond correctly. "User 42 exists" or "the cart has three items" are provider states. The consumer defines them; the provider must honour them.

State handlers are TypeScript (or JavaScript) functions that run before each interaction to set up that precondition:

stateHandlers: {
  'user 42 exists': async () => {
    await UserRepository.upsert({
      id: 42,
      name: 'Alice',
      email: 'alice@example.com',
      role: 'member',
    });
  },
  'user 42 does not exist': async () => {
    await UserRepository.delete(42);
  },
  'user 42 is an admin': async () => {
    await UserRepository.upsert({ id: 42, role: 'admin' });
  },
}

A few rules that save pain later:

State handlers must be idempotent. They can run multiple times across a test suite. Using upsert instead of insert prevents unique constraint violations.

Tear down after state, not before. It's tempting to clean the database at the start of each handler. That makes tests order-dependent. Instead, clean up in a global afterAll and seed fresh state in each handler.

Keep state descriptions in the consumer, coarse-grained. "User exists with a subscription" is better than "User has id=42, name=Alice, email=alice@example.com, subscriptionId=99, planName=pro, renewsAt=2027-01-01". The consumer should only specify what it actually needs.

Handling Authentication

Most production APIs require authentication. The provider verification test needs to inject valid credentials so the middleware doesn't reject Pact's requests.

Option 1: Disable Auth in Test Mode

The simplest approach — check for a test flag and skip auth:

// middleware/auth.ts
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
  if (process.env.PACT_TESTING === 'true') {
    // Inject a test user context
    req.user = { id: 0, role: 'test' };
    return next();
  }
  // Normal JWT verification
  verifyJWT(req, res, next);
}

This is pragmatic and fast, but it means your provider verification doesn't exercise the auth layer. If you're comfortable with that tradeoff (and most teams are — auth is better tested in dedicated middleware tests), this is fine.

Option 2: Request Filters

Pact's requestFilter lets you modify every verification request before it hits your server. Use this to inject a real or synthetic auth header:

import { Verifier } from '@pact-foundation/pact';

new Verifier({
  provider: 'user-service',
  providerBaseUrl: 'http://localhost:3001',
  pactBrokerUrl: process.env.PACT_BROKER_URL!,

  requestFilter: (req, res, next) => {
    // Inject a test JWT for all verification requests
    req.headers['authorization'] = `Bearer ${generateTestJWT({ id: 0, role: 'admin' })}`;
    next();
  },

  stateHandlers: { /* ... */ },
});

The requestFilter runs as Express middleware on Pact's proxy, so you have full control over request mutation. This is the right approach when you want auth middleware to actually run but can't control what token the consumer sends.

Option 3: Token in Provider State

For fine-grained control, generate and return a token from the state handler. The Verifier supports returning state data that gets injected into the interaction:

stateHandlers: {
  'authenticated as admin user': async () => {
    const token = await AuthService.createTestToken({ role: 'admin', userId: 99 });
    return { token }; // Returned data is available to requestFilter
  },
}

Combined with a requestFilter that reads the returned data — though this requires Pact v10+, which uses the V4 spec's stateful data passing.

Testing Against a Real Database

Provider verification against a real (test) database is the most reliable approach. In-memory fakes drift from real behaviour. Here's how to structure it cleanly:

// test/pact/setup.ts
import { Pool } from 'pg';
import { startServer } from '../../src/server';

let pool: Pool;
let server: ReturnType<typeof startServer>;

export async function setupPactEnvironment() {
  pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });

  // Run migrations
  await runMigrations(pool);

  // Start the real server with test database
  server = startServer({
    port: 3001,
    database: pool,
    authMode: 'test', // Disable external auth
  });

  return { pool, server };
}

export async function teardownPactEnvironment() {
  await pool.query('TRUNCATE users, orders, sessions RESTART IDENTITY CASCADE');
  await pool.end();
  server.close();
}
// test/pact/provider.pact.spec.ts
import { Verifier } from '@pact-foundation/pact';
import { setupPactEnvironment, teardownPactEnvironment } from './setup';

describe('Provider verification', () => {
  let env: Awaited<ReturnType<typeof setupPactEnvironment>>;

  beforeAll(async () => {
    env = await setupPactEnvironment();
  });

  afterAll(async () => {
    await teardownPactEnvironment();
  });

  it('satisfies all consumer contracts', () => {
    return new Verifier({
      provider: 'user-service',
      providerBaseUrl: 'http://localhost:3001',
      pactBrokerUrl: process.env.PACT_BROKER_URL!,
      consumerVersionSelectors: [
        { mainBranch: true },
        { deployedOrReleased: true },
      ],
      publishVerificationResult: true,
      providerVersion: process.env.GIT_COMMIT!,

      stateHandlers: {
        'user 42 exists': async () => {
          await env.pool.query(
            `INSERT INTO users (id, name, email) VALUES (42, 'Alice', 'alice@example.com')
             ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, email = EXCLUDED.email`
          );
        },
        'user 42 does not exist': async () => {
          await env.pool.query('DELETE FROM users WHERE id = 42');
        },
      },
    }).verifyProvider();
  });
});

Handling Slow or Flaky External Dependencies

Your provider probably calls other services — payment processors, notification services, analytics pipelines. For Pact verification, you want those dependencies mocked. Use dependency injection or environment-based mocking:

// src/server.ts
export function startServer(config: ServerConfig) {
  const paymentClient = config.paymentClient ?? new StripeClient(process.env.STRIPE_KEY!);
  const notifier = config.notifier ?? new SendGridNotifier(process.env.SENDGRID_KEY!);

  // ... build Express app with injected dependencies
}

In your Pact test setup:

server = startServer({
  port: 3001,
  database: pool,
  paymentClient: new MockPaymentClient(), // Never hits Stripe
  notifier: new NoopNotifier(),           // Never sends emails
});

This keeps verification tests fast and deterministic without requiring network access to third-party services.

Provider State Teardown Hooks

Some states require cleanup after the interaction runs — for example, if you're testing a "create user" endpoint, the user gets created during the test and needs to be removed before the next interaction:

stateHandlers: {
  'ready to create a new user': {
    setup: async () => {
      // Ensure email doesn't already exist
      await pool.query(`DELETE FROM users WHERE email = 'newuser@example.com'`);
    },
    teardown: async () => {
      // Remove user created by the interaction
      await pool.query(`DELETE FROM users WHERE email = 'newuser@example.com'`);
    },
  },
},

The setup/teardown object form is supported in @pact-foundation/pact v11+.

Debugging Verification Failures

When a verification fails, the error message tells you which interaction failed and what the actual response was. But for complex failures, set the log level to DEBUG:

new Verifier({
  logLevel: 'DEBUG',
  // ...
})

This outputs each interaction's request, the actual response, and the matcher comparison. Pipe it to a file and grep for FAILED to isolate the issue quickly.

For persistent failures in CI that pass locally, the usual culprit is state handler ordering — two interactions that modify the same row without proper isolation. Adding a global cleanup between interactions:

beforeEach: async () => {
  await pool.query('TRUNCATE users RESTART IDENTITY CASCADE');
},

This isn't ideal for performance but eliminates ordering bugs while you debug.

Where Contract Tests End and E2E Tests Begin

Provider verification confirms your API responds correctly to specific, documented interactions. It doesn't test the full behaviour of your service — complex queries, pagination edge cases, permission boundaries. Those belong in integration tests on the provider side, and in end-to-end tests that exercise real user flows.

HelpMeTest is well-suited for the latter — running end-to-end test suites against deployed environments using Robot Framework and Playwright under the hood, without requiring you to manage infrastructure. Contract tests handle the interface layer; HelpMeTest handles the user journey layer.

Summary

Solid provider verification requires:

  • Idempotent state handlers — safe to run multiple times without conflicts
  • Controlled auth — either skip in test mode or inject via requestFilter
  • Real database with migrations — don't fake what can be made real cheaply
  • Mocked external dependencies — keep tests fast and deterministic
  • Setup/teardown hooks — clean state between interactions when needed

Get these right and provider verification becomes a reliable, fast gate in your CI pipeline — not something you disable when it gets annoying.

Read more

Start now free