← Back to DevBytes

Testing Express Components: From Unit to E2E Tests

Testing Express Components: From Unit to E2E Tests

Building an Express application is only half the journey. The other half — and arguably the more important one — is making sure it behaves the way you expect under real-world conditions. Testing Express components, from isolated units to full end-to-end flows, gives you the confidence to refactor, ship, and scale without fear. This tutorial walks you through the entire testing pyramid as it applies to Express apps, with practical, copy-paste-ready examples.

What Is Express Component Testing?

Express component testing refers to the practice of verifying the behavior of the various building blocks that make up an Express application. These blocks include route handlers, middleware, controllers, services, database models, and the HTTP server itself. Depending on the scope of what you are verifying, tests fall into three broad categories:

Together, these layers form the classic testing pyramid: many fast unit tests at the bottom, fewer integration tests in the middle, and a small number of slow but realistic E2E tests at the top.

Why It Matters

Express is famously unopinionated. It gives you functions that handle requests and responses, but it does not impose structure. That flexibility is a double-edged sword: without tests, it is very easy for an Express app to become a tangled web of routes, middleware, and side effects that no one fully understands. A solid test suite provides several concrete benefits:

Project Setup

Before writing tests, you need a small Express application to test and a test runner installed. We will use Jest and Supertest, the most common pairing in the Node.js ecosystem. Start by initializing a project and installing dependencies:

mkdir express-testing-demo && cd express-testing-demo
npm init -y
npm install express
npm install --save-dev jest supertest

Add a test script to your package.json:

{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

Now create a minimal but realistic Express app. We will split it into a controller, a service, and the app entry point so that each layer can be tested independently.

Building the Sample Application

Create src/services/userService.js. This module contains pure business logic with no Express-specific code, which makes it ideal for unit testing:

// src/services/userService.js
const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com' },
  { id: 2, name: 'Bob', email: 'bob@example.com' }
];

let nextId = 3;

function getUsers() {
  return users;
}

function getUserById(id) {
  return users.find(u => u.id === Number(id));
}

function createUser({ name, email }) {
  if (!name || !email) {
    const err = new Error('Name and email are required');
    err.code = 'VALIDATION_ERROR';
    throw err;
  }
  const user = { id: nextId++, name, email };
  users.push(user);
  return user;
}

module.exports = { getUsers, getUserById, createUser };

Create src/controllers/userController.js. The controller translates HTTP requests into service calls and shapes the HTTP response:

// src/controllers/userController.js
const userService = require('../services/userService');

function listUsers(req, res) {
  const users = userService.getUsers();
  res.json(users);
}

function getUser(req, res) {
  const user = userService.getUserById(req.params.id);
  if (!user) {
    return res.status(404).json({ error: 'User not found' });
  }
  res.json(user);
}

function createUser(req, res) {
  try {
    const user = userService.createUser(req.body);
    res.status(201).json(user);
  } catch (err) {
    if (err.code === 'VALIDATION_ERROR') {
      return res.status(400).json({ error: err.message });
    }
    res.status(500).json({ error: 'Internal server error' });
  }
}

module.exports = { listUsers, getUser, createUser };

Create src/app.js. Notice that we export the Express app without calling app.listen(). This is critical: Supertest can work directly with the app object, and keeping the server bootstrap separate makes testing far easier.

// src/app.js
const express = require('express');
const userController = require('./controllers/userController');

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

app.get('/users', userController.listUsers);
app.get('/users/:id', userController.getUser);
app.post('/users', userController.createUser);

module.exports = app;

Finally, create src/server.js to actually start the server in production:

// src/server.js
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

This separation between app.js and server.js is one of the most important architectural decisions for testable Express apps. It lets you import the app in tests without binding to a port, avoiding conflicts and making tests faster.

Unit Testing the Service Layer

Unit tests focus on the smallest pieces of logic. The service layer is a perfect candidate because it contains pure functions with no HTTP concerns. Create tests/unit/userService.test.js:

// tests/unit/userService.test.js
const userService = require('../../src/services/userService');

describe('userService', () => {
  describe('getUsers', () => {
    test('returns an array of users', () => {
      const users = userService.getUsers();
      expect(Array.isArray(users)).toBe(true);
      expect(users.length).toBeGreaterThan(0);
    });
  });

  describe('getUserById', () => {
    test('returns the user when id exists', () => {
      const user = userService.getUserById(1);
      expect(user).toBeDefined();
      expect(user.name).toBe('Alice');
    });

    test('returns undefined when id does not exist', () => {
      const user = userService.getUserById(999);
      expect(user).toBeUndefined();
    });
  });

  describe('createUser', () => {
    test('creates a user with valid input', () => {
      const user = userService.createUser({ name: 'Charlie', email: 'charlie@example.com' });
      expect(user).toHaveProperty('id');
      expect(user.name).toBe('Charlie');
    });

    test('throws a validation error when name is missing', () => {
      expect(() => userService.createUser({ email: 'x@example.com' })).toThrow();
    });

    test('throws a validation error when email is missing', () => {
      expect(() => userService.createUser({ name: 'Dave' })).toThrow();
    });
  });
});

Run the tests with npm test. Because the service is a pure module with no external dependencies, these tests run in milliseconds and give you immediate feedback on the core business logic.

Unit Testing the Controller Layer

Controllers are slightly trickier because they depend on the service and on Express request and response objects. The cleanest approach is to mock the service and to construct fake req and res objects. Create tests/unit/userController.test.js:

// tests/unit/userController.test.js
const userController = require('../../src/controllers/userController');
const userService = require('../../src/services/userService');

jest.mock('../../src/services/userService');

function mockResponse() {
  const res = {};
  res.status = jest.fn().mockReturnValue(res);
  res.json = jest.fn().mockReturnValue(res);
  return res;
}

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

  describe('listUsers', () => {
    test('responds with a JSON array of users', () => {
      const fakeUsers = [{ id: 1, name: 'Alice' }];
      userService.getUsers.mockReturnValue(fakeUsers);

      const req = {};
      const res = mockResponse();

      userController.listUsers(req, res);

      expect(userService.getUsers).toHaveBeenCalled();
      expect(res.json).toHaveBeenCalledWith(fakeUsers);
    });
  });

  describe('getUser', () => {
    test('responds with the user when found', () => {
      const fakeUser = { id: 1, name: 'Alice' };
      userService.getUserById.mockReturnValue(fakeUser);

      const req = { params: { id: '1' } };
      const res = mockResponse();

      userController.getUser(req, res);

      expect(userService.getUserById).toHaveBeenCalledWith('1');
      expect(res.json).toHaveBeenCalledWith(fakeUser);
      expect(res.status).not.toHaveBeenCalledWith(404);
    });

    test('responds with 404 when user is not found', () => {
      userService.getUserById.mockReturnValue(undefined);

      const req = { params: { id: '999' } };
      const res = mockResponse();

      userController.getUser(req, res);

      expect(res.status).toHaveBeenCalledWith(404);
      expect(res.json).toHaveBeenCalledWith({ error: 'User not found' });
    });
  });

  describe('createUser', () => {
    test('responds with 201 and the created user on success', () => {
      const createdUser = { id: 3, name: 'Charlie', email: 'charlie@example.com' };
      userService.createUser.mockReturnValue(createdUser);

      const req = { body: { name: 'Charlie', email: 'charlie@example.com' } };
      const res = mockResponse();

      userController.createUser(req, res);

      expect(userService.createUser).toHaveBeenCalledWith(req.body);
      expect(res.status).toHaveBeenCalledWith(201);
      expect(res.json).toHaveBeenCalledWith(createdUser);
    });

    test('responds with 400 on validation error', () => {
      const validationError = new Error('Name and email are required');
      validationError.code = 'VALIDATION_ERROR';
      userService.createUser.mockImplementation(() => { throw validationError; });

      const req = { body: {} };
      const res = mockResponse();

      userController.createUser(req, res);

      expect(res.status).toHaveBeenCalledWith(400);
      expect(res.json).toHaveBeenCalledWith({ error: 'Name and email are required' });
    });
  });
});

By mocking the service, we isolate the controller and verify only that it correctly translates between HTTP and the service layer. If the service breaks, the controller tests still pass — and that is exactly what we want, because the service has its own dedicated tests.

Integration Testing with Supertest

Integration tests verify that the pieces fit together. Instead of mocking the service, we let the real Express app handle real HTTP requests through Supertest. Supertest works by invoking the Express app in-process, so no port binding is required. Create tests/integration/users.test.js:

// tests/integration/users.test.js
const request = require('supertest');
const app = require('../../src/app');

describe('Users API (integration)', () => {
  test('GET /users returns 200 and an array', async () => {
    const res = await request(app).get('/users');
    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });

  test('GET /users/:id returns 200 for an existing user', async () => {
    const res = await request(app).get('/users/1');
    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('id', 1);
  });

  test('GET /users/:id returns 404 for a missing user', async () => {
    const res = await request(app).get('/users/9999');
    expect(res.status).toBe(404);
    expect(res.body).toHaveProperty('error');
  });

  test('POST /users creates a new user and returns 201', async () => {
    const newUser = { name: 'Dana', email: 'dana@example.com' };
    const res = await request(app).post('/users').send(newUser);
    expect(res.status).toBe(201);
    expect(res.body).toHaveProperty('id');
    expect(res.body.name).toBe('Dana');
  });

  test('POST /users returns 400 when fields are missing', async () => {
    const res = await request(app).post('/users').send({ name: 'Eve' });
    expect(res.status).toBe(400);
    expect(res.body).toHaveProperty('error');
  });
});

These tests exercise the full request-response cycle: routing, JSON parsing, the controller, and the service. They are slower than unit tests but still fast enough to run on every save. They catch integration bugs that unit tests miss, such as a route being registered with the wrong path or a middleware interfering with request parsing.

Testing Middleware

Middleware is a core Express concept and deserves its own testing strategy. A middleware function takes (req, res, next), so you can test it the same way you test a controller, by passing fake objects. Suppose you have an authentication middleware in src/middleware/auth.js:

// src/middleware/auth.js
function authMiddleware(req, res, next) {
  const token = req.headers['authorization'];
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  if (!token.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Invalid token format' });
  }
  req.user = { id: 1, name: 'Authenticated User' };
  next();
}

module.exports = authMiddleware;

Test it in isolation with tests/unit/authMiddleware.test.js:

// tests/unit/authMiddleware.test.js
const authMiddleware = require('../../src/middleware/auth');

function mockResponse() {
  const res = {};
  res.status = jest.fn().mockReturnValue(res);
  res.json = jest.fn().mockReturnValue(res);
  return res;
}

describe('authMiddleware', () => {
  test('calls next and sets req.user when a valid Bearer token is provided', () => {
    const req = { headers: { authorization: 'Bearer abc123' } };
    const res = mockResponse();
    const next = jest.fn();

    authMiddleware(req, res, next);

    expect(next).toHaveBeenCalled();
    expect(req.user).toBeDefined();
    expect(res.status).not.toHaveBeenCalled();
  });

  test('returns 401 when no token is provided', () => {
    const req = { headers: {} };
    const res = mockResponse();
    const next = jest.fn();

    authMiddleware(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  test('returns 401 when token format is invalid', () => {
    const req = { headers: { authorization: 'abc123' } };
    const res = mockResponse();
    const next = jest.fn();

    authMiddleware(req, res, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });
});

This pattern works for any middleware: error handlers, request loggers, CORS handlers, rate limiters, and so on. The key is to verify both the happy path (where next is called) and the failure paths (where a response is sent directly).

End-to-End Testing

End-to-end tests verify the entire system from the outside in. In an Express context, this usually means starting the actual server, pointing it at a real (often test-specific) database, and making HTTP requests against it as a real client would. While integration tests use Supertest in-process, E2E tests start a real server process.

Create tests/e2e/fullFlow.test.js. This example starts the server on a random port, runs a complete user flow, and shuts the server down afterward:

// tests/e2e/fullFlow.test.js
const http = require('http');
const app = require('../../src/app');

let server;
let baseUrl;

beforeAll((done) => {
  server = http.createServer(app);
  server.listen(0, () => {
    const { port } = server.address();
    baseUrl = `http://localhost:${port}`;
    done();
  });
});

afterAll((done) => {
  server.close(done);
});

describe('Full user flow (E2E)', () => {
  test('a client can list, fetch, and create users', async () => {
    // Step 1: list existing users
    const listRes = await fetch(`${baseUrl}/users`);
    expect(listRes.status).toBe(200);
    const initialUsers = await listRes.json();
    const initialCount = initialUsers.length;

    // Step 2: create a new user
    const createRes = await fetch(`${baseUrl}/users`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: 'E2E User', email: 'e2e@example.com' })
    });
    expect(createRes.status).toBe(201);
    const createdUser = await createRes.json();
    expect(createdUser).toHaveProperty('id');

    // Step 3: fetch the newly created user
    const getRes = await fetch(`${baseUrl}/users/${createdUser.id}`);
    expect(getRes.status).toBe(200);
    const fetchedUser = await getRes.json();
    expect(fetchedUser.name).toBe('E2E User');

    // Step 4: list again and confirm count increased
    const listRes2 = await fetch(`${baseUrl}/users`);
    const finalUsers = await listRes2.json();
    expect(finalUsers.length).toBe(initialCount + 1);
  });
});

For Node versions older than 18, replace the global fetch with node-fetch or axios. The important thing is that these tests interact with the server exactly as an external client would, over a real TCP socket. This catches issues that in-process tests cannot, such as server bootstrap errors, port conflicts, and middleware ordering problems that only manifest under a real request lifecycle.

Organizing the Test Suite

As your test suite grows, organization becomes critical. A recommended folder structure mirrors the source tree and separates test types:

project/
├── src/
│   ├── controllers/
│   ├── services/
│   ├── middleware/
│   ├── app.js
│   └── server.js
└── tests/
    ├── unit/
    │   ├── userService.test.js
    │   ├── userController.test.js
    │   └── authMiddleware.test.js
    ├── integration/
    │   └── users.test.js
    └── e2e/
        └── fullFlow.test.js

You can run only a specific layer using Jest projects or path filters:

{
  "scripts": {
    "test:unit": "jest tests/unit",
    "test:integration": "jest tests/integration",
    "test:e2e": "jest tests/e2e"
  }
}

This lets you run fast unit tests constantly during development and reserve the slower integration and E2E tests for pre-commit hooks or CI pipelines.

Best Practices

Handling Async Errors and Timeouts

Express apps frequently deal with asynchronous operations, and async errors are a common source of test failures. Always return or await promises in your tests, and configure a sensible timeout for slower E2E tests:

describe('slow operations', () => {
  test('handles a slow database query', async () => {
    const res = await request(app).get('/users');
    expect(res.status).toBe(200);
  }, 10000); // 10 second timeout
});

For controllers that use async functions, wrap them so that rejected promises are forwarded to Express error handling. A common pattern is an async wrapper:

// src/utils/asyncHandler.js
function asyncHandler(fn) {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}

module.exports = asyncHandler;

Use it in your routes:

const asyncHandler = require('../utils/asyncHandler');
app.get('/users/:id', asyncHandler(async (req, res) => {
  const user = await userService.getUserByIdAsync(req.params.id);
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
}));

This keeps your tests reliable and your production code safe from unhandled promise rejections.

Conclusion

Testing Express components does not have to be complicated, but it does have to be deliberate. By separating your app from your server, keeping business logic in testable modules, mocking at the right boundaries, and layering your tests from fast unit checks up to realistic end-to-end flows, you build a safety net that lets you move quickly without breaking things. Start with unit tests for your services and controllers, add integration tests with Supertest to verify your routes, and finish with a small set of E2E tests that exercise the most important user journeys. Over time, this layered approach pays for itself many times over in fewer bugs, faster refactoring, and greater confidence every time you ship.

— Ad —

Google AdSense will appear here after approval

← Back to all articles