← Back to DevBytes

Testing Hapi Components: From Unit to E2E Tests

Introduction to Testing Hapi Components

Hapi.js is a powerful Node.js framework for building applications and services. Like any framework, ensuring the reliability of your Hapi components requires a robust testing strategy. Testing in Hapi spans multiple layers — from isolated unit tests that verify individual functions, to integration tests that check how modules work together, to end-to-end (E2E) tests that validate entire user flows. This tutorial walks you through each layer with practical, runnable examples.

Why Testing Hapi Components Matters

Without tests, refactoring becomes a gamble and deployments become nerve-wracking. A well-structured test suite gives you confidence that your routes, handlers, plugins, and business logic behave as expected. Hapi's modular design — where routes, plugins, and server instances are composable — makes it especially friendly to testing at multiple levels.

Setting Up the Testing Environment

For this tutorial, we'll use @hapi/hapi for the server, @hapi/lab as the test runner (Hapi's own testing utility), and @hapi/code for assertions. You can also use Jest or Mocha, but Lab integrates seamlessly with Hapi's ecosystem.

First, initialize your project and install the dependencies:

npm init -y
npm install @hapi/hapi @hapi/joi
npm install --save-dev @hapi/lab @hapi/code

Add a test script to your package.json:

{
  "scripts": {
    "test": "lab -v test/**/*.test.js"
  }
}

Building a Sample Hapi Application

Before writing tests, we need something to test. Let's build a small user management API with a route, a handler, and a plugin.

Create src/services/userService.js:

const users = new Map();

const userService = {
  list() {
    return Array.from(users.values());
  },

  get(id) {
    return users.get(id) || null;
  },

  create(payload) {
    const id = String(users.size + 1);
    const user = { id, ...payload };
    users.set(id, user);
    return user;
  },

  remove(id) {
    return users.delete(id);
  }
};

module.exports = userService;

Create src/plugins/userPlugin.js:

const Joi = require('@hapi/joi');
const userService = require('../services/userService');

const userPlugin = {
  name: 'userPlugin',
  version: '1.0.0',
  register: async (server) => {
    server.route([
      {
        method: 'GET',
        path: '/users',
        handler: (request, h) => {
          return h.response(userService.list()).code(200);
        }
      },
      {
        method: 'GET',
        path: '/users/{id}',
        handler: (request, h) => {
          const user = userService.get(request.params.id);
          if (!user) {
            return h.response({ error: 'User not found' }).code(404);
          }
          return h.response(user).code(200);
        }
      },
      {
        method: 'POST',
        path: '/users',
        options: {
          validate: {
            payload: Joi.object({
              name: Joi.string().min(2).required(),
              email: Joi.string().email().required()
            })
          }
        },
        handler: (request, h) => {
          const user = userService.create(request.payload);
          return h.response(user).code(201);
        }
      },
      {
        method: 'DELETE',
        path: '/users/{id}',
        handler: (request, h) => {
          const deleted = userService.remove(request.params.id);
          if (!deleted) {
            return h.response({ error: 'User not found' }).code(404);
          }
          return h.response().code(204);
        }
      }
    ]);
  }
};

module.exports = userPlugin;

Create src/server.js:

const Hapi = require('@hapi/hapi');
const userPlugin = require('./plugins/userPlugin');

const createServer = async (options = {}) => {
  const server = Hapi.server({
    port: options.port || 3000,
    host: options.host || 'localhost'
  });

  await server.register(userPlugin);
  return server;
};

module.exports = { createServer };

Unit Testing Hapi Components

Unit tests focus on the smallest pieces of your application in isolation. For Hapi apps, this typically means testing services, utility functions, and individual handler logic without spinning up the server.

Create test/userService.test.js:

const Lab = require('@hapi/lab');
const { expect } = require('@hapi/code');
const userService = require('../src/services/userService');

const { describe, it, beforeEach } = exports.lab = Lab.script();

describe('userService', () => {
  beforeEach(() => {
    // Reset internal state between tests
    const users = userService.list();
    users.forEach(u => userService.remove(u.id));
  });

  it('creates a new user with an auto-generated id', () => {
    const user = userService.create({ name: 'Alice', email: 'alice@test.com' });
    expect(user.id).to.exist();
    expect(user.name).to.equal('Alice');
    expect(user.email).to.equal('alice@test.com');
  });

  it('retrieves a user by id', () => {
    const created = userService.create({ name: 'Bob', email: 'bob@test.com' });
    const found = userService.get(created.id);
    expect(found).to.equal(created);
  });

  it('returns null for a non-existent user', () => {
    const found = userService.get('999');
    expect(found).to.be.null();
  });

  it('deletes an existing user', () => {
    const created = userService.create({ name: 'Carol', email: 'carol@test.com' });
    const result = userService.remove(created.id);
    expect(result).to.be.true();
    expect(userService.get(created.id)).to.be.null();
  });

  it('returns false when deleting a non-existent user', () => {
    const result = userService.remove('999');
    expect(result).to.be.false();
  });
});

Notice how each test is independent. The beforeEach hook resets the in-memory store so tests don't interfere with each other. This isolation is the hallmark of good unit testing.

Testing Validation Logic in Isolation

You can also unit test Joi schemas directly without involving the server:

const Lab = require('@hapi/lab');
const { expect } = require('@hapi/code');
const Joi = require('@hapi/joi');

const { describe, it } = exports.lab = Lab.script();

const userSchema = Joi.object({
  name: Joi.string().min(2).required(),
  email: Joi.string().email().required()
});

describe('user validation schema', () => {
  it('accepts a valid payload', () => {
    const { error } = userSchema.validate({ name: 'Alice', email: 'alice@test.com' });
    expect(error).to.be.undefined();
  });

  it('rejects a name that is too short', () => {
    const { error } = userSchema.validate({ name: 'A', email: 'alice@test.com' });
    expect(error).to.exist();
    expect(error.details[0].message).to.match(/name/);
  });

  it('rejects an invalid email', () => {
    const { error } = userSchema.validate({ name: 'Alice', email: 'not-an-email' });
    expect(error).to.exist();
    expect(error.details[0].message).to.match(/email/);
  });
});

Integration Testing Hapi Routes

Integration tests verify that multiple components work together — for example, that a route correctly invokes a handler and returns the right HTTP response. Hapi makes this easy with server.inject(), which simulates HTTP requests without actually opening a network port.

Create test/userRoutes.test.js:

const Lab = require('@hapi/lab');
const { expect } = require('@hapi/code');
const { createServer } = require('../src/server');
const userService = require('../src/services/userService');

const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script();

describe('user routes', () => {
  let server;

  beforeEach(async () => {
    server = await createServer({ port: 0 });
    await server.initialize();
  });

  afterEach(async () => {
    const users = userService.list();
    users.forEach(u => userService.remove(u.id));
    await server.stop();
  });

  it('GET /users returns an empty array initially', async () => {
    const res = await server.inject({ method: 'GET', url: '/users' });
    expect(res.statusCode).to.equal(200);
    expect(JSON.parse(res.payload)).to.equal([]);
  });

  it('POST /users creates a user and returns 201', async () => {
    const res = await server.inject({
      method: 'POST',
      url: '/users',
      payload: { name: 'Alice', email: 'alice@test.com' }
    });
    expect(res.statusCode).to.equal(201);
    const body = JSON.parse(res.payload);
    expect(body.name).to.equal('Alice');
    expect(body.id).to.exist();
  });

  it('POST /users rejects an invalid payload with 400', async () => {
    const res = await server.inject({
      method: 'POST',
      url: '/users',
      payload: { name: 'A', email: 'bad' }
    });
    expect(res.statusCode).to.equal(400);
  });

  it('GET /users/{id} returns 404 for a missing user', async () => {
    const res = await server.inject({ method: 'GET', url: '/users/999' });
    expect(res.statusCode).to.equal(404);
  });

  it('GET /users/{id} returns the user when found', async () => {
    const created = userService.create({ name: 'Bob', email: 'bob@test.com' });
    const res = await server.inject({ method: 'GET', url: `/users/${created.id}` });
    expect(res.statusCode).to.equal(200);
    expect(JSON.parse(res.payload).name).to.equal('Bob');
  });

  it('DELETE /users/{id} removes the user and returns 204', async () => {
    const created = userService.create({ name: 'Carol', email: 'carol@test.com' });
    const res = await server.inject({ method: 'DELETE', url: `/users/${created.id}` });
    expect(res.statusCode).to.equal(204);
    expect(userService.get(created.id)).to.be.null();
  });
});

The key advantage of server.inject() is speed. Because no actual socket is opened, these tests run fast while still exercising the full request lifecycle — including routing, validation, and response serialization.

Testing Plugins in Isolation

You can also test a plugin by registering it on a fresh server instance, without loading the rest of your application:

const Lab = require('@hapi/lab');
const Hapi = require('@hapi/hapi');
const { expect } = require('@hapi/code');
const userPlugin = require('../src/plugins/userPlugin');
const userService = require('../src/services/userService');

const { describe, it, beforeEach, afterEach } = exports.lab = Lab.script();

describe('userPlugin', () => {
  let server;

  beforeEach(async () => {
    server = Hapi.server({ port: 0 });
    await server.register(userPlugin);
    await server.initialize();
  });

  afterEach(async () => {
    const users = userService.list();
    users.forEach(u => userService.remove(u.id));
    await server.stop();
  });

  it('registers all expected routes', () => {
    const table = server.table();
    const paths = table.map(r => r.path);
    expect(paths).to.contain('/users');
    expect(paths).to.contain('/users/{id}');
  });
});

End-to-End Testing Hapi Applications

End-to-end tests validate the entire system from the outside, simulating real client behavior. For Hapi, this means starting the server on an actual port and making real HTTP requests. Tools like node-fetch, axios, or Hapi's own @hapi/wreck work well here.

Install @hapi/wreck for making HTTP requests:

npm install --save-dev @hapi/wreck

Create test/e2e.test.js:

const Lab = require('@hapi/lab');
const Wreck = require('@hapi/wreck');
const { expect } = require('@hapi/code');
const { createServer } = require('../src/server');
const userService = require('../src/services/userService');

const { describe, it, before, after } = exports.lab = Lab.script();

describe('E2E: user API', () => {
  let server;
  let baseUrl;

  before(async () => {
    server = await createServer({ port: 0 });
    await server.start();
    baseUrl = `http://${server.info.address}:${server.info.port}`;
  });

  after(async () => {
    const users = userService.list();
    users.forEach(u => userService.remove(u.id));
    await server.stop();
  });

  it('completes a full create-read-delete lifecycle', async () => {
    // Step 1: Create a user
    const { res: createRes, payload: createPayload } = await Wreck.post(
      `${baseUrl}/users`,
      { payload: { name: 'Dave', email: 'dave@test.com' }, json: true }
    );
    expect(createRes.statusCode).to.equal(201);
    const userId = createPayload.id;

    // Step 2: Read the user back
    const { res: getRes, payload: getPayload } = await Wreck.get(
      `${baseUrl}/users/${userId}`,
      { json: true }
    );
    expect(getRes.statusCode).to.equal(200);
    expect(getPayload.name).to.equal('Dave');

    // Step 3: Delete the user
    const { res: delRes } = await Wreck.delete(`${baseUrl}/users/${userId}`);
    expect(delRes.statusCode).to.equal(204);

    // Step 4: Confirm deletion
    const { res: confirmRes } = await Wreck.get(`${baseUrl}/users/${userId}`);
    expect(confirmRes.statusCode).to.equal(404);
  });

  it('returns validation errors for malformed requests', async () => {
    try {
      await Wreck.post(`${baseUrl}/users`, {
        payload: { name: '', email: 'nope' },
        json: true
      });
      throw new Error('Should have thrown');
    } catch (err) {
      expect(err.data.res.statusCode).to.equal(400);
    }
  });
});

E2E tests are slower than integration tests because they involve real network I/O, so use them judiciously. Focus them on critical user journeys rather than every possible edge case.

Best Practices for Testing Hapi Components

Conclusion

Testing Hapi components effectively means thinking in layers. Unit tests give you fast, precise feedback on individual functions and schemas. Integration tests using server.inject() validate that your routes, plugins, and handlers work together without the overhead of real network calls. End-to-end tests confirm that the entire system behaves correctly from a client's perspective. By combining all three layers and following best practices like state isolation, explicit validation testing, and a well-structured test pyramid, you can build Hapi applications that are reliable, maintainable, and safe to refactor at any time.

— Ad —

Google AdSense will appear here after approval

← Back to all articles