Back to Technology

API Development Series Part 15: Testing & Contracts

January 31, 2026 Wasil Zafar 45 min read

Master API testing and contracts including unit testing, integration testing, contract testing with Pact, Postman/Newman automation, Prism mocking, and test-driven API development.

Table of Contents

  1. API Testing Pyramid
  2. Unit Testing APIs
  3. Integration Testing
  4. Contract Testing
  5. Postman & Newman
  6. API Mocking
Series Navigation: This is Part 15 of the 17-part API Development Series. Review Part 14: GraphQL & gRPC first.

API Testing Pyramid

The Testing Pyramid for APIs

Effective API testing requires a balanced approach across multiple layers, from fast unit tests to comprehensive end-to-end tests.

API Testing Pyramid

Reference
Layer Quantity Speed What It Tests
Unit Many (70%) Fast (ms) Business logic, validators, transformers
Integration Some (20%) Medium (s) Database, external services, middleware
Contract Key APIs Medium API shape, consumer expectations
E2E Few (10%) Slow (min) Full user journeys, critical paths

Unit Testing APIs

Testing Business Logic

// taskService.js
class TaskService {
  constructor(taskRepository) {
    this.taskRepository = taskRepository;
  }

  async createTask(data, userId) {
    if (!data.title?.trim()) {
      throw new ValidationError('Title is required');
    }
    
    const task = {
      ...data,
      id: generateId(),
      ownerId: userId,
      status: data.status || 'pending',
      createdAt: new Date()
    };
    
    return this.taskRepository.save(task);
  }
}

// taskService.test.js
const { TaskService } = require('./taskService');
const { ValidationError } = require('./errors');

describe('TaskService', () => {
  let taskService;
  let mockRepository;

  beforeEach(() => {
    mockRepository = {
      save: jest.fn(task => Promise.resolve({ ...task, id: 'task_123' })),
      findById: jest.fn()
    };
    taskService = new TaskService(mockRepository);
  });

  describe('createTask', () => {
    it('should create a task with default status', async () => {
      const task = await taskService.createTask(
        { title: 'Test task' },
        'user_1'
      );

      expect(task.title).toBe('Test task');
      expect(task.status).toBe('pending');
      expect(task.ownerId).toBe('user_1');
      expect(mockRepository.save).toHaveBeenCalledTimes(1);
    });

    it('should throw ValidationError for empty title', async () => {
      await expect(
        taskService.createTask({ title: '' }, 'user_1')
      ).rejects.toThrow(ValidationError);
    });

    it('should throw ValidationError for whitespace-only title', async () => {
      await expect(
        taskService.createTask({ title: '   ' }, 'user_1')
      ).rejects.toThrow(ValidationError);
    });
  });
});

Integration Testing

Testing with Real Database

// tasks.integration.test.js
const request = require('supertest');
const { createApp } = require('../app');
const { setupTestDb, teardownTestDb, getTestDb } = require('./testUtils');

describe('Tasks API Integration', () => {
  let app;
  let db;
  let authToken;

  beforeAll(async () => {
    db = await setupTestDb();
    app = createApp({ db });
    
    // Get auth token for tests
    const authRes = await request(app)
      .post('/auth/login')
      .send({ email: 'test@example.com', password: 'password123' });
    authToken = authRes.body.accessToken;
  });

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

  beforeEach(async () => {
    // Clean tasks table between tests
    await db.query('DELETE FROM tasks');
  });

  describe('POST /tasks', () => {
    it('should create a task and return 201', async () => {
      const response = await request(app)
        .post('/tasks')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ title: 'Integration test task', priority: 'high' });

      expect(response.status).toBe(201);
      expect(response.body.id).toBeDefined();
      expect(response.body.title).toBe('Integration test task');
      expect(response.headers.location).toMatch(/\/tasks\/task_/);

      // Verify in database
      const dbTask = await db.query(
        'SELECT * FROM tasks WHERE id = $1',
        [response.body.id]
      );
      expect(dbTask.rows[0].title).toBe('Integration test task');
    });

    it('should return 422 for invalid data', async () => {
      const response = await request(app)
        .post('/tasks')
        .set('Authorization', `Bearer ${authToken}`)
        .send({ title: '' });

      expect(response.status).toBe(422);
      expect(response.body.errors).toBeDefined();
    });

    it('should return 401 without auth token', async () => {
      const response = await request(app)
        .post('/tasks')
        .send({ title: 'Unauthorized task' });

      expect(response.status).toBe(401);
    });
  });

  describe('GET /tasks', () => {
    beforeEach(async () => {
      // Seed test data
      await db.query(`
        INSERT INTO tasks (id, title, status, owner_id)
        VALUES 
          ('task_1', 'Task 1', 'pending', 'user_1'),
          ('task_2', 'Task 2', 'completed', 'user_1'),
          ('task_3', 'Task 3', 'pending', 'user_1')
      `);
    });

    it('should return paginated tasks', async () => {
      const response = await request(app)
        .get('/tasks?limit=2')
        .set('Authorization', `Bearer ${authToken}`);

      expect(response.status).toBe(200);
      expect(response.body.data).toHaveLength(2);
      expect(response.body.pagination.hasMore).toBe(true);
    });

    it('should filter by status', async () => {
      const response = await request(app)
        .get('/tasks?status=completed')
        .set('Authorization', `Bearer ${authToken}`);

      expect(response.status).toBe(200);
      expect(response.body.data).toHaveLength(1);
      expect(response.body.data[0].status).toBe('completed');
    });
  });
});

Contract Testing

Consumer-Driven Contract Testing with Pact

Contract Testing: Ensures API consumers and providers agree on the API shape. Contracts are generated by consumers and verified by providers.
// consumer.pact.test.js - Frontend/Consumer side
const { PactV3 } = require('@pact-foundation/pact');
const { TaskApiClient } = require('./taskApiClient');

const provider = new PactV3({
  consumer: 'TaskWebApp',
  provider: 'TaskAPI'
});

describe('Task API Contract', () => {
  it('should get a task by ID', async () => {
    // Define expected interaction
    provider
      .given('a task with ID task_123 exists')
      .uponReceiving('a request to get task task_123')
      .withRequest({
        method: 'GET',
        path: '/tasks/task_123',
        headers: { 'Authorization': 'Bearer valid-token' }
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 'task_123',
          title: Matchers.string('Sample task'),
          status: Matchers.term({
            matcher: 'pending|in_progress|completed',
            generate: 'pending'
          }),
          createdAt: Matchers.iso8601DateTime()
        }
      });

    // Run test against mock provider
    await provider.executeTest(async (mockProvider) => {
      const client = new TaskApiClient(mockProvider.url);
      const task = await client.getTask('task_123');
      
      expect(task.id).toBe('task_123');
      expect(task.status).toBe('pending');
    });
  });

  it('should create a task', async () => {
    provider
      .uponReceiving('a request to create a task')
      .withRequest({
        method: 'POST',
        path: '/tasks',
        headers: {
          'Authorization': 'Bearer valid-token',
          'Content-Type': 'application/json'
        },
        body: {
          title: 'New task'
        }
      })
      .willRespondWith({
        status: 201,
        headers: {
          'Content-Type': 'application/json',
          'Location': Matchers.term({
            matcher: '/tasks/task_.*',
            generate: '/tasks/task_456'
          })
        },
        body: {
          id: Matchers.uuid(),
          title: 'New task',
          status: 'pending'
        }
      });

    await provider.executeTest(async (mockProvider) => {
      const client = new TaskApiClient(mockProvider.url);
      const task = await client.createTask({ title: 'New task' });
      
      expect(task.title).toBe('New task');
    });
  });
});

Provider Verification

// provider.pact.test.js - Backend/Provider side
const { Verifier } = require('@pact-foundation/pact');
const { createApp } = require('../app');

describe('Pact Provider Verification', () => {
  let server;

  beforeAll(async () => {
    const app = createApp();
    server = app.listen(4000);
  });

  afterAll(() => server.close());

  it('should validate the contract', async () => {
    const verifier = new Verifier({
      providerBaseUrl: 'http://localhost:4000',
      pactUrls: ['./pacts/TaskWebApp-TaskAPI.json'],
      // Or fetch from Pact Broker
      // pactBrokerUrl: 'https://pact-broker.example.com',
      
      stateHandlers: {
        'a task with ID task_123 exists': async () => {
          // Set up test data
          await db.query(`
            INSERT INTO tasks (id, title, status)
            VALUES ('task_123', 'Sample task', 'pending')
          `);
        }
      }
    });

    await verifier.verifyProvider();
  });
});

Postman & Newman

Automated API Testing with Postman

{
  "info": {
    "name": "Task API Tests",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "item": [
    {
      "name": "Create Task",
      "event": [
        {
          "listen": "test",
          "script": {
            "exec": [
              "pm.test('Status code is 201', () => {",
              "  pm.response.to.have.status(201);",
              "});",
              "",
              "pm.test('Response has task ID', () => {",
              "  const json = pm.response.json();",
              "  pm.expect(json.id).to.match(/^task_/);",
              "  pm.environment.set('taskId', json.id);",
              "});",
              "",
              "pm.test('Response time under 500ms', () => {",
              "  pm.expect(pm.response.responseTime).to.be.below(500);",
              "});"
            ]
          }
        }
      ],
      "request": {
        "method": "POST",
        "url": "{{baseUrl}}/tasks",
        "header": [
          { "key": "Authorization", "value": "Bearer {{token}}" },
          { "key": "Content-Type", "value": "application/json" }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\"title\": \"Postman test task\"}"
        }
      }
    }
  ]
}

Running with Newman in CI

# Install Newman
npm install -g newman newman-reporter-htmlextra

# Run collection
newman run postman-collection.json \
  --environment production.postman_environment.json \
  --reporters cli,htmlextra \
  --reporter-htmlextra-export ./reports/api-test-report.html \
  --bail  # Stop on first failure

API Mocking

Mock Server with Prism

# Start mock server from OpenAPI spec
npx @stoplight/prism-cli mock openapi.yaml -p 4010

# Dynamic mocking with examples
curl http://localhost:4010/tasks
# Returns example from OpenAPI spec

# Force specific response
curl -H "Prefer: code=404" http://localhost:4010/tasks/invalid

MSW for Frontend Testing

// mocks/handlers.js
import { rest } from 'msw';

export const handlers = [
  rest.get('/api/tasks', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({
        data: [
          { id: 'task_1', title: 'Mock task 1', status: 'pending' },
          { id: 'task_2', title: 'Mock task 2', status: 'completed' }
        ],
        pagination: { hasMore: false }
      })
    );
  }),

  rest.post('/api/tasks', async (req, res, ctx) => {
    const body = await req.json();
    return res(
      ctx.status(201),
      ctx.json({
        id: 'task_new',
        ...body,
        status: 'pending',
        createdAt: new Date().toISOString()
      })
    );
  })
];

Practice Exercises

Exercise 1: Unit Tests

Beginner 45 minutes
  • Write unit tests for your service layer
  • Mock repository dependencies
  • Test edge cases and error scenarios

Exercise 2: Pact Contract Tests

Intermediate 1.5 hours
  • Set up Pact for consumer contract tests
  • Define contracts for 3 key endpoints
  • Implement provider verification

Exercise 3: Postman + Newman CI

Advanced 2 hours
  • Create comprehensive Postman collection
  • Add test scripts with assertions
  • Set up Newman in GitHub Actions
  • Generate HTML test reports
Next Steps: In Part 16: CI/CD & Automation, we'll build automated pipelines with Spectral linting, GitHub Actions workflows, and Terraform deployments.
Technology