Skip to content

API Testing — Unit, Integration, and Contract Testing Guide

DodaTech Updated 2026-06-23 8 min read

In this tutorial, you'll learn about API Testing. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

API testing is the practice of verifying that API endpoints work correctly through unit tests that validate logic in isolation, integration tests that verify database and service interactions, and contract tests that ensure compatibility between services.

What You'll Learn

You will learn three levels of API testing unit, integration, and Contract Testing with practical examples using Jest, supertest, pytest, and Pact for building reliable and testable APIs.

Why API Testing Matters

APIs are the backbone of modern applications. A single broken endpoint can cascade failures across multiple services and clients. Automated API testing catches regressions before deployment, ensures contract compliance between teams, and provides confidence for continuous delivery. Companies with comprehensive API testing deploy up to 50 times more frequently.

Real-World Use

DodaTech runs over 5,000 API tests across products. Doda Browser sync API has unit tests for every endpoint, integration tests against a test database, and contract tests between the sync service and DodaZIP update service. Durga Antivirus Pro runs contract tests between threat intelligence Microservices.

API Testing Learning Path

flowchart LR
  A[Manual Testing] --> B[Unit Testing]
  B --> C[Integration Testing]
  C --> D[Contract Testing]
  D --> E[E2E Testing]
  E --> F[CI/CD Pipeline]
  B:::current

  classDef current fill:#f90,color:#fff,stroke:#333,stroke-width:2px

Prerequisites

Understand RESTful Api Design Best Practices and know at least one programming language. Familiarity with Postman for API Testing helps. Basic knowledge of JavaScript Basics or Python Basics is required.

Unit Testing APIs

Unit tests verify individual functions and middleware in isolation by mocking external dependencies.

Unit Testing Express.js with Jest

// userController.test.js
const { listUsers, createUser } = require("./userController");

// Mock the model
jest.mock("../models/userModel", () => ({
  findAll: jest.fn(),
  count: jest.fn(),
  create: jest.fn()
}));

const userModel = require("../models/userModel");

describe("User Controller - listUsers", () => {
  let req, res, next;

  beforeEach(() => {
    req = { query: { page: "1", limit: "20" } };
    res = {
      json: jest.fn().mockReturnThis(),
      status: jest.fn().mockReturnThis()
    };
    next = jest.fn();
    jest.clearAllMocks();
  });

  it("returns paginated user list", async () => {
    const mockUsers = [{ id: "1", name: "Alice" }, { id: "2", name: "Bob" }];
    userModel.findAll.mockResolvedValue(mockUsers);
    userModel.count.mockResolvedValue(2);

    await listUsers(req, res, next);

    expect(res.json).toHaveBeenCalledWith({
      success: true,
      data: mockUsers,
      pagination: {
        page: 1,
        limit: 20,
        total: 2,
        pages: 1
      }
    });
  });

  it("calls next with error on failure", async () => {
    const error = new Error("Database error");
    userModel.findAll.mockRejectedValue(error);

    await listUsers(req, res, next);

    expect(next).toHaveBeenCalledWith(error);
  });
});

describe("User Controller - createUser", () => {
  it("returns 409 if email exists", async () => {
    req = {
      body: { name: "Alice", email: "alice@example.com", password: "pass123" }
    };

    userModel.findByEmail.mockResolvedValue({ id: "existing", email: "alice@example.com" });

    await createUser(req, res, next);

    expect(res.status).toHaveBeenCalledWith(409);
    expect(res.json).toHaveBeenCalledWith({
      success: false,
      error: "Email already exists"
    });
  });

  it("returns 201 for successful creation", async () => {
    const newUser = { id: "3", name: "Charlie", email: "charlie@example.com" };
    userModel.findByEmail.mockResolvedValue(null);
    userModel.create.mockResolvedValue(newUser);

    await createUser(req, res, next);

    expect(res.status).toHaveBeenCalledWith(201);
    expect(res.json).toHaveBeenCalledWith({
      success: true,
      data: newUser
    });
  });
});

Running Unit Tests

npx jest userController.test.js --verbose

Expected output:

PASS  userController.test.js
  User Controller - listUsers
    ✓ returns paginated user list (12 ms)
    ✓ calls next with error on failure (8 ms)
  User Controller - createUser
    ✓ returns 409 if email exists (5 ms)
    ✓ returns 201 for successful creation (6 ms)

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total

Integration Testing APIs

Integration tests verify that the API works with real dependencies like databases.

Integration Testing with supertest

// integration/users.test.js
const request = require("supertest");
const app = require("../src/app");
const { pool } = require("../src/config/database");

beforeAll(async () => {
  // Create test database tables
  await pool.query(`
    CREATE TABLE IF NOT EXISTS users (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      name VARCHAR(100) NOT NULL,
      email VARCHAR(255) UNIQUE NOT NULL,
      created_at TIMESTAMP DEFAULT NOW()
    )
  `);
});

afterAll(async () => {
  await pool.query("DROP TABLE IF EXISTS users");
  await pool.end();
});

beforeEach(async () => {
  await pool.query("DELETE FROM users");
});

describe("GET /api/v1/users", () => {
  it("returns empty list when no users exist", async () => {
    const res = await request(app)
      .get("/api/v1/users")
      .set("Authorization", "Bearer test-token");

    expect(res.status).toBe(200);
    expect(res.body.success).toBe(true);
    expect(res.body.data).toEqual([]);
    expect(res.body.pagination.total).toBe(0);
  });

  it("returns all users", async () => {
    await pool.query(
      "INSERT INTO users (name, email) VALUES ($1, $2), ($3, $4)",
      ["Alice", "alice"@example".com", "Bob", "bob"@example".com"]
    );

    const res = await request(app)
      .get("/api/v1/users")
      .set("Authorization", "Bearer test-token");

    expect(res.status).toBe(200);
    expect(res.body.data).toHaveLength(2);
  });
});

describe("POST /api/v1/users", () => {
  it("creates a new user", async () => {
    const res = await request(app)
      .post("/api/v1/users")
      .send({
        name: "Alice",
        email: "alice@example.com",
        password: "securepass123"
      });

    expect(res.status).toBe(201);
    expect(res.body.data.name).toBe("Alice");
    expect(res.body.data.email).toBe("alice@example.com");
  });

  it("returns 422 for invalid email", async () => {
    const res = await request(app)
      .post("/api/v1/users")
      .send({
        name: "Alice",
        email: "not-an-email",
        password: "pass123"
      });

    expect(res.status).toBe(422);
  });
});

Contract Testing with Pact

Contract tests verify that a consumer (client) and provider (API) agree on the interaction contract.

Consumer-Side Contract Test

// consumer/pact.test.js
const { PactV3, MatchersV3 } = require("@pact-foundation/pact");
const { UserApiClient } = require("./userApiClient");

const { like, eachLike } = MatchersV3;

describe("User API Contract (Consumer)", () => {
  const provider = new PactV3({
    consumer: "DodaBrowser",
    provider: "DodaTechUsersAPI"
  });

  describe("GET /users", () => {
    it("returns a list of users", async () => {
      provider
        .given("users exist")
        .uponReceiving("a request for all users")
        .withRequest({
          method: "GET",
          path: "/api/v1/users",
          headers: { Accept: "application/json" }
        })
        .willRespondWith({
          status: 200,
          headers: { "Content-Type": "application/json" },
          body: {
            success: true,
            data: eachLike({
              id: like("550e8400-e29b-41d4-a716-446655440000"),
              name: like("Alice"),
              email: like("alice@example.com")
            })
          }
        });

      return provider.executeTest(async (mockServer) => {
        const client = new UserApiClient(mockServer.url);
        const users = await client.getUsers();
        expect(users).toHaveLength(1);
        expect(users[0]).toHaveProperty("id");
      });
    });
  });
});

Provider-Side Contract Verification

// provider/pactVerification.js
const { Verifier } = require("@pact-foundation/pact");

describe("Pact Verification (Provider)", () => {
  it("validates the consumer contract", async () => {
    const verifier = new Verifier({
      provider: "DodaTechUsersAPI",
      providerBaseUrl: "http://localhost:3000",
      pactUrls: ["./pacts/DodaBrowser-DodaTechUsersAPI.json"]
    });

    return verifier.verifyProvider().then((output) => {
      console.log("Pact verification complete");
    });
  });
});

API Test Pyramid

graph TD
  A[E2E Tests - Few] --> B[Contract Tests - Some]
  B --> C[Integration Tests - More]
  C --> D[Unit Tests - Most]
  style D fill:#4CAF50,color:#fff
  style C fill:#FF9800,color:#fff
  style B fill:#2196F3,color:#fff
  style A fill:#9C27B0,color:#fff
  • Unit Tests (70 percent) — Test individual functions and middleware
  • Integration Tests (20 percent) — Test endpoints with real databases
  • Contract Tests (7 percent) — Test service-to-service contracts
  • E2E Tests (3 percent) — Test complete user workflows

Common Errors

  1. Testing implementation instead of behavior — Writing tests that check internal function calls instead of testing the actual endpoint response. Test the HTTP request and response, not the internal implementation.

  2. Not cleaning test data — Tests that leave data in the database cause failures in subsequent tests. Always clean up test data in beforeEach or afterEach hooks.

  3. Mocking too much — Mocking the database for integration tests defeats the purpose. Use a real test database for integration tests. Only mock external services.

  4. Flaky tests from shared State — Tests that depend on execution order or shared global State. Make each test independent by resetting State between tests.

  5. Not testing error paths — Testing only successful responses. Every error case (400, 401, 403, 404, 422, 500) needs a test. Error handling is where most production bugs hide.

  6. Slow test suites — Integration tests that recreate the database for every test. Use transactions that roll back after each test instead of dropping and recreating tables.

  7. No contract tests for Microservices — Assuming two services are compatible without verifying. Deploying incompatible services causes production incidents. Run contract tests in CI.

Practice Questions

  1. What is the difference between Unit Testing and Integration Testing for APIs?
  2. How do you mock external dependencies in API unit tests?
  3. What is Contract Testing and when should you use it?
  4. What is the API Test Pyramid and why is it important?
  5. How do you handle test data cleanup in integration tests?

Challenge

Set up a complete testing pipeline for a REST API. Write unit tests for all controller functions with mocked models (at least 20 tests). Write integration tests for all endpoints using a test database (at least 10 tests). Write a consumer-driven contract test using Pact. Set up GitHub Actions that run unit tests on every push, integration tests on PRs to main, and contract tests before deployment.

FAQ

Should I test the database layer separately from the API layer? Yes. Test database queries and models separately from HTTP endpoints. Database tests verify SQL correctness. API tests verify HTTP behavior. Separating them makes tests faster and easier to debug.

How many tests should I write per endpoint? Aim for at least three tests per endpoint: happy path (200 or 201), validation error (422), and authentication error (401). Add more tests for each business rule and edge case.

What is the best practice for test data factories? Use Factory libraries like FactoryBot (Ruby) or Factory_boy (Python) to generate test data. Factories make tests readable and maintainable by centralizing data creation logic.

How do I test authenticated endpoints? Create a test helper that generates a valid JWT token. Use the helper in the authorization header of test requests. Test both valid and invalid tokens.

Can I use Postman tests as a replacement for unit tests? Postman tests are great for smoke tests and manual verification but should not replace automated unit and integration tests. Postman tests run against a live server and cannot test error conditions like database failures.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro