Appearance
Testing
The API uses Jest 30 with two test projects: unit tests (colocated) and E2E tests. The two worker apps (data-fetching/, data-processing/) have their own Jest setup as well — a single colocated unit project (no E2E) that resolves @gis-data/shared directly out of shared/src/ so worker tests don't have to rebuild the shared package between runs.
Coverage targets
Recent refactors lifted coverage substantially:
| Package | Coverage |
|---|---|
api (unit) | ~76% (up from ~47%) |
frontend (touched composables) | ~92% (up from ~65%) |
admin | ~89% (up from ~76%) |
data-fetching / data-processing | new; 28 unit tests added with the worker Jest setup |
Treat these as the floor — new modules should ship with tests that keep the line above its current value.
Running Tests
bash
cd api
# All tests (unit + e2e)
pnpm test
# Unit tests only
pnpm test:unit
# E2E tests only (runs sequentially)
pnpm test:e2e
# Unit tests with coverage
pnpm test:covUnit Tests
Unit tests are colocated with the service they test: <name>.service.spec.ts.
Setup Pattern
typescript
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { NotFoundException } from '@nestjs/common';
import { AccidentsService } from './accidents.service';
import { Accident } from './entities/accident.entity';
// Mock query builder with fluent interface
function createMockQueryBuilder(result: any = []) {
const qb: Record<string, jest.Mock> = {};
qb.select = jest.fn().mockReturnValue(qb);
qb.addSelect = jest.fn().mockReturnValue(qb);
qb.where = jest.fn().mockReturnValue(qb);
qb.andWhere = jest.fn().mockReturnValue(qb);
qb.orderBy = jest.fn().mockReturnValue(qb);
qb.skip = jest.fn().mockReturnValue(qb);
qb.take = jest.fn().mockReturnValue(qb);
qb.getMany = jest.fn().mockResolvedValue(result);
qb.getManyAndCount = jest.fn().mockResolvedValue([result, result.length]);
qb.getOne = jest.fn().mockResolvedValue(result[0] || null);
return qb;
}
describe('AccidentsService', () => {
let service: AccidentsService;
let mockRepository: Record<string, jest.Mock>;
beforeEach(async () => {
mockRepository = {
createQueryBuilder: jest.fn().mockReturnValue(createMockQueryBuilder()),
findOne: jest.fn(),
find: jest.fn(),
save: jest.fn(),
findAndCount: jest.fn(),
};
const module = await Test.createTestingModule({
providers: [
AccidentsService,
{
provide: getRepositoryToken(Accident),
useValue: mockRepository,
},
],
}).compile();
service = module.get(AccidentsService);
});
// Tests...
});Test Examples
typescript
describe('findOne', () => {
it('should return accident with participants', async () => {
const mockAccident = { id: 'uuid-1', participants: [] };
mockRepository.findOne.mockResolvedValue(mockAccident);
const result = await service.findOne('uuid-1');
expect(result).toEqual(mockAccident);
expect(mockRepository.findOne).toHaveBeenCalledWith({
where: { id: 'uuid-1' },
relations: ['participants'],
});
});
it('should throw NotFoundException when not found', async () => {
mockRepository.findOne.mockResolvedValue(null);
await expect(service.findOne('nonexistent')).rejects.toThrow(
NotFoundException,
);
});
});
describe('findAll', () => {
it('should apply filters to query builder', async () => {
const mockQb = createMockQueryBuilder([{ id: '1' }]);
mockRepository.createQueryBuilder.mockReturnValue(mockQb);
await service.findAll({
accidentreason: 'Speeding,Fatigue',
startDate: '2023-01-01',
});
expect(mockQb.andWhere).toHaveBeenCalledTimes(2);
});
it('should return results without filters', async () => {
const expected = [{ id: '1' }, { id: '2' }];
const mockQb = createMockQueryBuilder(expected);
mockRepository.createQueryBuilder.mockReturnValue(mockQb);
const result = await service.findAll();
expect(result).toEqual(expected);
expect(mockQb.andWhere).not.toHaveBeenCalled();
});
});Rules:
- Use
Test.createTestingModule()for DI setup - Mock repositories with
getRepositoryToken(Entity) - Create fluent mock query builders that return
thisfor chaining - Use
mockReturnValueOnce()for sequential mock results - Test both happy path and error cases
E2E Tests
E2E tests live in api/test/ and test full HTTP request/response cycles against a real database.
Prerequisites
E2E tests require a running PostgreSQL instance with a gis_data_test database:
sql
CREATE DATABASE gis_data_test;
\c gis_data_test
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";Setup (test/setup-e2e.ts)
The shared createTestApp() helper:
- Creates a NestJS testing module with all feature modules
- Connects to the
gis_data_testdatabase (port 5434, synchronize: true) - Mocks the
MailService(no real emails sent) - Applies global guards (
JwtAuthGuard,RolesGuard,ThrottlerGuard) - Truncates all tables before each test suite
Helper functions:
getDataSource(app)— returns the TypeORM DataSource for raw SQLloginAsUser(app, role)— creates a test user and returns the auth cookie
Test Pattern
typescript
import * as request from 'supertest';
import { INestApplication } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { createTestApp, getDataSource, loginAsUser } from './setup-e2e';
describe('Accidents (e2e)', () => {
let app: INestApplication;
let ds: DataSource;
beforeAll(async () => {
app = await createTestApp();
ds = await getDataSource(app);
// Seed test data with raw SQL
await ds.query(`
INSERT INTO accidents (id, latitude, longitude, date, ...)
VALUES ('test-uuid', 46.05, 14.5, '2023-06-15', ...)
`);
});
afterAll(async () => {
if (app) await app.close();
});
describe('GET /api/accidents', () => {
it('should return 200 with all accidents', async () => {
const { body } = await request(app.getHttpServer())
.get('/api/accidents')
.expect(200);
expect(body).toHaveLength(1);
expect(body[0]).toHaveProperty('id');
});
it('should filter by accident reason', async () => {
const { body } = await request(app.getHttpServer())
.get('/api/accidents?accidentreason=Speeding')
.expect(200);
expect(body).toHaveLength(0);
});
});
});Authenticated Tests
typescript
describe('Users (e2e)', () => {
let app: INestApplication;
let adminCookie: string;
beforeAll(async () => {
app = await createTestApp();
adminCookie = await loginAsUser(app, 'admin');
});
it('should return 200 for admin', async () => {
const { body } = await request(app.getHttpServer())
.get('/api/users')
.set('Cookie', adminCookie)
.expect(200);
expect(body.data).toBeDefined();
expect(body.total).toBeDefined();
});
it('should return 403 for regular user', async () => {
const userCookie = await loginAsUser(app, 'user');
await request(app.getHttpServer())
.get('/api/users')
.set('Cookie', userCookie)
.expect(403);
});
});Rules:
- Use
createTestApp()helper — don't set up the app manually - Seed test data with raw SQL in
beforeAll() - Uses separate
gis_data_testdatabase supertestfor HTTP assertions- Run with
--runInBandto prevent parallel database conflicts - Always close the app in
afterAll() - Use
loginAsUser(app, role)for authenticated requests
Jest Configuration
typescript
// jest.config.ts
export default {
projects: [
{
displayName: 'unit',
testMatch: ['<rootDir>/src/**/*.spec.ts'],
transform: { '^.+\\.ts$': 'ts-jest' },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
},
{
displayName: 'e2e',
testMatch: ['<rootDir>/test/**/*.e2e-spec.ts'],
transform: { '^.+\\.ts$': 'ts-jest' },
moduleNameMapper: { '^@/(.*)$': '<rootDir>/src/$1' },
maxWorkers: 1,
testTimeout: 30000,
},
],
};