Writing REST API Tests

beginner | 90 min read | 2025.12.20

What You’ll Learn in This Tutorial

  • Setting up Jest and Supertest
  • Testing GET endpoints
  • Testing POST endpoints
  • Testing APIs that require authentication
  • Mocking databases
  • Testing best practices

Prerequisites: Node.js 18 or higher with npm/yarn/pnpm installed. Basic knowledge of Express will help with understanding.

What is Software Testing? Why is it Necessary?

History of Testing

The concept of software testing dates back to the 1950s, but modern Test-Driven Development (TDD) was systematized by Kent Beck in the late 1990s.

“Testing is not about measuring quality. Testing is about building quality in.” — Kent Beck

Why Write Tests

  1. Prevent Regression: Early detection of breaking existing functionality
  2. Documentation: Tests demonstrate how code should be used
  3. Confidence in Refactoring: Tests allow bold improvements
  4. Design Improvement: Testable code tends to have good design

The Test Pyramid

The “Test Pyramid” proposed by Martin Fowler is a model showing the types and balance of tests:

flowchart TB
    subgraph Pyramid["Test Pyramid"]
        E2E["E2E Tests (Few)<br/>Browser automation"]
        Integration["Integration Tests (Moderate)<br/>API tests, component tests"]
        Unit["Unit Tests (Many)<br/>Functions, classes"]
    end

    E2E --- Integration
    Integration --- Unit
TypeExecution SpeedMaintenance CostReliabilityCoverage
E2ESlowHighFragileWide
IntegrationModerateModerateModerateModerate
UnitFastLowStableNarrow

Reference: Martin Fowler - Test Pyramid

Where API Testing Fits

API testing is classified as “integration testing” and offers the following benefits:

  • Not affected by UI changes
  • Faster and more stable than E2E
  • Tests actual HTTP requests
  • Can test backend without frontend

Step 1: Project Setup

First, create a simple Express application to test.

Creating the Project

mkdir api-testing-tutorial
cd api-testing-tutorial
npm init -y
npm install express
npm install -D jest supertest @types/jest @types/supertest typescript ts-jest

package.json (scripts section)

{
  "scripts": {
    "start": "node dist/index.js",
    "build": "tsc",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  }
}

Jest Configuration

jest.config.js

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.test.ts'
  ],
  // Setup before and after tests
  setupFilesAfterEnv: ['<rootDir>/src/test/setup.ts'],
  // Coverage thresholds (optional)
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

Official Documentation: Jest Configuration

Step 2: Create the API to Test

src/app.ts

import express, { Express, Request, Response, NextFunction } from 'express';

const app: Express = express();
app.use(express.json());

// In-memory data store
interface User {
  id: number;
  name: string;
  email: string;
}

let users: User[] = [
  { id: 1, name: 'Taro Tanaka', email: 'tanaka@example.com' },
  { id: 2, name: 'Hanako Sato', email: 'sato@example.com' }
];

// Error handling middleware
const errorHandler = (err: Error, req: Request, res: Response, next: NextFunction) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Internal Server Error' });
};

// GET /api/users - User list
app.get('/api/users', (req: Request, res: Response) => {
  res.json(users);
});

// GET /api/users/:id - User details
app.get('/api/users/:id', (req: Request, res: Response) => {
  const user = users.find(u => u.id === parseInt(req.params.id));
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
});

// POST /api/users - Create user
app.post('/api/users', (req: Request, res: Response) => {
  const { name, email } = req.body;

  // Validation
  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email are required' });
  }

  if (!email.includes('@')) {
    return res.status(400).json({ error: 'Invalid email format' });
  }

  const newUser: User = {
    id: users.length + 1,
    name,
    email
  };
  users.push(newUser);

  res.status(201).json(newUser);
});

// PUT /api/users/:id - Update user
app.put('/api/users/:id', (req: Request, res: Response) => {
  const userId = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === userId);

  if (userIndex === -1) {
    return res.status(404).json({ error: 'User not found' });
  }

  const { name, email } = req.body;
  users[userIndex] = { ...users[userIndex], name, email };

  res.json(users[userIndex]);
});

// DELETE /api/users/:id - Delete user
app.delete('/api/users/:id', (req: Request, res: Response) => {
  const userId = parseInt(req.params.id);
  const userIndex = users.findIndex(u => u.id === userId);

  if (userIndex === -1) {
    return res.status(404).json({ error: 'User not found' });
  }

  users.splice(userIndex, 1);
  res.status(204).send();
});

app.use(errorHandler);

// Function to reset data for testing
export const resetUsers = () => {
  users = [
    { id: 1, name: 'Taro Tanaka', email: 'tanaka@example.com' },
    { id: 2, name: 'Hanako Sato', email: 'sato@example.com' }
  ];
};

export default app;

Step 3: Basic GET Tests

src/app.test.ts

import request from 'supertest';
import app, { resetUsers } from './app';

describe('Users API', () => {
  // Reset data before each test
  beforeEach(() => {
    resetUsers();
  });

  // GET /api/users tests
  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 return users with correct properties', async () => {
      const response = await request(app)
        .get('/api/users')
        .expect(200);

      const user = response.body[0];
      expect(user).toHaveProperty('id');
      expect(user).toHaveProperty('name');
      expect(user).toHaveProperty('email');
    });
  });

  // GET /api/users/:id tests
  describe('GET /api/users/:id', () => {
    it('should return a user by id', async () => {
      const response = await request(app)
        .get('/api/users/1')
        .expect(200);

      expect(response.body.id).toBe(1);
      expect(response.body).toHaveProperty('name');
    });

    it('should return 404 for non-existent user', async () => {
      const response = await request(app)
        .get('/api/users/999')
        .expect(404);

      expect(response.body.error).toBe('User not found');
    });

    it('should handle invalid id format', async () => {
      const response = await request(app)
        .get('/api/users/invalid')
        .expect(404);
    });
  });
});

Running Tests

npm test

# Example output
# PASS  src/app.test.ts
#   Users API
#     GET /api/users
#       ✓ should return all users (25 ms)
#       ✓ should return users with correct properties (8 ms)
#     GET /api/users/:id
#       ✓ should return a user by id (5 ms)
#       ✓ should return 404 for non-existent user (4 ms)

Step 4: POST Tests

Add tests for data creation.

describe('POST /api/users', () => {
  it('should create a new user', async () => {
    const newUser = {
      name: 'Jiro Yamada',
      email: 'yamada@example.com'
    };

    const response = await request(app)
      .post('/api/users')
      .send(newUser)
      .expect('Content-Type', /json/)
      .expect(201);

    expect(response.body).toMatchObject(newUser);
    expect(response.body).toHaveProperty('id');
  });

  it('should return 400 when name is missing', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ email: 'test@example.com' })
      .expect(400);

    expect(response.body.error).toContain('required');
  });

  it('should return 400 when email is missing', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Test User' })
      .expect(400);

    expect(response.body.error).toContain('required');
  });

  it('should return 400 for invalid email format', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Test User', email: 'invalid-email' })
      .expect(400);

    expect(response.body.error).toContain('format');
  });
});

Step 5: PUT/DELETE Tests

describe('PUT /api/users/:id', () => {
  it('should update an existing user', async () => {
    const updatedData = {
      name: 'Taro Tanaka (Updated)',
      email: 'tanaka-updated@example.com'
    };

    const response = await request(app)
      .put('/api/users/1')
      .send(updatedData)
      .expect(200);

    expect(response.body.name).toBe(updatedData.name);
    expect(response.body.email).toBe(updatedData.email);
  });

  it('should return 404 for non-existent user', async () => {
    await request(app)
      .put('/api/users/999')
      .send({ name: 'test', email: 'test@example.com' })
      .expect(404);
  });
});

describe('DELETE /api/users/:id', () => {
  it('should delete an existing user', async () => {
    await request(app)
      .delete('/api/users/1')
      .expect(204);

    // Verify deletion
    await request(app)
      .get('/api/users/1')
      .expect(404);
  });

  it('should return 404 for non-existent user', async () => {
    await request(app)
      .delete('/api/users/999')
      .expect(404);
  });
});

Step 6: Test Patterns and Best Practices

AAA Pattern (Arrange-Act-Assert)

Structure tests in three phases:

it('should create a new user', async () => {
  // Arrange: Prepare test data
  const newUser = {
    name: 'Jiro Yamada',
    email: 'yamada@example.com'
  };

  // Act: Execute the test target
  const response = await request(app)
    .post('/api/users')
    .send(newUser);

  // Assert: Verify results
  expect(response.status).toBe(201);
  expect(response.body).toMatchObject(newUser);
});

Maintaining Test Independence

describe('Users API', () => {
  // Reset data before each test
  beforeEach(() => {
    resetUsers();
  });

  // Cleanup after each test (if needed)
  afterEach(() => {
    // Reset mocks, etc.
    jest.clearAllMocks();
  });

  // Cleanup after all tests
  afterAll(async () => {
    // Close DB connections, etc.
  });
});

Descriptive Test Names

// Good examples: Specific and clear intent
it('should return 404 when user does not exist', () => {});
it('should create user and return 201 status', () => {});
it('should validate email format and reject invalid emails', () => {});

// Bad examples: Unclear intent
it('test1', () => {});
it('works', () => {});
it('success', () => {});

Naming Convention: The “should + expected behavior” format is common.

Boundary Value Testing

describe('Validation', () => {
  it('should accept name with 1 character (minimum)', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'A', email: 'test@example.com' })
      .expect(201);
  });

  it('should accept name with 100 characters (maximum)', async () => {
    const longName = 'A'.repeat(100);
    const response = await request(app)
      .post('/api/users')
      .send({ name: longName, email: 'test@example.com' })
      .expect(201);
  });

  it('should reject empty name', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: '', email: 'test@example.com' })
      .expect(400);
  });
});

Step 7: Testing APIs with Authentication

Example of testing an API that uses JWT tokens:

describe('Protected API', () => {
  const validToken = 'Bearer valid-jwt-token';
  const invalidToken = 'Bearer invalid-token';

  it('should return 401 without authorization header', async () => {
    await request(app)
      .get('/api/protected/resource')
      .expect(401);
  });

  it('should return 401 with invalid token', async () => {
    await request(app)
      .get('/api/protected/resource')
      .set('Authorization', invalidToken)
      .expect(401);
  });

  it('should return 200 with valid token', async () => {
    await request(app)
      .get('/api/protected/resource')
      .set('Authorization', validToken)
      .expect(200);
  });
});

Step 8: Using Mocks

Mock external dependencies (databases, external APIs).

Mocking Databases

// src/services/userService.ts
import { db } from '../db';

export const userService = {
  findAll: async () => db.users.findMany(),
  findById: async (id: number) => db.users.findUnique({ where: { id } }),
  create: async (data: { name: string; email: string }) => db.users.create({ data })
};
// src/services/userService.test.ts
import { userService } from './userService';
import { db } from '../db';

// Mock the db module
jest.mock('../db', () => ({
  db: {
    users: {
      findMany: jest.fn(),
      findUnique: jest.fn(),
      create: jest.fn()
    }
  }
}));

describe('UserService', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  it('should return all users', async () => {
    const mockUsers = [
      { id: 1, name: 'User 1', email: 'user1@example.com' }
    ];

    (db.users.findMany as jest.Mock).mockResolvedValue(mockUsers);

    const result = await userService.findAll();

    expect(db.users.findMany).toHaveBeenCalledTimes(1);
    expect(result).toEqual(mockUsers);
  });
});

Mocking External APIs

import axios from 'axios';

jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;

describe('External API integration', () => {
  it('should fetch data from external API', async () => {
    const mockData = { data: { id: 1, title: 'Test' } };
    mockedAxios.get.mockResolvedValue(mockData);

    const result = await fetchExternalData();

    expect(mockedAxios.get).toHaveBeenCalledWith('https://api.example.com/data');
    expect(result).toEqual(mockData.data);
  });

  it('should handle API errors', async () => {
    mockedAxios.get.mockRejectedValue(new Error('Network Error'));

    await expect(fetchExternalData()).rejects.toThrow('Network Error');
  });
});

Test Coverage

Generating Coverage Reports

npm test -- --coverage
---------------------|---------|----------|---------|---------|
File                 | % Stmts | % Branch | % Funcs | % Lines |
---------------------|---------|----------|---------|---------|
All files            |   95.24 |    88.89 |   100   |   95.24 |
 app.ts              |   95.24 |    88.89 |   100   |   95.24 |
---------------------|---------|----------|---------|---------|

Types of Coverage

TypeDescription
StatementsStatement execution rate
BranchesBranch coverage rate
FunctionsFunction call rate
LinesLine execution rate

Note: 100% coverage is not the goal. Prioritize testing important business logic.

Common Mistakes and Anti-Patterns

1. Test Interdependence

// Bad example: Depends on test order
it('should create user', async () => {
  await request(app).post('/api/users').send({ name: 'Test', email: 'test@example.com' });
});

it('should have 3 users', async () => {
  // Assumes the above test has run
  const response = await request(app).get('/api/users');
  expect(response.body.length).toBe(3);
});

// Good example: Each test is independent
beforeEach(() => {
  resetUsers(); // Reset data
});

it('should create user', async () => {
  const response = await request(app)
    .post('/api/users')
    .send({ name: 'Test', email: 'test@example.com' });
  expect(response.status).toBe(201);
});

2. Testing Implementation Details

// Bad example: Testing internal implementation
it('should call database with correct SQL', async () => {
  await userService.findById(1);
  expect(db.query).toHaveBeenCalledWith('SELECT * FROM users WHERE id = 1');
});

// Good example: Testing behavior
it('should return user by id', async () => {
  const user = await userService.findById(1);
  expect(user.id).toBe(1);
});

3. Over-Mocking

// Bad example: Mocking everything (test becomes meaningless)
jest.mock('./userService');
jest.mock('./database');
jest.mock('./validator');

// Good example: Only mock external dependencies
jest.mock('./externalApiClient');

Summary

Writing API tests provides the following benefits:

  • Prevent regression (breaking existing functionality)
  • Document API specifications in code
  • Confidence when refactoring
  • Automated verification in CI/CD pipelines

Start with simple GET/POST tests and gradually expand coverage.

Official Documentation

Best Practices & Articles

Books

  • “Test Driven Development” (by Kent Beck) - The original TDD book
  • “Refactoring” (by Martin Fowler) - The relationship between testing and refactoring

Tools

  • Jest - JavaScript test framework
  • Vitest - Fast test framework for Vite
  • Postman - API development and testing tool
  • Insomnia - REST/GraphQL client
← Back to list