Fastify best practices skill
93
97%
Does it follow best practices?
Impact
85%
1.37xAverage score across 4 eval scenarios
Passed
No known issues
Fastify's inject() method simulates HTTP requests without network overhead:
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import { buildApp } from './app.js';
describe('User API', () => {
let app;
before(async () => {
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
it('should return users list', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
const body = response.json();
t.assert.ok(Array.isArray(body.users));
});
it('should create a user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John Doe',
email: 'john@example.com',
},
});
t.assert.equal(response.statusCode, 201);
const body = response.json();
t.assert.equal(body.name, 'John Doe');
t.assert.ok(body.id);
});
});Test authenticated endpoints:
describe('Protected Routes', () => {
let app;
let authToken;
before(async () => {
app = await buildApp();
await app.ready();
// Get auth token
const loginResponse = await app.inject({
method: 'POST',
url: '/auth/login',
payload: {
email: 'test@example.com',
password: 'password123',
},
});
authToken = loginResponse.json().token;
});
after(async () => {
await app.close();
});
it('should reject unauthenticated requests', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/profile',
});
t.assert.equal(response.statusCode, 401);
});
it('should return profile for authenticated user', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/profile',
headers: {
authorization: `Bearer ${authToken}`,
},
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.json().email, 'test@example.com');
});
});Test routes with query strings:
it('should filter users by status', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
query: {
status: 'active',
page: '1',
limit: '10',
},
});
t.assert.equal(response.statusCode, 200);
const body = response.json();
t.assert.ok(body.users.every((u) => u.status === 'active'));
});
// Or use URL with query string
it('should search users', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users?q=john&sort=name',
});
t.assert.equal(response.statusCode, 200);
});Test routes with path parameters:
it('should return user by id', async (t) => {
const userId = 'user-123';
const response = await app.inject({
method: 'GET',
url: `/users/${userId}`,
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(response.json().id, userId);
});
it('should return 404 for non-existent user', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users/non-existent',
});
t.assert.equal(response.statusCode, 404);
});Test schema validation:
describe('Validation', () => {
it('should reject invalid email', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John',
email: 'not-an-email',
},
});
t.assert.equal(response.statusCode, 400);
const body = response.json();
t.assert.ok(body.message.includes('email'));
});
it('should reject missing required fields', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: {
name: 'John',
// missing email
},
});
t.assert.equal(response.statusCode, 400);
});
it('should coerce query parameters', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/items?limit=10&active=true',
});
t.assert.equal(response.statusCode, 200);
// limit is coerced to number, active to boolean
});
});Test multipart form data:
import { createReadStream } from 'node:fs';
import FormData from 'form-data';
it('should upload file', async (t) => {
const form = new FormData();
form.append('file', createReadStream('./test/fixtures/test.pdf'));
form.append('name', 'test-document');
const response = await app.inject({
method: 'POST',
url: '/upload',
payload: form,
headers: form.getHeaders(),
});
t.assert.equal(response.statusCode, 200);
t.assert.ok(response.json().fileId);
});Test streaming responses:
it('should stream large file', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/files/large-file',
});
t.assert.equal(response.statusCode, 200);
t.assert.ok(response.rawPayload.length > 0);
});Mock external services and databases:
import { describe, it, before, after, mock } from 'node:test';
describe('User Service', () => {
let app;
before(async () => {
// Create app with mocked dependencies
const mockDb = {
users: {
findAll: mock.fn(async () => [
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' },
]),
findById: mock.fn(async (id) => {
if (id === '1') return { id: '1', name: 'User 1' };
return null;
}),
create: mock.fn(async (data) => ({ id: 'new-id', ...data })),
},
};
app = Fastify();
app.decorate('db', mockDb);
app.register(import('./routes/users.js'));
await app.ready();
});
after(async () => {
await app.close();
});
it('should call findAll', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/users',
});
t.assert.equal(response.statusCode, 200);
t.assert.equal(app.db.users.findAll.mock.calls.length, 1);
});
});Test plugins independently:
import { describe, it, before, after } from 'node:test';
import Fastify from 'fastify';
import cachePlugin from './plugins/cache.js';
describe('Cache Plugin', () => {
let app;
before(async () => {
app = Fastify();
app.register(cachePlugin, { ttl: 1000 });
await app.ready();
});
after(async () => {
await app.close();
});
it('should decorate fastify with cache', (t) => {
t.assert.ok(app.hasDecorator('cache'));
t.assert.equal(typeof app.cache.get, 'function');
t.assert.equal(typeof app.cache.set, 'function');
});
it('should cache and retrieve values', (t) => {
app.cache.set('key', 'value');
t.assert.equal(app.cache.get('key'), 'value');
});
});Test hook behavior:
describe('Hooks', () => {
it('should add request id header', async (t) => {
const response = await app.inject({
method: 'GET',
url: '/health',
});
t.assert.ok(response.headers['x-request-id']);
});
it('should log request timing', async (t) => {
const logs = [];
const app = Fastify({
logger: {
level: 'info',
stream: {
write: (msg) => logs.push(JSON.parse(msg)),
},
},
});
app.register(import('./app.js'));
await app.ready();
await app.inject({ method: 'GET', url: '/health' });
const responseLog = logs.find((l) => l.msg?.includes('completed'));
t.assert.ok(responseLog);
t.assert.ok(responseLog.responseTime);
await app.close();
});
});Create a reusable test app builder:
// test/helper.ts
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
interface TestContext {
app: FastifyInstance;
inject: FastifyInstance['inject'];
}
export async function buildTestApp(options = {}): Promise<TestContext> {
const app = Fastify({
logger: false, // Disable logging in tests
...options,
});
// Register plugins
app.register(import('../src/plugins/database.js'), {
connectionString: process.env.TEST_DATABASE_URL,
});
app.register(import('../src/routes/index.js'));
await app.ready();
return {
app,
inject: app.inject.bind(app),
};
}
// Usage in tests
describe('API Tests', () => {
let ctx: TestContext;
before(async () => {
ctx = await buildTestApp();
});
after(async () => {
await ctx.app.close();
});
it('should work', async (t) => {
const response = await ctx.inject({
method: 'GET',
url: '/health',
});
t.assert.equal(response.statusCode, 200);
});
});Use transactions for test isolation:
describe('Database Integration', () => {
let app;
let transaction;
before(async () => {
app = await buildApp();
await app.ready();
});
after(async () => {
await app.close();
});
beforeEach(async () => {
transaction = await app.db.beginTransaction();
app.db.setTransaction(transaction);
});
afterEach(async () => {
await transaction.rollback();
});
it('should create user', async (t) => {
const response = await app.inject({
method: 'POST',
url: '/users',
payload: { name: 'Test', email: 'test@example.com' },
});
t.assert.equal(response.statusCode, 201);
// Transaction is rolled back after test
});
});Structure tests for parallel execution:
// Tests run in parallel by default with node:test
// Use separate app instances or proper isolation
import { describe, it } from 'node:test';
describe('User API', async () => {
// Each test suite gets its own app instance
const app = await buildTestApp();
it('test 1', async (t) => {
// ...
});
it('test 2', async (t) => {
// ...
});
// Cleanup after all tests in this suite
after(() => app.close());
});
describe('Post API', async () => {
const app = await buildTestApp();
it('test 1', async (t) => {
// ...
});
after(() => app.close());
});# Run all tests
node --test
# Run with TypeScript
node --test src/**/*.test.ts
# Run specific file
node --test src/routes/users.test.ts
# With coverage
node --test --experimental-test-coverage
# Watch mode
node --test --watch