Skip to main content

Next.js API Testing

In modern web development, API endpoints form the backbone of data flow between clients and servers. With Next.js, creating API endpoints is streamlined through its API Routes feature. However, ensuring these endpoints work correctly requires thorough testing. This guide will walk you through testing API routes in Next.js, from basic setup to more advanced scenarios.

Introduction to API Testing in Next.js

Next.js API routes allow you to create serverless functions that run on the server-side. These routes, located in the /pages/api directory, handle HTTP requests and return appropriate responses. Testing these routes is crucial to ensure they:

  • Return the expected data in the correct format
  • Handle errors gracefully
  • Process authentication and authorization correctly
  • Perform the intended operations on your data sources

Setting Up the Testing Environment

Before we dive into writing tests, we need to configure our testing environment. We'll use Jest as our testing framework and Supertest to make HTTP requests to our API endpoints.

Installing Dependencies

bash
npm install --save-dev jest supertest @testing-library/react

Configuring Jest

Create a jest.config.js file in your project root:

js
module.exports = {
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.js'],
collectCoverageFrom: [
'pages/api/**/*.js',
'!**/node_modules/**',
'!**/vendor/**'
],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }]
}
};

Basic API Testing

Let's start with a simple API endpoint. Create a file at pages/api/hello.js:

js
export default function handler(req, res) {
res.status(200).json({ message: 'Hello World!' });
}

Now, let's write a test for this endpoint. Create a file at __tests__/api/hello.test.js:

js
import { createMocks } from 'node-mocks-http';
import helloHandler from '../../pages/api/hello';

describe('/api/hello endpoint', () => {
test('returns a hello world message', async () => {
const { req, res } = createMocks({
method: 'GET',
});

await helloHandler(req, res);

expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual({ message: 'Hello World!' });
});
});

This test creates mock request and response objects, calls our API handler with them, and then verifies the response status and body.

Testing Different HTTP Methods

Most real-world APIs support different HTTP methods (GET, POST, PUT, DELETE). Let's create an endpoint that handles different methods:

js
// pages/api/users.js
export default function handler(req, res) {
switch (req.method) {
case 'GET':
return res.status(200).json({ users: ['John', 'Jane', 'Bob'] });
case 'POST':
const { name } = req.body;
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
// In a real app, you would save the user to a database
return res.status(201).json({ message: 'User created', name });
default:
res.setHeader('Allow', ['GET', 'POST']);
return res.status(405).json({ error: `Method ${req.method} not allowed` });
}
}

Now, let's test different methods:

js
// __tests__/api/users.test.js
import { createMocks } from 'node-mocks-http';
import usersHandler from '../../pages/api/users';

describe('/api/users endpoint', () => {
test('GET returns list of users', async () => {
const { req, res } = createMocks({
method: 'GET',
});

await usersHandler(req, res);

expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual({
users: ['John', 'Jane', 'Bob']
});
});

test('POST with valid data creates a user', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Alice'
}
});

await usersHandler(req, res);

expect(res._getStatusCode()).toBe(201);
expect(JSON.parse(res._getData())).toEqual({
message: 'User created',
name: 'Alice'
});
});

test('POST without name returns error', async () => {
const { req, res } = createMocks({
method: 'POST',
body: {}
});

await usersHandler(req, res);

expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual({
error: 'Name is required'
});
});

test('Unsupported method returns 405', async () => {
const { req, res } = createMocks({
method: 'PUT',
});

await usersHandler(req, res);

expect(res._getStatusCode()).toBe(405);
expect(JSON.parse(res._getData())).toEqual({
error: 'Method PUT not allowed'
});
});
});

Testing API Routes with Query Parameters

Many API endpoints use query parameters to filter or paginate results. Let's create an endpoint that uses query parameters:

js
// pages/api/products.js
export default function handler(req, res) {
// Mock product data
const allProducts = [
{ id: 1, name: 'Laptop', category: 'electronics' },
{ id: 2, name: 'Headphones', category: 'electronics' },
{ id: 3, name: 'Shirt', category: 'clothing' },
{ id: 4, name: 'Jeans', category: 'clothing' }
];

if (req.method === 'GET') {
const { category } = req.query;

if (category) {
const filteredProducts = allProducts.filter(
product => product.category === category
);
return res.status(200).json(filteredProducts);
}

return res.status(200).json(allProducts);
}

res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} not allowed` });
}

Let's test this endpoint:

js
// __tests__/api/products.test.js
import { createMocks } from 'node-mocks-http';
import productsHandler from '../../pages/api/products';

describe('/api/products endpoint', () => {
test('GET returns all products when no query params', async () => {
const { req, res } = createMocks({
method: 'GET',
});

await productsHandler(req, res);

expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toHaveLength(4);
});

test('GET with category filter returns filtered products', async () => {
const { req, res } = createMocks({
method: 'GET',
query: {
category: 'electronics'
}
});

await productsHandler(req, res);

const data = JSON.parse(res._getData());

expect(res._getStatusCode()).toBe(200);
expect(data).toHaveLength(2);
expect(data.every(product => product.category === 'electronics')).toBe(true);
});
});

Testing API Routes with Authentication

Authentication is a common requirement for API routes. Let's create an endpoint that requires authentication:

js
// utils/auth.js
export function isAuthenticated(req) {
// In a real application, this would verify a JWT token or session
return req.headers.authorization === 'Bearer valid-token';
}

// pages/api/protected.js
import { isAuthenticated } from '../../utils/auth';

export default function handler(req, res) {
if (!isAuthenticated(req)) {
return res.status(401).json({ error: 'Unauthorized' });
}

return res.status(200).json({ data: 'This is protected data' });
}

Now let's test this protected endpoint:

js
// __tests__/api/protected.test.js
import { createMocks } from 'node-mocks-http';
import protectedHandler from '../../pages/api/protected';

describe('/api/protected endpoint', () => {
test('returns 401 when no authorization header is present', async () => {
const { req, res } = createMocks({
method: 'GET',
});

await protectedHandler(req, res);

expect(res._getStatusCode()).toBe(401);
expect(JSON.parse(res._getData())).toEqual({
error: 'Unauthorized'
});
});

test('returns 401 when invalid token is provided', async () => {
const { req, res } = createMocks({
method: 'GET',
headers: {
authorization: 'Bearer invalid-token'
}
});

await protectedHandler(req, res);

expect(res._getStatusCode()).toBe(401);
expect(JSON.parse(res._getData())).toEqual({
error: 'Unauthorized'
});
});

test('returns protected data when valid token is provided', async () => {
const { req, res } = createMocks({
method: 'GET',
headers: {
authorization: 'Bearer valid-token'
}
});

await protectedHandler(req, res);

expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual({
data: 'This is protected data'
});
});
});

Testing API Routes with Database Integration

When your API routes interact with a database, you'll want to mock the database calls to avoid hitting the actual database during tests. Here's an example using a mock for a MongoDB-like interaction:

js
// services/userService.js
export async function findUserById(id) {
// In a real app, this would query your database
// return await db.users.findOne({ _id: id });
}

// pages/api/user/[id].js
import { findUserById } from '../../../services/userService';

export default async function handler(req, res) {
if (req.method !== 'GET') {
res.setHeader('Allow', ['GET']);
return res.status(405).json({ error: `Method ${req.method} not allowed` });
}

const { id } = req.query;

try {
const user = await findUserById(id);

if (!user) {
return res.status(404).json({ error: 'User not found' });
}

return res.status(200).json(user);
} catch (error) {
return res.status(500).json({ error: 'Server error' });
}
}

To test this, we'll mock the findUserById function:

js
// __tests__/api/user/[id].test.js
import { createMocks } from 'node-mocks-http';
import userHandler from '../../../pages/api/user/[id]';
import { findUserById } from '../../../services/userService';

// Mock the userService
jest.mock('../../../services/userService');

describe('/api/user/[id] endpoint', () => {
test('returns user when found', async () => {
// Setup the mock to return a user
findUserById.mockResolvedValueOnce({
id: '123',
name: 'John Doe'
});

const { req, res } = createMocks({
method: 'GET',
query: {
id: '123'
}
});

await userHandler(req, res);

expect(findUserById).toHaveBeenCalledWith('123');
expect(res._getStatusCode()).toBe(200);
expect(JSON.parse(res._getData())).toEqual({
id: '123',
name: 'John Doe'
});
});

test('returns 404 when user not found', async () => {
// Setup the mock to return null (user not found)
findUserById.mockResolvedValueOnce(null);

const { req, res } = createMocks({
method: 'GET',
query: {
id: 'nonexistent'
}
});

await userHandler(req, res);

expect(findUserById).toHaveBeenCalledWith('nonexistent');
expect(res._getStatusCode()).toBe(404);
expect(JSON.parse(res._getData())).toEqual({
error: 'User not found'
});
});

test('returns 500 when database error occurs', async () => {
// Setup the mock to throw an error
findUserById.mockRejectedValueOnce(new Error('Database connection failed'));

const { req, res } = createMocks({
method: 'GET',
query: {
id: '123'
}
});

await userHandler(req, res);

expect(findUserById).toHaveBeenCalledWith('123');
expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual({
error: 'Server error'
});
});
});

Testing with Supertest

While the previous examples use node-mocks-http to test API handlers directly, you might want to test the actual HTTP endpoints. Supertest is a great library for this purpose:

js
// __tests__/api/integration/hello.test.js
import { createServer } from 'http';
import { apiResolver } from 'next/dist/server/api-utils/node';
import request from 'supertest';
import helloHandler from '../../../pages/api/hello';

describe('Integration test for /api/hello', () => {
function createTestServer(handler) {
return createServer((req, res) => {
return apiResolver(req, res, undefined, handler, {}, undefined);
});
}

it('should return hello world message', async () => {
const server = createTestServer(helloHandler);

const response = await request(server)
.get('/')
.expect('Content-Type', /json/)
.expect(200);

expect(response.body).toEqual({ message: 'Hello World!' });
});
});

This approach tests the API route through the entire Next.js API resolution pipeline, making it closer to an integration test.

Best Practices for API Testing

  1. Test the happy path first: Ensure your API works correctly with valid inputs before testing edge cases.

  2. Test different response codes: Verify that your API returns appropriate status codes for different scenarios (200 for success, 400 for client errors, 500 for server errors).

  3. Mock external dependencies: When your API calls a database, external API, or file system, mock these dependencies to keep your tests fast and isolated.

  4. Test error handling: Make sure your API gracefully handles errors and provides meaningful error messages.

  5. Use environment variables: Store environment-specific configurations in environment variables, and use different values for testing.

  6. Clean up after tests: If your tests create resources (like files or database records), ensure they're cleaned up after the tests run.

  7. Organize tests by endpoint: Structure your test files to match your API structure, making it easier to find and maintain tests.

Summary

Testing API routes in Next.js is an essential part of building reliable applications. By using tools like Jest and node-mocks-http (or Supertest for integration tests), you can ensure your API endpoints behave as expected under various conditions.

This guide covered:

  • Setting up a testing environment for Next.js API routes
  • Testing basic API endpoints
  • Handling different HTTP methods
  • Working with query parameters
  • Testing authentication and authorization
  • Mocking database interactions
  • Integration testing with Supertest

By following these patterns and best practices, you can build robust, tested API routes that provide a solid foundation for your Next.js applications.

Additional Resources

Exercises

  1. Create a simple CRUD API for a todo list and write comprehensive tests for it.

  2. Add authentication to your API using JWT tokens and test both authenticated and unauthenticated requests.

  3. Implement pagination for a list endpoint and write tests that verify it works with different page sizes and offsets.

  4. Create an API endpoint that handles file uploads and write tests that verify it processes them correctly.

  5. Implement rate limiting for an API endpoint and write tests to verify that it rejects requests that exceed the limit.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)