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 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
.envfile in CI - The server runs on port
3001so it never conflicts with your dev server - JWT secrets are predictable, so token assertions work reliably
clearMocks: trueinjest.config.jsautomatically 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— Exportsmodule.exports = app(noapp.listen())server.js— Importsappand callsapp.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.