Skip to content

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:

PackageCoverage
api (unit)~76% (up from ~47%)
frontend (touched composables)~92% (up from ~65%)
admin~89% (up from ~76%)
data-fetching / data-processingnew; 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:cov

Unit 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 this for 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:

  1. Creates a NestJS testing module with all feature modules
  2. Connects to the gis_data_test database (port 5434, synchronize: true)
  3. Mocks the MailService (no real emails sent)
  4. Applies global guards (JwtAuthGuard, RolesGuard, ThrottlerGuard)
  5. Truncates all tables before each test suite

Helper functions:

  • getDataSource(app) — returns the TypeORM DataSource for raw SQL
  • loginAsUser(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_test database
  • supertest for HTTP assertions
  • Run with --runInBand to 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,
    },
  ],
};