Saltar al contenido principal

Testing

Every xpress-generator project comes with Jest + supertest configured, a dedicated tests/setup.js file, and a working example test. No setup required.


Running tests

npm test # run once with coverage report
npm run test:watch # watch mode — re-runs on file changes

npm test runs Jest with --coverage. On first run, it executes the generated example test and prints a line-by-line coverage report.


Test configuration

jest.config.js

module.exports = {
testEnvironment: 'node',
testMatch: ['**/tests/**/*.test.js'],
setupFiles: ['<rootDir>/tests/setup.js'],
clearMocks: true,
coverageDirectory: 'coverage',
collectCoverageFrom: ['src/**/*.js', '!src/server.js'],
coverageThreshold: {
global: { lines: 70, functions: 70, branches: 70, statements: 70 }
}
};
TypeScript projects

TypeScript projects use preset: 'ts-jest', testMatch: ['**/tests/**/*.test.ts'], and setupFiles: ['<rootDir>/tests/setup.ts'] automatically.


Test setup file — tests/setup.js

This file is automatically executed before every test suite, thanks to the setupFiles entry in jest.config.js.

// tests/setup.js
process.env.NODE_ENV = 'test';
process.env.PORT = '3001';
process.env.JWT_SECRET = 'test-jwt-secret-key-...';
process.env.REFRESH_TOKEN_SECRET = 'test-refresh-secret-key-...';

Why this matters:

  • Tests never need a real .env file in CI
  • The server runs on port 3001 so it never conflicts with your dev server
  • JWT secrets are predictable, so token assertions work reliably
  • clearMocks: true in jest.config.js automatically resets all mocks between tests

You can add global beforeEach / afterAll hooks here as your test suite grows.


Why app.js and server.js are separate

The generated project intentionally separates:

  • app.js — Exports module.exports = app (no app.listen())
  • server.js — Imports app and calls app.listen(PORT, ...)

This is the standard pattern for testable Express apps. When supertest imports app, it starts a temporary in-memory server — so your real port 3000 is never touched during tests and no "EADDRINUSE" errors occur.

// tests/indexController.test.js
const request = require('supertest');
const app = require('../src/app'); // ← import app, NOT server.js

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

The generated example test

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

describe('API Base — MyApp', () => {
it('GET / should return 200 with standard response', async () => {
const res = await request(app).get('/');
expect(res.statusCode).toBe(200);
expect(res.body).toHaveProperty('status', 'success');
expect(res.body).toHaveProperty('data');
});

it('Unknown route should return 404', async () => {
const res = await request(app).get('/this-route-does-not-exist');
expect(res.statusCode).toBe(404);
expect(res.body).toHaveProperty('status', 'fail');
});

it('Auth route should exist', async () => {
const res = await request(app).post('/api/auth/login').send({});
expect(res.statusCode).not.toBe(404);
});
});

Writing your own tests

Create any file in tests/ that matches *.test.js (or *.test.ts). The setup.js env config is applied automatically.

Test a module controller

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

describe('Product endpoints', () => {
let token;

beforeAll(async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ email: 'admin@test.com', password: 'password123' });
token = res.body.data.accessToken;
});

it('GET /api/products — public', async () => {
const res = await request(app).get('/api/products');
expect(res.statusCode).toBe(200);
expect(res.body.data).toBeInstanceOf(Array);
});

it('POST /api/products — requires auth', async () => {
const res = await request(app).post('/api/products').send({ name: 'Test' });
expect(res.statusCode).toBe(401);
});

it('POST /api/products — creates when authenticated', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'Widget', email: 'widget@test.com' });
expect(res.statusCode).toBe(201);
expect(res.body.data).toHaveProperty('name', 'Widget');
});
});

Test error handling

it('Invalid body returns 422 with field errors', async () => {
const res = await request(app)
.post('/api/products')
.set('Authorization', `Bearer ${token}`)
.send({ email: 'not-an-email' }); // fails Zod schema
expect(res.statusCode).toBe(422);
expect(res.body.status).toBe('fail');
expect(res.body.errors).toBeInstanceOf(Array);
expect(res.body.errors[0]).toHaveProperty('field');
});

Test protected routes

it('DELETE /api/products/:id — requires admin role', async () => {
// login as non-admin user
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: 'user@test.com', password: 'password' });
const userToken = loginRes.body.data.accessToken;

const res = await request(app)
.delete('/api/products/1')
.set('Authorization', `Bearer ${userToken}`);
expect(res.statusCode).toBe(403);
});

Pre-commit hooks

Husky + lint-staged are pre-configured. Every git commit automatically runs:

npx lint-staged
# runs: eslint --fix on src/**/*.{js,ts}

If ESLint finds unfixable errors, the commit is blocked.

Lint-staged config (.lintstagedrc.json)

{
"src/**/*.{js,ts}": ["eslint --fix"]
}

You can extend this to also run related tests on commit:

{
"src/**/*.{js,ts}": [
"eslint --fix",
"jest --findRelatedTests --passWithNoTests"
]
}

Coverage report

After npm test, open coverage/lcov-report/index.html for a full line-by-line coverage report.

The project enforces 70% minimum coverage for lines, functions, branches, and statements. Falling below this threshold fails the test run.