Dredd: Contract Testing for API Blueprint and OpenAPI

Dredd: Contract Testing for API Blueprint and OpenAPI

API documentation that lies is worse than no documentation. When a spec says an endpoint returns { "id": "string" } but the server actually returns { "id": 42 }, every consumer who read the docs gets a runtime surprise.

Dredd is a tool specifically built to prevent this. It reads your API description document — API Blueprint or OpenAPI — and actually sends the described requests to your running server, comparing the responses against what the spec promises. If the spec says 201, Dredd checks for 201. If the spec says the response body has a name field, Dredd verifies it exists.

This is contract testing: the spec is the contract, Dredd is the enforcement mechanism.

How Dredd Works

The core loop is simple:

  1. Dredd parses your API description (Blueprint .apib file or OpenAPI .yaml/.json)
  2. For each transaction defined in the spec, it generates an HTTP request using the documented examples
  3. It sends that request to your running server
  4. It compares the actual response against the documented expected response
  5. It reports which transactions passed and which failed

The critical insight is that Dredd relies on examples in your spec. If your spec doesn't include request body examples or expected response examples, Dredd has less to work with. Good example coverage in your spec directly translates to better Dredd test coverage.

Installation

# Install globally
npm install -g dredd

# Or per-project
npm install --save-dev dredd

# Verify installation
dredd --version

Dredd requires Node.js 12 or later.

Basic Usage with OpenAPI

Given an OpenAPI spec (openapi.yaml):

openapi: 3.0.3
info:
  title: User API
  version: 1.0.0

paths:
  /users/{userId}:
    get:
      summary: Get user by ID
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
          example: "user-123"
      responses:
        '200':
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
              example:
                id: "user-123"
                email: "alice@example.com"
                name: "Alice"
                createdAt: "2024-01-15T10:30:00Z"
        '404':
          description: User not found
          content:
            application/json:
              example:
                error: "USER_NOT_FOUND"
                message: "User with id user-123 not found"

components:
  schemas:
    User:
      type: object
      required: [id, email, createdAt]
      properties:
        id:
          type: string
        email:
          type: string
        name:
          type: string
        createdAt:
          type: string
          format: date-time

Run Dredd against a local server:

dredd openapi.yaml http://localhost:3000

Dredd will make a GET /users/user-123 request (using the example value from the spec) and verify the response.

Configuration File

For anything beyond the simplest setup, use a dredd.yml config file:

# dredd.yml
dry-run: false
hookfiles: ./dredd-hooks/*.js
language: nodejs
sandbox: false
server: npm start
server-wait: 5
init: false
custom: {}
names: false
only: []
reporter: cli
output: []
header: []
sorted: false
user: null
inline-errors: false
details: false
method: []
color: true
level: info
timestamp: false
silent: false
path: []
blueprint: openapi.yaml
endpoint: http://localhost:3000

Key options:

  • server — command to start your API server before running tests
  • server-wait — seconds to wait after starting the server
  • hookfiles — path to hook scripts (more on this below)
  • reporter — output format (cli, junit, xunit, dot, markdown, apiary)

Run with config:

dredd  # Uses dredd.yml automatically

API Blueprint Format

Dredd was originally built for API Blueprint, a Markdown-based API description format. While OpenAPI has become more common, Blueprint is still supported and has a distinctive readable syntax:

FORMAT: 1A

# User API

## Users [/users]

### Create User [POST]

Create a new user account.

+ Request (application/json)

    + Attributes
        + email: alice@example.com (string, required)
        + name: Alice Smith (string, required)
        + password: s3cr3t (string, required)

    + Body

            {
                "email": "alice@example.com",
                "name": "Alice Smith",
                "password": "s3cr3t"
            }

+ Response 201 (application/json)

    + Body

            {
                "id": "user-123",
                "email": "alice@example.com",
                "name": "Alice Smith",
                "createdAt": "2024-01-15T10:30:00Z"
            }

## User [/users/{userId}]

+ Parameters
    + userId: `user-123` (string) - The user's unique ID

### Get User [GET]

+ Response 200 (application/json)

    + Body

            {
                "id": "user-123",
                "email": "alice@example.com",
                "name": "Alice Smith",
                "createdAt": "2024-01-15T10:30:00Z"
            }

+ Response 404 (application/json)

    + Body

            {
                "error": "USER_NOT_FOUND",
                "message": "User with id user-123 not found"
            }

Hooks: Customizing Request Flow

Hooks are where Dredd becomes truly powerful. Many APIs require authentication, specific database state, or request modification that a static spec can't capture. Hooks let you inject JavaScript (or other languages) at specific points in the test lifecycle.

Hook lifecycle events:

  • beforeAll — runs once before all transactions
  • afterAll — runs once after all transactions
  • before("Transaction Name") — runs before a specific transaction
  • after("Transaction Name") — runs after a specific transaction
  • beforeEach — runs before every transaction
  • afterEach — runs after every transaction

Authentication Hook

// dredd-hooks/auth.js
const hooks = require('hooks');

// Obtain a token once before all tests run
let authToken;

hooks.beforeAll(async function(transactions, done) {
  try {
    const response = await fetch('http://localhost:3000/auth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        client_id: process.env.TEST_CLIENT_ID,
        client_secret: process.env.TEST_CLIENT_SECRET,
        grant_type: 'client_credentials'
      })
    });
    const data = await response.json();
    authToken = data.access_token;
    console.log('Auth token obtained');
    done();
  } catch (err) {
    done(err);
  }
});

// Inject the token into every request
hooks.beforeEach(function(transaction) {
  transaction.request.headers['Authorization'] = `Bearer ${authToken}`;
});

Seeding and Cleanup Hooks

// dredd-hooks/setup.js
const hooks = require('hooks');
const db = require('../test-helpers/db');

let testUserId;

hooks.before('User > Create User > Create User', async function(transaction, done) {
  // Nothing to set up — we're creating the resource
  done();
});

hooks.after('User > Create User > Create User', async function(transaction, done) {
  // Extract the created user ID for subsequent tests
  const body = JSON.parse(transaction.real.body);
  testUserId = body.id;
  done();
});

hooks.before('User > User > Get User', function(transaction) {
  // Inject the real user ID into the URL
  transaction.fullPath = transaction.fullPath.replace('user-123', testUserId);
});

hooks.after('User > User > Get User', async function(transaction, done) {
  // Clean up the test user
  await db.query('DELETE FROM users WHERE id = $1', [testUserId]);
  done();
});

// Skip a transaction that requires setup you haven't done
hooks.before('Admin > Delete All Users > Delete All Users', function(transaction) {
  transaction.skip = true;
});

Modifying Request Bodies

hooks.before('Order > Orders > Create Order', function(transaction) {
  // Replace static example with dynamic values
  const body = JSON.parse(transaction.request.body);
  body.productId = process.env.TEST_PRODUCT_ID;
  body.customerId = process.env.TEST_CUSTOMER_ID;
  transaction.request.body = JSON.stringify(body);
  transaction.request.headers['Content-Length'] = 
    Buffer.byteLength(transaction.request.body).toString();
});

Skipping and Focusing Transactions

Not every spec transaction is testable in every environment. Dredd provides mechanisms to skip selectively:

# Skip specific transactions by name
dredd openapi.yaml http://localhost:3000 \
  --skip-endpoints-without-servers \
  --only "User > Get User"

# Run only transactions matching a path pattern
dredd openapi.yaml http://localhost:3000 --method GET

# Skip specific paths
dredd openapi.yaml http://localhost:3000 \
  --path /admin  # Skip admin endpoints

In hooks, you can skip conditionally:

hooks.before('Admin > Delete Database > Delete Database', function(transaction) {
  if (process.env.CI === 'true') {
    transaction.skip = true;
    console.log('Skipping destructive test in CI');
  }
});

Reporters and Output

Dredd supports multiple output formats for different consumers:

# JUnit XML (for CI systems)
dredd openapi.yaml http://localhost:3000 \
  --reporter junit \
  --output results.xml

# Multiple reporters simultaneously
dredd openapi.yaml http://localhost:3000 \
  --reporter junit \
  --output results/junit.xml \
  --reporter cli

# Markdown report
dredd openapi.yaml http://localhost:3000 \
  --reporter markdown \
  --output results/report.md

CI/CD Integration

GitHub Actions

name: API Contract Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    paths:
      - 'src/**'
      - 'openapi.yaml'

jobs:
  contract-tests:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Run database migrations
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
        run: npm run db:migrate

      - name: Seed test data
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
        run: npm run db:seed:test

      - name: Run contract tests
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          TEST_CLIENT_ID: ${{ secrets.TEST_CLIENT_ID }}
          TEST_CLIENT_SECRET: ${{ secrets.TEST_CLIENT_SECRET }}
        run: |
          npm run start:test &
          npx wait-on http://localhost:3000/health
          npx dredd --reporter junit --output contract-results.xml

      - name: Publish contract test results
        uses: mikepenz/action-junit-report@v4
        if: always()
        with:
          report_paths: contract-results.xml
          check_name: Contract Tests

Package.json Scripts

{
  "scripts": {
    "test:contract": "dredd",
    "test:contract:ci": "dredd --reporter junit --output results/contract.xml",
    "test:contract:verbose": "dredd --level debug"
  }
}

Handling Common Issues

Problem: Dredd uses example values that don't exist in your database

Solution: Use before hooks to create the required resources, then patch the transaction URL to use real IDs:

let realUserId;

hooks.beforeAll(async (transactions, done) => {
  const resp = await createTestUser({ email: 'dredd-test@example.com' });
  realUserId = resp.id;
  done();
});

hooks.beforeEach(function(transaction) {
  // Replace all occurrences of the spec's example ID
  transaction.fullPath = transaction.fullPath.replace(/user-123/g, realUserId);
  if (transaction.request.body) {
    transaction.request.body = transaction.request.body.replace(
      /user-123/g, 
      realUserId
    );
  }
});

Problem: Spec has many 4xx examples that need specific conditions to trigger

Solution: Skip them in normal runs, test them explicitly with dedicated unit/integration tests:

hooks.beforeEach(function(transaction) {
  const status = parseInt(transaction.expected.statusCode);
  if (status >= 400 && status < 500) {
    // Only run error cases if explicitly opted in
    if (!process.env.TEST_ERROR_CASES) {
      transaction.skip = true;
    }
  }
});

Limitations to Know

Dredd validates structure, not business logic. It checks that the response matches the shape described in your spec. It won't verify that a GET /users response actually reflects database state, or that creating an order actually reduces inventory.

Request/response examples must exist in your spec. Dredd can only test what's documented with examples. Schemas without examples are tested minimally.

Authentication flows need hooks. If your API requires session cookies, CSRF tokens, or multi-step auth, hooks are the only way to handle this.

Dredd is maintained but not rapidly evolving. The project has slowed down — GitHub activity is low. It still works well, but don't expect new features. For actively-developed alternatives, look at Schemathesis (property-based, more aggressive testing) or Prism (mock server + validation).

Summary

Dredd occupies a specific and useful niche: automated enforcement that your running server matches your published spec. This is contract testing at the HTTP layer — fast, runnable in CI, and requiring zero test code for the baseline case (just a spec with good examples).

The hook system makes it practical for real APIs that need auth and database state. The JUnit reporter makes it a natural fit for existing CI pipelines. And the very act of writing Dredd-compatible specs forces you to include concrete examples, which makes your documentation more useful regardless of whether Dredd is in your pipeline.

If you're already maintaining an OpenAPI spec, adding Dredd to CI is low-effort insurance against the spec diverging from reality.

Read more

Start now free