← Back to DevBytes

Testing Koa Components: From Unit to E2E Tests

Testing Koa Components: From Unit to E2E Tests

Koa is a minimalist Node.js web framework created by the team behind Express. Its elegant middleware-based architecture, built on async/await, makes it a joy to work with — but also demands a thoughtful testing strategy. Because Koa apps are composed of small, composable middleware functions, they lend themselves beautifully to layered testing: from isolated unit tests of individual functions, through integration tests of middleware chains, all the way to end-to-end tests that exercise the entire HTTP surface.

This tutorial walks you through building a complete testing pyramid for a Koa application. You'll learn how to isolate business logic, test middleware in isolation, compose integration tests with supertest, and run true end-to-end tests against a real server. By the end, you'll have a reusable pattern you can drop into any Koa project.

Why Testing Koa Matters

Koa's design philosophy is "small core, pluggable everything." Unlike heavier frameworks, Koa gives you almost nothing out of the box — no routing, no body parsing, no sessions. This means most of your application's behavior lives in code you write yourself: middleware functions, helpers, services, and route handlers. That code deserves the same rigor as any production system.

Project Setup

Let's start by scaffolding a small Koa application and installing the testing tools we'll use throughout this tutorial. We'll use Jest as the test runner, supertest for HTTP-level assertions, and a few utilities for mocking.

mkdir koa-testing-demo && cd koa-testing-demo
npm init -y
npm install koa @koa/router koa-bodyparser
npm install --save-dev jest supertest

Now let's create the application structure. We'll separate concerns so each layer is independently testable:

src/
  app.js          # Koa app assembly
  routes/
    users.js      # Route definitions
  middleware/
    auth.js       # Authentication middleware
    errorHandler.js
  services/
    userService.js # Business logic
  db/
    users.js       # In-memory data store
test/
  unit/
  integration/
  e2e/

Building the Application

Before we test, we need something to test. Here's a minimal but realistic Koa app with authentication, error handling, and a user resource.

src/db/users.js — a simple in-memory store:

const users = [
  { id: 1, name: 'Alice', email: 'alice@example.com', role: 'admin' },
  { id: 2, name: 'Bob', email: 'bob@example.com', role: 'user' },
];

module.exports = {
  findAll: () => Promise.resolve([...users]),
  findById: (id) => Promise.resolve(users.find((u) => u.id === id) || null),
  create: (data) => {
    const user = { id: users.length + 1, ...data };
    users.push(user);
    return Promise.resolve(user);
  },
};

src/services/userService.js — business logic, isolated from HTTP:

const db = require('../db/users');

class UserService {
  async listUsers() {
    return db.findAll();
  }

  async getUser(id) {
    const user = await db.findById(Number(id));
    if (!user) {
      const err = new Error('User not found');
      err.status = 404;
      throw err;
    }
    return user;
  }

  async createUser(data) {
    if (!data.name || !data.email) {
      const err = new Error('Name and email are required');
      err.status = 400;
      throw err;
    }
    return db.create(data);
  }

  toPublic(user) {
    const { id, name, email, role } = user;
    return { id, name, email, role };
  }
}

module.exports = new UserService();

src/middleware/errorHandler.js — centralized error handling:

module.exports = function errorHandler() {
  return async (ctx, next) => {
    try {
      await next();
    } catch (err) {
      ctx.status = err.status || 500;
      ctx.body = {
        error: {
          message: err.message,
          status: ctx.status,
        },
      };
      if (ctx.status >= 500) {
        ctx.app.emit('error', err, ctx);
      }
    }
  };
};

src/middleware/auth.js — a simple token-based auth middleware:

const VALID_TOKENS = new Map([
  ['admin-token', { id: 1, role: 'admin' }],
  ['user-token', { id: 2, role: 'user' }],
]);

module.exports = function auth(options = {}) {
  const { required = true } = options;
  return async (ctx, next) => {
    const header = ctx.headers.authorization || '';
    const token = header.startsWith('Bearer ') ? header.slice(7) : null;

    if (!token) {
      if (required) {
        ctx.throw(401, 'Missing authorization token');
      }
      return next();
    }

    const user = VALID_TOKENS.get(token);
    if (!user) {
      ctx.throw(401, 'Invalid authorization token');
    }

    ctx.state.user = user;
    await next();
  };
};

src/routes/users.js — route handlers wired to the service:

const Router = require('@koa/router');
const auth = require('../middleware/auth');
const userService = require('../services/userService');

const router = new Router({ prefix: '/api/users' });

router.get('/', auth(), async (ctx) => {
  const users = await userService.listUsers();
  ctx.body = users.map((u) => userService.toPublic(u));
});

router.get('/:id', auth(), async (ctx) => {
  const user = await userService.getUser(ctx.params.id);
  ctx.body = userService.toPublic(user);
});

router.post('/', auth({ required: true }), async (ctx) => {
  if (ctx.state.user.role !== 'admin') {
    ctx.throw(403, 'Admin access required');
  }
  const user = await userService.createUser(ctx.request.body);
  ctx.status = 201;
  ctx.body = userService.toPublic(user);
});

module.exports = router;

src/app.js — assembling everything into a Koa application:

const Koa = require('koa');
const bodyParser = require('koa-bodyparser');
const errorHandler = require('./middleware/errorHandler');
const userRoutes = require('./routes/users');

function createApp() {
  const app = new Koa();
  app.use(errorHandler());
  app.use(bodyParser());
  app.use(userRoutes.routes());
  app.use(userRoutes.allowedMethods());
  return app;
}

module.exports = createApp;

Notice that createApp is a factory function. This is intentional — it lets each test create a fresh app instance, avoiding shared state between test cases.

Unit Testing: Isolating the Smallest Pieces

Unit tests verify individual functions in isolation. For our Koa app, the best candidates for unit testing are the service layer and pure utility functions. These have no dependency on the Koa context object, making them trivial to test.

Testing the UserService

Create test/unit/userService.test.js:

const userService = require('../../src/services/userService');
const db = require('../../src/db/users');

jest.mock('../../src/db/users');

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

  describe('listUsers', () => {
    it('returns all users from the database', async () => {
      const mockUsers = [{ id: 1, name: 'Alice' }];
      db.findAll.mockResolvedValue(mockUsers);

      const result = await userService.listUsers();

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

  describe('getUser', () => {
    it('returns a user when found', async () => {
      db.findById.mockResolvedValue({ id: 1, name: 'Alice' });

      const result = await userService.getUser(1);

      expect(result).toEqual({ id: 1, name: 'Alice' });
    });

    it('throws a 404 error when user is not found', async () => {
      db.findById.mockResolvedValue(null);

      await expect(userService.getUser(999)).rejects.toMatchObject({
        message: 'User not found',
        status: 404,
      });
    });
  });

  describe('createUser', () => {
    it('creates a user with valid data', async () => {
      const input = { name: 'Charlie', email: 'charlie@example.com' };
      db.create.mockResolvedValue({ id: 3, ...input });

      const result = await userService.createUser(input);

      expect(db.create).toHaveBeenCalledWith(input);
      expect(result).toEqual({ id: 3, ...input });
    });

    it('throws a 400 error when name is missing', async () => {
      await expect(
        userService.createUser({ email: 'x@example.com' })
      ).rejects.toMatchObject({
        message: 'Name and email are required',
        status: 400,
      });
      expect(db.create).not.toHaveBeenCalled();
    });
  });

  describe('toPublic', () => {
    it('strips sensitive fields from a user object', () => {
      const user = { id: 1, name: 'Alice', email: 'a@x.com', role: 'admin', passwordHash: 'secret' };
      const result = userService.toPublic(user);
      expect(result).toEqual({ id: 1, name: 'Alice', email: 'a@x.com', role: 'admin' });
      expect(result).not.toHaveProperty('passwordHash');
    });
  });
});

These tests run in milliseconds because they never touch the network, never start a server, and never instantiate Koa. The database is mocked with jest.mock, so we control exactly what it returns.

Testing Middleware in Isolation

Middleware functions receive a Koa context object and a next function. We can construct lightweight mocks of both to test middleware without spinning up a full app. This is where many developers get stuck, but the pattern is straightforward.

Create test/unit/auth.test.js:

const auth = require('../../src/middleware/auth');

function createContext(overrides = {}) {
  return {
    headers: {},
    state: {},
    throw: jest.fn((status, message) => {
      const err = new Error(message);
      err.status = status;
      throw err;
    }),
    ...overrides,
  };
}

describe('auth middleware', () => {
  let ctx, next;

  beforeEach(() => {
    ctx = createContext();
    next = jest.fn().mockResolvedValue(undefined);
  });

  it('throws 401 when no authorization header is present and auth is required', async () => {
    const middleware = auth({ required: true });
    await expect(middleware(ctx, next)).rejects.toMatchObject({
      status: 401,
      message: 'Missing authorization token',
    });
    expect(next).not.toHaveBeenCalled();
  });

  it('calls next without setting user when token is missing and auth is optional', async () => {
    const middleware = auth({ required: false });
    await middleware(ctx, next);
    expect(ctx.state.user).toBeUndefined();
    expect(next).toHaveBeenCalledTimes(1);
  });

  it('sets ctx.state.user when a valid token is provided', async () => {
    ctx.headers.authorization = 'Bearer admin-token';
    const middleware = auth();
    await middleware(ctx, next);
    expect(ctx.state.user).toEqual({ id: 1, role: 'admin' });
    expect(next).toHaveBeenCalledTimes(1);
  });

  it('throws 401 when an invalid token is provided', async () => {
    ctx.headers.authorization = 'Bearer bogus-token';
    const middleware = auth();
    await expect(middleware(ctx, next)).rejects.toMatchObject({
      status: 401,
      message: 'Invalid authorization token',
    });
  });

  it('ignores malformed authorization headers', async () => {
    ctx.headers.authorization = 'NotBearer sometoken';
    const middleware = auth({ required: false });
    await middleware(ctx, next);
    expect(ctx.state.user).toBeUndefined();
  });
});

The key insight is that we don't need a real Koa app to test middleware. We construct a minimal context object with just the properties our middleware touches, plus a throw spy that mimics Koa's ctx.throw behavior. This keeps tests fast and focused.

Let's also test the error handler middleware:

const errorHandler = require('../../src/middleware/errorHandler');

describe('errorHandler middleware', () => {
  let ctx, next, app;

  beforeEach(() => {
    app = { emit: jest.fn() };
    ctx = {
      status: 200,
      body: null,
      app,
    };
  });

  it('passes through when no error is thrown', async () => {
    next = jest.fn().mockResolvedValue(undefined);
    const middleware = errorHandler();
    await middleware(ctx, next);
    expect(ctx.status).toBe(200);
    expect(ctx.body).toBeNull();
  });

  it('formats a 404 error in the response body', async () => {
    const err = new Error('User not found');
    err.status = 404;
    next = jest.fn().mockRejectedValue(err);

    const middleware = errorHandler();
    await middleware(ctx, next);

    expect(ctx.status).toBe(404);
    expect(ctx.body).toEqual({
      error: { message: 'User not found', status: 404 },
    });
    expect(app.emit).not.toHaveBeenCalled();
  });

  it('emits an error event for 500-level errors', async () => {
    const err = new Error('Database exploded');
    next = jest.fn().mockRejectedValue(err);

    const middleware = errorHandler();
    await middleware(ctx, next);

    expect(ctx.status).toBe(500);
    expect(app.emit).toHaveBeenCalledWith('error', err, ctx);
  });
});

Integration Testing: Middleware Chains and Routes

Integration tests verify that multiple components work together correctly. In a Koa app, this usually means testing route handlers together with their middleware — but still without binding to a real TCP port. The tool of choice here is supertest, which invokes the Koa callback directly.

Supertest works with any framework that exposes a request listener function. Koa apps expose this via app.callback(), so integration tests are trivial to set up.

Setting Up a Test Helper

Create test/helpers/request.js to reduce boilerplate:

const supertest = require('supertest');
const createApp = require('../../src/app');

function request(overrides = {}) {
  const app = createApp(overrides);
  return supertest(app.callback());
}

module.exports = { request };

Testing the User Routes

Create test/integration/users.test.js:

const { request } = require('../helpers/request');

describe('GET /api/users', () => {
  it('returns 401 without an auth token', async () => {
    const res = await request().get('/api/users');
    expect(res.status).toBe(401);
    expect(res.body.error.message).toBe('Missing authorization token');
  });

  it('returns a list of users with a valid token', async () => {
    const res = await request()
      .get('/api/users')
      .set('Authorization', 'Bearer admin-token');

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
    expect(res.body[0]).toHaveProperty('id');
    expect(res.body[0]).toHaveProperty('name');
    expect(res.body[0]).not.toHaveProperty('passwordHash');
  });
});

describe('GET /api/users/:id', () => {
  it('returns a single user', async () => {
    const res = await request()
      .get('/api/users/1')
      .set('Authorization', 'Bearer user-token');

    expect(res.status).toBe(200);
    expect(res.body.id).toBe(1);
    expect(res.body.name).toBe('Alice');
  });

  it('returns 404 for a non-existent user', async () => {
    const res = await request()
      .get('/api/users/9999')
      .set('Authorization', 'Bearer user-token');

    expect(res.status).toBe(404);
    expect(res.body.error.message).toBe('User not found');
  });
});

describe('POST /api/users', () => {
  it('creates a user when called by an admin', async () => {
    const res = await request()
      .post('/api/users')
      .set('Authorization', 'Bearer admin-token')
      .send({ name: 'Dave', email: 'dave@example.com' });

    expect(res.status).toBe(201);
    expect(res.body.name).toBe('Dave');
    expect(res.body.email).toBe('dave@example.com');
    expect(res.body).toHaveProperty('id');
  });

  it('returns 403 when a non-admin tries to create a user', async () => {
    const res = await request()
      .post('/api/users')
      .set('Authorization', 'Bearer user-token')
      .send({ name: 'Eve', email: 'eve@example.com' });

    expect(res.status).toBe(403);
    expect(res.body.error.message).toBe('Admin access required');
  });

  it('returns 400 when required fields are missing', async () => {
    const res = await request()
      .post('/api/users')
      .set('Authorization', 'Bearer admin-token')
      .send({ name: 'Frank' });

    expect(res.status).toBe(400);
    expect(res.body.error.message).toBe('Name and email are required');
  });
});

These tests exercise the full request pipeline: body parsing, auth middleware, routing, the service layer, the error handler, and JSON serialization. They're slower than unit tests (each test creates an app and runs through the middleware stack) but still complete in well under a second.

Testing Middleware Composition

Sometimes you want to test that middleware ordering is correct without testing specific routes. You can build a minimal app with just the middleware you care about:

const Koa = require('koa');
const supertest = require('supertest');
const errorHandler = require('../../src/middleware/errorHandler');
const auth = require('../../src/middleware/auth');

describe('middleware composition', () => {
  it('runs errorHandler around auth so 401s are formatted as JSON', async () => {
    const app = new Koa();
    app.use(errorHandler());
    app.use(auth());
    app.use((ctx) => {
      ctx.body = { ok: true };
    });

    const res = await supertest(app.callback()).get('/');

    expect(res.status).toBe(401);
    expect(res.body).toHaveProperty('error');
  });

  it('allows requests through when auth succeeds', async () => {
    const app = new Koa();
    app.use(errorHandler());
    app.use(auth());
    app.use((ctx) => {
      ctx.body = { ok: true, user: ctx.state.user };
    });

    const res = await supertest(app.callback())
      .get('/')
      .set('Authorization', 'Bearer admin-token');

    expect(res.status).toBe(200);
    expect(res.body.ok).toBe(true);
    expect(res.body.user.role).toBe('admin');
  });
});

End-to-End Testing: Real Server, Real Requests

End-to-end (E2E) tests start an actual HTTP server, bind it to a port, and make real network requests. They're the slowest and most brittle tests in your suite, so you should have fewer of them — but they catch issues that unit and integration tests miss, such as port binding problems, environment variable loading, and graceful shutdown behavior.

Adding a Server Entry Point

Create src/server.js to separate server startup from app creation:

const createApp = require('./app');

function startServer(port = 3000) {
  const app = createApp();
  const server = app.listen(port);
  return { app, server };
}

if (require.main === module) {
  const { server } = startServer(process.env.PORT || 3000);
  console.log(`Server listening on ${process.env.PORT || 3000}`);
}

module.exports = startServer;

Writing E2E Tests

Create test/e2e/server.test.js:

const http = require('http');
const startServer = require('../../src/server');

function getFreePort() {
  return new Promise((resolve) => {
    const srv = http.createServer();
    srv.listen(0, () => {
      const port = srv.address().port;
      srv.close(() => resolve(port));
    });
  });
}

function httpRequest(options, body) {
  return new Promise((resolve, reject) => {
    const req = http.request(options, (res) => {
      let data = '';
      res.on('data', (chunk) => (data += chunk));
      res.on('end', () => {
        try {
          resolve({ status: res.statusCode, body: JSON.parse(data), headers: res.headers });
        } catch {
          resolve({ status: res.statusCode, body: data, headers: res.headers });
        }
      });
    });
    req.on('error', reject);
    if (body) req.write(JSON.stringify(body));
    req.end();
  });
}

describe('E2E: server lifecycle', () => {
  let server, port;

  beforeAll(async () => {
    port = await getFreePort();
    ({ server } = startServer(port));
  });

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

  it('responds to health check over a real TCP connection', async () => {
    const res = await httpRequest({
      hostname: '127.0.0.1',
      port,
      path: '/api/users',
      method: 'GET',
      headers: { Authorization: 'Bearer admin-token' },
    });

    expect(res.status).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });

  it('creates and retrieves a user over a real connection', async () => {
    const createRes = await httpRequest(
      {
        hostname: '127.0.0.1',
        port,
        path: '/api/users',
        method: 'POST',
        headers: {
          'Authorization': 'Bearer admin-token',
          'Content-Type': 'application/json',
        },
      },
      { name: 'E2E User', email: 'e2e@example.com' }
    );

    expect(createRes.status).toBe(201);
    const newId = createRes.body.id;

    const getRes = await httpRequest({
      hostname: '127.0.0.1',
      port,
      path: `/api/users/${newId}`,
      method: 'GET',
      headers: { Authorization: 'Bearer user-token' },
    });

    expect(getRes.status).toBe(200);
    expect(getRes.body.name).toBe('E2E User');
  });

  it('returns proper Content-Type header', async () => {
    const res = await httpRequest({
      hostname: '127.0.0.1',
      port,
      path: '/api/users',
      method: 'GET',
      headers: { Authorization: 'Bearer admin-token' },
    });

    expect(res.headers['content-type']).toMatch(/application\/json/);
  });
});

Notice we use Node's built-in http module rather than supertest here. This is deliberate: supertest bypasses the network layer by calling the request listener directly, so it can't catch issues like "the server failed to bind" or "the port is already in use." Using real HTTP requests makes these tests true end-to-end.

Best Practices for Testing Koa Applications

Separate App Creation from Server Startup

Always export a factory function (createApp) rather than a singleton app instance. This lets tests create isolated app instances and prevents state leakage between test files. The server startup code should live in a separate file that imports createApp.

Keep Business Logic Out of Middleware

Middleware should be thin: parse input, call a service, set response. Push all business rules into service modules that have no dependency on the Koa context. This makes the bulk of your logic unit-testable without any HTTP machinery.

Mock at the Boundaries

Mock external dependencies — databases, third-party APIs, file systems — at their module boundary. Don't mock Koa itself or your own middleware unless you're specifically unit-testing that middleware. Over-mocking leads to tests that pass but prove nothing.

Use the Testing Pyramid

Aim for many unit tests, a moderate number of integration tests, and a small set of E2E tests. A good ratio for a Koa app might be 70% unit, 25% integration, 5% E2E. Unit tests give you speed and precision; E2E tests give you confidence that everything is wired together.

Test Error Paths Explicitly

Koa's error handling is centralized, which is great — but it means a single bug in your error handler can mask every error in your app. Write explicit tests for 400, 401, 403, 404, and 500 responses. Verify that error responses have a consistent shape.

Reset State Between Tests

If your tests share an in-memory database or any mutable state, reset it in beforeEach or afterEach. For real databases, use transactions that roll back after each test, or spin up a fresh database per test run.

Configure Jest for Speed

Add a Jest configuration that runs tests in parallel and isolates test files:

// jest.config.js
module.exports = {
  testEnvironment: 'node',
  testMatch: ['**/test/**/*.test.js'],
  testPathIgnorePatterns: ['/node_modules/'],
  collectCoverageFrom: ['src/**/*.js'],
  coverageDirectory: 'coverage',
  projects: [
    { displayName: 'unit', testMatch: ['**/test/unit/**/*.test.js'] },
    { displayName: 'integration', testMatch: ['**/test/integration/**/*.test.js'] },
    { displayName: 'e2e', testMatch: ['**/test/e2e/**/*.test.js'] },
  ],
};

Using Jest projects lets you run specific layers on demand: jest --selectProjects unit runs only unit tests, which is perfect for fast feedback during development.

Avoid Time-Based Flakiness

If your middleware uses timers, JWT expiry, or rate limiting, use fake timers (jest.useFakeTimers()) to make tests deterministic. Never rely on setTimeout in tests to wait for async operations — use proper async assertions instead.

Conclusion

Testing a Koa application is fundamentally about respecting its layered architecture. By keeping business logic in framework-agnostic services, you unlock fast unit tests that don't need HTTP at all. By using supertest against app.callback(), you get integration tests that exercise the full middleware stack without the overhead of a real server. And by occasionally starting a true TCP server, you gain end-to-end confidence that the whole system boots and responds correctly. Together, these three layers form a safety net that lets you refactor aggressively, ship features quickly, and sleep well knowing your Koa app behaves exactly as the tests describe. Start with unit tests for your services, add integration tests for your routes, and sprinkle in E2E tests for critical user journeys — then watch your confidence in the codebase grow with every green build.

— Ad —

Google AdSense will appear here after approval

← Back to all articles