I'm building RunHop in public — a social + event platform for running races, built on NestJS.
Today I set up e2e test infrastructure: dedicated test database, automatic resets, proper isolation. And within the first run, the e2e tests caught a bug that unit tests had been blind to for days.
The Bug Unit Tests Can't See
Here's the controller method that passed every unit test:
// organization.controller.ts
@Patch(':id')
async update(
@CurrentUser() user: interfaces.AuthenticatedUser,
@Param('id') id: string,
dto: UpdateOrganizationDto // <-- spot the problem?
) {
await this.membershipService.verifyRole(user.userId, id, 'ADMIN');
return this.orgService.update(id, dto);
}
The dto parameter is missing @body(). Without that decorator, NestJS never parses the request body into the DTO. The value is always undefined.
Here's the unit test that passed:
it('should update org fields', async () => {
mockPrisma.organization.update.mockResolvedValue({ ...mockOrg, name: 'New Name' });
const result = await service.update('id-123', { name: 'New Name' });
expect(mockPrisma.organization.update).toHaveBeenCalledWith({
where: { id: 'id-123' },
data: { name: 'New Name' }
});
expect(result).toEqual({ ...mockOrg, name: 'New Name' });
});
See why it passes?
service.update('id-123', { name: 'New Name' })
calls the service directly.
No HTTP request. No controller. No decorators. The DTO is passed as a plain argument.
The e2e test caught it immediately:
it('should be able to update org as owner', async () => {
const org = await request(app.getHttpServer())
.patch(`/api/v1/organizations/${orgSaved.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({
name: 'New name',
description: 'New Description'
});
expect(org.statusCode).toBe(200); // fails — dto is undefined
});
The fix is one word:
@Body() dto: UpdateOrganizationDto
But the point isn't the fix. The point is that unit tests and e2e tests catch fundamentally different categories of bugs. Unit tests verify logic. E2e tests verify wiring.
Setting Up a Dedicated Test Database
Before writing e2e tests, I needed test isolation. Tests hitting the dev database is a recipe for pain.
The setup: a .env.test file pointing to a separate runhop_test database:
.env.test
DATABASE_URL=postgresql://runhop:runhop@localhost:5432/runhop_test
And a Jest globalSetup file that resets the database before every run:
// test/e2e-setup.ts
import { execSync } from "child_process";
import * as dotenv from 'dotenv';
export default async function () {
dotenv.config({
path: '.env.test',
override: true
});
execSync('npx prisma migrate reset --force', { stdio: 'inherit' });
}
This loads .env.test, then runs
prisma migrate reset --force
which drops all tables, re-applies every migration, and gives you a clean database. The --force flag skips the confirmation prompt.
The Two-Process Problem
Here's the part that wasn't obvious.
Jest's globalSetup runs in a separate Node process from the test workers. The dotenv.config() call in e2e-setup.ts sets process.env.DATABASE_URL in that process. When the process exits, those env vars are gone.
The test workers — where your NestJS app actually boots — never see them. So ConfigModule.forRoot() reads from .env (the dev config), and your tests hit the dev database anyway.
The fix: a second file loaded via setupFiles, which runs inside each test worker:
// test/e2e-env-setup.ts
import * as dotenv from 'dotenv';
export default async function () {
dotenv.config({
path: '.env.test',
override: true
});
}
And the Jest config ties both together:
{
"globalSetup": "./e2e-setup.ts",
"setupFiles": ["./e2e-env-setup.ts"],
"testRegex": ".*\\.e2e-spec\\.ts$",
"transformIgnorePatterns": ["node_modules/(?!uuid)"]
}
globalSetup runs once — resets the database. setupFiles runs per worker — loads the right env vars. Two files because they run in different processes.
(The transformIgnorePatterns line is there because uuid ships ESM-only in newer versions, and Jest needs to transform it.)
E2e Tests Are User Flows, Not Isolated Units
One design question I had: does calling a membership endpoint inside an organization e2e test violate separation of concerns?
No — and this is an important distinction. Unit tests isolate. The org service test only tests the org service. But e2e tests verify user flows. A real user would:
Register an account
Create an organization
Check that they're the owner
That's one coherent scenario. The e2e test mirrors it:
describe('Register a user, create an org, verify OWNER membership', () => {
it('POST /auth/register', async () => {
await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({
email: 'org-test@test.com',
password: 'password123',
displayName: 'E2E Org Test'
})
.expect(201)
.expect((res) => {
accessToken = res.body.data.accessToken;
});
});
it('should create org and verify OWNER membership', async () => {
const orgRes = await request(app.getHttpServer())
.post('/api/v1/organizations')
.set('Authorization', `Bearer ${accessToken}`)
.send({ name: 'Test Org', description: 'E2E testing org.' });
expect(orgRes.statusCode).toBe(201);
const membership = await request(app.getHttpServer())
.get(`/api/v1/organizations/${orgRes.body.data.id}/members/find`)
.set('Authorization', `Bearer ${accessToken}`);
expect(membership.body.data.role).toBe('OWNER');
});
});
The create endpoint only returns the org — not the membership. So verifying OWNER requires a second request to the membership endpoint. That's not a test smell. That's the test doing what a user would do.
Takeaway
Unit tests and e2e tests aren't redundant — they catch different things. Unit tests prove your logic is correct. E2e tests prove your app actually works when a real HTTP request hits it. A missing decorator, a wrong status code, a misconfigured pipe — these are invisible to unit tests.
Set up both. Run both. Trust neither alone.
Top comments (0)