TechLead

API Testing

Test REST and GraphQL APIs with supertest, contract testing with Pact, authentication flows, and response validation strategies

Why API Testing Matters

API tests verify your backend contracts independently of the UI. They are faster than E2E tests, more reliable, and catch issues in business logic, data validation, authentication, and error handling before they reach production.

πŸ”— API Testing Layers:

Unit tests for handlers, integration tests with supertest, contract tests with Pact, and load tests for performance -- each layer catches different issues.

Testing REST APIs with Supertest

// app.ts - Express app (export without listening)
import express from 'express';
const app = express();
app.use(express.json());

app.get('/api/products', async (req, res) => {
  const products = await db.products.findAll();
  res.json(products);
});

app.post('/api/products', async (req, res) => {
  const { name, price } = req.body;
  if (!name) return res.status(400).json({ error: 'Name required' });
  if (price < 0) return res.status(400).json({ error: 'Invalid price' });

  const product = await db.products.create({ name, price });
  res.status(201).json(product);
});

export default app;

// __tests__/products.test.ts
import request from 'supertest';
import app from '../app';

describe('Products API', () => {
  test('GET /api/products returns product list', async () => {
    const res = await request(app).get('/api/products');

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
    expect(res.headers['content-type']).toMatch(/json/);
  });

  test('POST /api/products creates a product', async () => {
    const newProduct = { name: 'Widget', price: 29.99 };
    const res = await request(app)
      .post('/api/products')
      .send(newProduct)
      .expect(201);

    expect(res.body).toMatchObject(newProduct);
    expect(res.body.id).toBeDefined();
  });

  test('POST /api/products validates input', async () => {
    await request(app)
      .post('/api/products')
      .send({ price: 10 })
      .expect(400);

    await request(app)
      .post('/api/products')
      .send({ name: 'Bad', price: -5 })
      .expect(400);
  });
});

Testing GraphQL APIs

// __tests__/graphql.test.ts
import request from 'supertest';
import app from '../app';

describe('GraphQL API', () => {
  test('query users returns list', async () => {
    const query = `
      query {
        users {
          id
          name
          email
        }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query })
      .expect(200);

    expect(res.body.data.users).toBeInstanceOf(Array);
    expect(res.body.data.users[0]).toHaveProperty('name');
    expect(res.body.errors).toBeUndefined();
  });

  test('mutation createUser validates input', async () => {
    const mutation = `
      mutation {
        createUser(input: { name: "", email: "invalid" }) {
          id
        }
      }
    `;

    const res = await request(app)
      .post('/graphql')
      .send({ query: mutation });

    expect(res.body.errors).toBeDefined();
    expect(res.body.errors[0].message).toContain('validation');
  });
});

Contract Testing with Pact

// Consumer-side contract test
import { PactV3 } from '@pact-foundation/pact';
import { fetchUser } from '../api-client';

const provider = new PactV3({
  consumer: 'FrontendApp',
  provider: 'UserService',
});

describe('User API Contract', () => {
  test('fetches a user by ID', async () => {
    provider
      .given('user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          id: 1,
          name: 'Alice',
          email: 'alice@example.com',
        },
      });

    await provider.executeTest(async (mockServer) => {
      const user = await fetchUser(1, mockServer.url);
      expect(user.name).toBe('Alice');
    });
  });
});

Testing Authentication Flows

describe('Authentication', () => {
  let authToken: string;

  test('login returns JWT token', async () => {
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'admin@test.com', password: 'secret' })
      .expect(200);

    expect(res.body.token).toBeDefined();
    authToken = res.body.token;
  });

  test('protected route requires auth', async () => {
    await request(app)
      .get('/api/admin/dashboard')
      .expect(401);
  });

  test('protected route works with valid token', async () => {
    const res = await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer \${authToken}`)
      .expect(200);

    expect(res.body.data).toBeDefined();
  });

  test('expired token returns 401', async () => {
    const expiredToken = generateToken({ id: 1 }, { expiresIn: '0s' });
    await request(app)
      .get('/api/admin/dashboard')
      .set('Authorization', `Bearer \${expiredToken}`)
      .expect(401);
  });
});

Key Takeaways

  • Export your Express app without calling listen() for supertest compatibility
  • Test happy paths, validation errors, and edge cases for every endpoint
  • Contract tests prevent consumer-provider integration breakdowns
  • Always test authentication and authorization as separate concerns
  • Use tools like Bruno or Postman for exploratory API testing alongside automated tests

Continue Learning