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
- Use
server.inject()for route tests. It is faster and more reliable than real HTTP requests, and it still exercises the full request pipeline. - Keep unit tests pure. Avoid spinning up a server in unit tests. Test services and utilities in isolation for maximum speed and clarity.
- Reset state between tests. Use
beforeEachandafterEachhooks to clear databases, in-memory stores, or mocks so tests remain independent. - Test validation explicitly. Joi schemas are a common source of bugs. Validate both accepted and rejected payloads.
- Mock external dependencies. When your handlers call external APIs or databases, use libraries like
sinonor Hapi'sserver.decorateto inject mocks. - Test error paths, not just happy paths. Verify 404s, 400s, and 500s are returned correctly. Error handling is where many applications break down.
- Use
server.initialize()for integration tests. This starts the server lifecycle without binding to a port, which is ideal forinject()-based tests. Useserver.start()only for E2E tests that need a real port. - Organize tests by layer. Keep unit, integration, and E2E tests in separate directories. This makes it easy to run them independently and at different frequencies in your CI pipeline.
- Aim for a testing pyramid. Have many fast unit tests, fewer integration tests, and a small set of E2E tests covering the most important flows.
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.