TestingAPI Testing

API Testing

Learn how to test REST and GraphQL APIs effectively using modern tools like Supertest, MSW (Mock Service Worker), Postman, and Pact for contract testing.

What is API Testing?

API testing validates the functionality, reliability, performance, and security of Application Programming Interfaces (APIs). Unlike UI testing, API testing focuses on the business logic layer, ensuring data exchange between systems works correctly.

Benefits of API Testing

  • Early Detection: Catch issues before UI implementation
  • Faster Execution: No browser rendering overhead
  • Better Coverage: Test edge cases and error scenarios easily
  • Independent: Test backend logic without frontend dependencies
  • Stability: Less brittle than UI tests

API tests are typically 10-100x faster than UI tests and can catch bugs earlier in the development cycle, making them essential for continuous integration pipelines.


Testing REST APIs

REST (Representational State Transfer) APIs are the most common API architecture. Testing REST APIs involves validating HTTP methods, status codes, headers, and response bodies.

Key Testing Aspects

AspectDescriptionExample
HTTP MethodsGET, POST, PUT, PATCH, DELETEGET /users/123
Status Codes2xx success, 4xx client errors, 5xx server errors200 OK, 404 Not Found
HeadersContent-Type, Authorization, etc.Content-Type: application/json
Request BodyJSON, XML, form data{ "name": "John" }
Response BodyData structure and valuesValidate schema and data
AuthenticationBearer tokens, API keys, OAuthAuthorization: Bearer token

Using Supertest for Node.js

Supertest is a high-level HTTP assertion library for testing Node.js HTTP servers. It works seamlessly with Express, Koa, and other frameworks.

Installation

npm install --save-dev supertest @types/supertest

Basic REST API Testing

import request from 'supertest';
import { app } from '../src/app';
 
describe('User API', () => {
  describe('GET /api/users', () => {
    it('should return all users', async () => {
      const response = await request(app)
        .get('/api/users')
        .expect('Content-Type', /json/)
        .expect(200);
 
      expect(response.body).toBeInstanceOf(Array);
      expect(response.body.length).toBeGreaterThan(0);
    });
 
    it('should filter users by role', async () => {
      const response = await request(app)
        .get('/api/users')
        .query({ role: 'admin' })
        .expect(200);
 
      expect(response.body.every((user: any) => user.role === 'admin')).toBe(true);
    });
  });
 
  describe('GET /api/users/:id', () => {
    it('should return a specific user', async () => {
      const userId = '123';
      const response = await request(app)
        .get(`/api/users/${userId}`)
        .expect(200);
 
      expect(response.body).toHaveProperty('id', userId);
      expect(response.body).toHaveProperty('name');
      expect(response.body).toHaveProperty('email');
    });
 
    it('should return 404 for non-existent user', async () => {
      const response = await request(app)
        .get('/api/users/999999')
        .expect(404);
 
      expect(response.body).toHaveProperty('error');
    });
  });
 
  describe('POST /api/users', () => {
    it('should create a new user', async () => {
      const newUser = {
        name: 'Jane Doe',
        email: 'jane@example.com',
        role: 'user'
      };
 
      const response = await request(app)
        .post('/api/users')
        .send(newUser)
        .set('Authorization', 'Bearer valid-token')
        .expect('Content-Type', /json/)
        .expect(201);
 
      expect(response.body).toHaveProperty('id');
      expect(response.body.name).toBe(newUser.name);
      expect(response.body.email).toBe(newUser.email);
    });
 
    it('should validate required fields', async () => {
      const invalidUser = {
        name: 'John'
        // Missing email
      };
 
      const response = await request(app)
        .post('/api/users')
        .send(invalidUser)
        .set('Authorization', 'Bearer valid-token')
        .expect(400);
 
      expect(response.body).toHaveProperty('errors');
      expect(response.body.errors).toContain('email is required');
    });
 
    it('should reject unauthorized requests', async () => {
      await request(app)
        .post('/api/users')
        .send({ name: 'Test', email: 'test@example.com' })
        .expect(401);
    });
  });
 
  describe('PUT /api/users/:id', () => {
    it('should update an existing user', async () => {
      const userId = '123';
      const updates = {
        name: 'Updated Name',
        email: 'updated@example.com'
      };
 
      const response = await request(app)
        .put(`/api/users/${userId}`)
        .send(updates)
        .set('Authorization', 'Bearer valid-token')
        .expect(200);
 
      expect(response.body.name).toBe(updates.name);
      expect(response.body.email).toBe(updates.email);
    });
  });
 
  describe('DELETE /api/users/:id', () => {
    it('should delete a user', async () => {
      const userId = '123';
 
      await request(app)
        .delete(`/api/users/${userId}`)
        .set('Authorization', 'Bearer valid-token')
        .expect(204);
 
      // Verify deletion
      await request(app)
        .get(`/api/users/${userId}`)
        .expect(404);
    });
  });
});
⚠️

Always test error scenarios (4xx, 5xx responses) as thoroughly as success scenarios. Many bugs hide in edge cases and error handling.

Advanced Supertest Patterns

// Testing file uploads
describe('POST /api/uploads', () => {
  it('should upload a file', async () => {
    const response = await request(app)
      .post('/api/uploads')
      .attach('file', 'test/fixtures/sample.pdf')
      .field('description', 'Test file')
      .set('Authorization', 'Bearer valid-token')
      .expect(200);
 
    expect(response.body).toHaveProperty('fileUrl');
  });
});
 
// Testing with cookies
describe('Authentication', () => {
  it('should set session cookie on login', async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({ email: 'user@example.com', password: 'password' })
      .expect(200);
 
    expect(response.headers['set-cookie']).toBeDefined();
  });
});
 
// Testing redirects
describe('GET /api/redirect', () => {
  it('should redirect to new location', async () => {
    await request(app)
      .get('/api/redirect')
      .expect(302)
      .expect('Location', '/api/new-location');
  });
});
 
// Testing with custom headers
describe('API Versioning', () => {
  it('should handle API version headers', async () => {
    const response = await request(app)
      .get('/api/users')
      .set('Accept', 'application/vnd.api.v2+json')
      .expect(200);
 
    expect(response.body.version).toBe('2.0');
  });
});

Testing GraphQL APIs

GraphQL provides a query language for APIs with a type system. Testing GraphQL requires different strategies than REST.

GraphQL Testing Fundamentals

import request from 'supertest';
import { app } from '../src/app';
 
describe('GraphQL API', () => {
  const endpoint = '/graphql';
 
  describe('Queries', () => {
    it('should fetch user by ID', async () => {
      const query = `
        query GetUser($id: ID!) {
          user(id: $id) {
            id
            name
            email
            posts {
              id
              title
            }
          }
        }
      `;
 
      const variables = { id: '123' };
 
      const response = await request(app)
        .post(endpoint)
        .send({ query, variables })
        .set('Content-Type', 'application/json')
        .expect(200);
 
      expect(response.body.data.user).toBeDefined();
      expect(response.body.data.user.id).toBe('123');
      expect(response.body.data.user.posts).toBeInstanceOf(Array);
      expect(response.body.errors).toBeUndefined();
    });
 
    it('should handle query errors gracefully', async () => {
      const query = `
        query {
          user(id: "invalid") {
            id
            name
          }
        }
      `;
 
      const response = await request(app)
        .post(endpoint)
        .send({ query })
        .expect(200);
 
      expect(response.body.errors).toBeDefined();
      expect(response.body.errors[0].message).toContain('User not found');
    });
  });
 
  describe('Mutations', () => {
    it('should create a new post', async () => {
      const mutation = `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) {
            id
            title
            content
            author {
              id
              name
            }
          }
        }
      `;
 
      const variables = {
        input: {
          title: 'Test Post',
          content: 'This is a test post',
          authorId: '123'
        }
      };
 
      const response = await request(app)
        .post(endpoint)
        .send({ mutation, variables })
        .set('Authorization', 'Bearer valid-token')
        .expect(200);
 
      expect(response.body.data.createPost).toBeDefined();
      expect(response.body.data.createPost.title).toBe('Test Post');
      expect(response.body.errors).toBeUndefined();
    });
 
    it('should validate mutation inputs', async () => {
      const mutation = `
        mutation CreatePost($input: CreatePostInput!) {
          createPost(input: $input) {
            id
          }
        }
      `;
 
      const variables = {
        input: {
          title: '', // Invalid: empty title
          content: 'Test'
        }
      };
 
      const response = await request(app)
        .post(endpoint)
        .send({ mutation, variables })
        .set('Authorization', 'Bearer valid-token')
        .expect(200);
 
      expect(response.body.errors).toBeDefined();
    });
  });
 
  describe('Subscriptions', () => {
    it('should test subscription schema', async () => {
      // Note: Testing actual subscriptions requires WebSocket support
      const query = `
        query IntrospectSubscription {
          __type(name: "Subscription") {
            fields {
              name
              type {
                name
              }
            }
          }
        }
      `;
 
      const response = await request(app)
        .post(endpoint)
        .send({ query })
        .expect(200);
 
      const subscriptionFields = response.body.data.__type.fields;
      expect(subscriptionFields.some((f: any) => f.name === 'postCreated')).toBe(true);
    });
  });
});

GraphQL returns 200 OK even for errors. Always check response.body.errors in addition to the status code.


API Mocking with MSW

Mock Service Worker (MSW) intercepts HTTP requests at the network level, providing realistic API mocking for both testing and development.

Installation

npm install --save-dev msw

Setting Up MSW

// src/mocks/handlers.ts
import { rest } from 'msw';
 
export const handlers = [
  // Mock REST API
  rest.get('/api/users', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { id: '1', name: 'John Doe', email: 'john@example.com' },
        { id: '2', name: 'Jane Smith', email: 'jane@example.com' }
      ])
    );
  }),
 
  rest.get('/api/users/:id', (req, res, ctx) => {
    const { id } = req.params;
    
    if (id === '999') {
      return res(
        ctx.status(404),
        ctx.json({ error: 'User not found' })
      );
    }
 
    return res(
      ctx.status(200),
      ctx.json({
        id,
        name: 'John Doe',
        email: 'john@example.com'
      })
    );
  }),
 
  rest.post('/api/users', async (req, res, ctx) => {
    const body = await req.json();
 
    if (!body.email) {
      return res(
        ctx.status(400),
        ctx.json({ errors: ['email is required'] })
      );
    }
 
    return res(
      ctx.status(201),
      ctx.json({
        id: '123',
        ...body
      })
    );
  }),
 
  // Mock with authentication
  rest.delete('/api/users/:id', (req, res, ctx) => {
    const authHeader = req.headers.get('Authorization');
 
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return res(
        ctx.status(401),
        ctx.json({ error: 'Unauthorized' })
      );
    }
 
    return res(ctx.status(204));
  })
];
// src/mocks/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
 
export const server = setupServer(...handlers);

Using MSW in Tests

// src/setupTests.ts
import { server } from './mocks/server';
 
// Start server before all tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
 
// Reset handlers after each test
afterEach(() => server.resetHandlers());
 
// Clean up after all tests
afterAll(() => server.close());
// src/api.test.ts
import { rest } from 'msw';
import { server } from './mocks/server';
import { fetchUser, createUser } from './api';
 
describe('API Client', () => {
  it('should fetch user successfully', async () => {
    const user = await fetchUser('1');
 
    expect(user.id).toBe('1');
    expect(user.name).toBe('John Doe');
  });
 
  it('should handle 404 errors', async () => {
    await expect(fetchUser('999')).rejects.toThrow('User not found');
  });
 
  it('should create a user', async () => {
    const newUser = {
      name: 'New User',
      email: 'new@example.com'
    };
 
    const created = await createUser(newUser);
 
    expect(created.id).toBeDefined();
    expect(created.name).toBe(newUser.name);
  });
 
  // Override handler for specific test
  it('should handle server errors', async () => {
    server.use(
      rest.get('/api/users/:id', (req, res, ctx) => {
        return res(
          ctx.status(500),
          ctx.json({ error: 'Internal server error' })
        );
      })
    );
 
    await expect(fetchUser('1')).rejects.toThrow('Internal server error');
  });
 
  // Simulate network delay
  it('should handle slow responses', async () => {
    server.use(
      rest.get('/api/users/:id', (req, res, ctx) => {
        return res(
          ctx.delay(2000),
          ctx.json({ id: '1', name: 'John Doe' })
        );
      })
    );
 
    const start = Date.now();
    await fetchUser('1');
    const duration = Date.now() - start;
 
    expect(duration).toBeGreaterThanOrEqual(2000);
  });
});

MSW works in both Node.js (for tests) and browsers (for development). You can use the same handlers for testing and local development without a real backend.

GraphQL Mocking with MSW

import { graphql } from 'msw';
 
export const graphqlHandlers = [
  graphql.query('GetUser', (req, res, ctx) => {
    const { id } = req.variables;
 
    return res(
      ctx.data({
        user: {
          id,
          name: 'John Doe',
          email: 'john@example.com',
          posts: [
            { id: '1', title: 'First Post' },
            { id: '2', title: 'Second Post' }
          ]
        }
      })
    );
  }),
 
  graphql.mutation('CreatePost', (req, res, ctx) => {
    const { input } = req.variables;
 
    if (!input.title) {
      return res(
        ctx.errors([
          { message: 'Title is required' }
        ])
      );
    }
 
    return res(
      ctx.data({
        createPost: {
          id: '123',
          ...input
        }
      })
    );
  })
];

Postman and Automated Testing

Postman is a popular API development platform that includes powerful testing capabilities.

Postman Tests

// Test for status code
pm.test("Status code is 200", function () {
    pm.response.to.have.status(200);
});
 
// Test response time
pm.test("Response time is less than 500ms", function () {
    pm.expect(pm.response.responseTime).to.be.below(500);
});
 
// Test response body
pm.test("Response has correct structure", function () {
    const jsonData = pm.response.json();
    pm.expect(jsonData).to.have.property('id');
    pm.expect(jsonData).to.have.property('name');
    pm.expect(jsonData.email).to.match(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/);
});
 
// Test headers
pm.test("Content-Type is application/json", function () {
    pm.response.to.have.header("Content-Type", "application/json");
});
 
// Save data for next request
pm.test("Save user ID", function () {
    const jsonData = pm.response.json();
    pm.environment.set("userId", jsonData.id);
});
 
// Test array length
pm.test("Returns at least 5 users", function () {
    const jsonData = pm.response.json();
    pm.expect(jsonData).to.be.an('array');
    pm.expect(jsonData.length).to.be.at.least(5);
});
 
// Schema validation
const schema = {
    type: "object",
    required: ["id", "name", "email"],
    properties: {
        id: { type: "string" },
        name: { type: "string" },
        email: { type: "string", format: "email" }
    }
};
 
pm.test("Schema is valid", function () {
    pm.response.to.have.jsonSchema(schema);
});

Running Postman Tests with Newman

# Install Newman
npm install -g newman
 
# Run collection
newman run collection.json -e environment.json
 
# Generate HTML report
newman run collection.json --reporters html --reporter-html-export report.html
 
# Run in CI/CD
newman run collection.json --reporters cli,json --reporter-json-export results.json

Newman is Postman’s CLI tool, perfect for integrating API tests into CI/CD pipelines.


Contract Testing with Pact

Contract testing ensures that services can communicate with each other. Pact enables consumer-driven contract testing.

Installation

npm install --save-dev @pact-foundation/pact

Consumer Test

import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { fetchUser } from './userService';
 
const { like, eachLike } = MatchersV3;
 
describe('User Service Contract', () => {
  const provider = new PactV3({
    consumer: 'UserWebApp',
    provider: 'UserAPI',
    dir: './pacts'
  });
 
  it('should fetch a user by ID', async () => {
    await provider
      .given('user 123 exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/api/users/123',
        headers: {
          Accept: 'application/json'
        }
      })
      .willRespondWith({
        status: 200,
        headers: {
          'Content-Type': 'application/json'
        },
        body: like({
          id: '123',
          name: 'John Doe',
          email: 'john@example.com',
          posts: eachLike({
            id: '1',
            title: 'Post Title'
          })
        })
      })
      .executeTest(async (mockServer) => {
        const user = await fetchUser('123', mockServer.url);
        
        expect(user.id).toBe('123');
        expect(user.name).toBe('John Doe');
        expect(user.posts).toHaveLength(1);
      });
  });
 
  it('should handle user not found', async () => {
    await provider
      .given('user 999 does not exist')
      .uponReceiving('a request for user 999')
      .withRequest({
        method: 'GET',
        path: '/api/users/999'
      })
      .willRespondWith({
        status: 404,
        body: like({
          error: 'User not found'
        })
      })
      .executeTest(async (mockServer) => {
        await expect(
          fetchUser('999', mockServer.url)
        ).rejects.toThrow('User not found');
      });
  });
});

Provider Verification

import { Verifier } from '@pact-foundation/pact';
import { app } from './app';
 
describe('Pact Verification', () => {
  it('should validate the expectations of UserWebApp', async () => {
    const opts = {
      provider: 'UserAPI',
      providerBaseUrl: 'http://localhost:3000',
      pactUrls: ['./pacts/userwebapp-userapi.json'],
      stateHandlers: {
        'user 123 exists': async () => {
          // Setup: Create user 123 in test database
          await setupUser({ id: '123', name: 'John Doe' });
        },
        'user 999 does not exist': async () => {
          // Setup: Ensure user 999 doesn't exist
          await deleteUser('999');
        }
      }
    };
 
    await new Verifier(opts).verifyProvider();
  });
});
⚠️

Contract testing complements but doesn’t replace integration testing. Use both for comprehensive API validation.


Best Practices

1. Test Pyramid for APIs

       ┌─────────┐
       │   E2E   │  ← Few end-to-end tests
       ├─────────┤
       │  API    │  ← Many API/integration tests
       │  Tests  │
       ├─────────┤
       │  Unit   │  ← Most unit tests
       │  Tests  │
       └─────────┘

2. Test Structure

describe('Resource API', () => {
  describe('Happy Paths', () => {
    // Test successful scenarios
  });
 
  describe('Error Cases', () => {
    // Test validation, not found, server errors
  });
 
  describe('Edge Cases', () => {
    // Test boundary conditions, special characters, etc.
  });
 
  describe('Security', () => {
    // Test authentication, authorization, input sanitization
  });
});

3. Use Test Fixtures

// fixtures/users.ts
export const validUser = {
  name: 'John Doe',
  email: 'john@example.com',
  role: 'user'
};
 
export const adminUser = {
  name: 'Admin User',
  email: 'admin@example.com',
  role: 'admin'
};
 
// In tests
import { validUser } from './fixtures/users';
 
it('should create a user', async () => {
  const response = await request(app)
    .post('/api/users')
    .send(validUser)
    .expect(201);
});

4. Test Authentication Flows

describe('Authentication', () => {
  let authToken: string;
 
  beforeAll(async () => {
    const response = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'password' });
    
    authToken = response.body.token;
  });
 
  it('should access protected route with token', async () => {
    await request(app)
      .get('/api/protected')
      .set('Authorization', `Bearer ${authToken}`)
      .expect(200);
  });
});

5. Clean Up Test Data

afterEach(async () => {
  // Clean up database
  await cleanupTestData();
});

Tools Comparison

ToolBest ForLearning CurveEcosystem
SupertestNode.js integration testsEasyExpress, Koa, Fastify
MSWAPI mocking, dev & testModerateFramework agnostic
Postman/NewmanManual & automated testingEasyStandalone
PactContract testing, microservicesSteepMulti-language
Jest + fetchSimple HTTP testsEasyAny framework
CypressE2E with API testingEasyBrowser-based


Additional Resources