Skip to content

Latest commit

 

History

History
428 lines (309 loc) · 11.9 KB

File metadata and controls

428 lines (309 loc) · 11.9 KB

Testing Guide

How to write, run, and maintain tests for the RetroLoop project.

Quick Reference

# Backend
yarn workspace back test              # Run unit tests
yarn workspace back test:watch        # Watch mode
yarn workspace back test --coverage   # With coverage report
yarn workspace back test:e2e          # E2E tests (requires PostgreSQL)

# Frontend
yarn workspace front test             # Run unit tests
yarn workspace front test:watch       # Watch mode
yarn workspace front test:coverage    # With coverage report

Test Stack

Backend Frontend
Framework Jest 30 Jest 30
Environment node jsdom
Config apps/back/package.json (inline jest key) apps/front/jest.config.ts
Setup file apps/back/test/setup-e2e.ts (E2E only) apps/front/jest.setup.ts
Assertion lib Jest built-in Jest + @testing-library/jest-dom
Rendering @testing-library/react
User events @testing-library/user-event
HTTP testing supertest (E2E)

File Naming and Location

Tests live alongside source code in __tests__/ directories:

apps/back/src/modules/voting/
├── voting.service.ts
└── __tests__/
    └── voting.service.spec.ts      # ← unit test

apps/front/components/ui/
├── button.tsx
└── __tests__/
    └── button.test.tsx             # ← component test

apps/back/test/
└── health.e2e-spec.ts              # ← E2E test
Pattern Use
*.spec.ts Backend unit / integration tests
*.test.tsx Frontend component tests
*.e2e-spec.ts Backend end-to-end tests

Writing Backend Tests

Unit test structure

Backend tests use the NestJS Test.createTestingModule() to wire up the class under test with mocked dependencies.

import { Test, TestingModule } from '@nestjs/testing';
import { WsException } from '@nestjs/websockets';
import { CardHandler } from '../card.handler';
import { CardsService } from '../../../modules/cards/cards.service';
import { WsAuthorizationService } from '../../ws-authorization.service';

describe('CardHandler', () => {
  let handler: CardHandler;
  let cardsService: jest.Mocked<CardsService>;

  // Mock the Socket.IO server
  const mockEmit = jest.fn();
  const mockServer = {
    to: jest.fn().mockReturnValue({ emit: mockEmit }),
  } as any;

  beforeEach(async () => {
    mockEmit.mockClear();
    mockServer.to.mockClear();
    mockServer.to.mockReturnValue({ emit: mockEmit });

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        CardHandler,
        {
          provide: CardsService,
          useValue: {
            createWithLimitCheck: jest.fn(),
            update: jest.fn(),
            delete: jest.fn(),
          },
        },
        {
          provide: WsAuthorizationService,
          useValue: {
            verifyRetroAccess: jest.fn().mockResolvedValue(undefined),
          },
        },
      ],
    }).compile();

    handler = module.get<CardHandler>(CardHandler);
    cardsService = module.get(CardsService);
  });

  describe('handleCreateCard', () => {
    it('should emit card:created to retro room', async () => {
      cardsService.createWithLimitCheck.mockResolvedValue(mockCard);

      await handler.handleCreateCard('user-1', createData, mockServer);

      expect(mockServer.to).toHaveBeenCalledWith('retro-1');
      expect(mockEmit).toHaveBeenCalledWith('card:created', mockCard);
    });

    it('should throw WsException on validation failure', async () => {
      const longContent = { ...createData, content: 'a'.repeat(5001) };

      await expect(
        handler.handleCreateCard('user-1', longContent, mockServer),
      ).rejects.toThrow(WsException);
    });
  });
});

Key patterns

  • Mock all dependencies — provide every injected service via useValue with jest.fn() methods.
  • Use jest.Mocked<T> — gives you type-safe access to .mockResolvedValue(), .mockRejectedValue(), etc.
  • Clear mocks in beforeEach — prevents state leaking between tests.
  • Test both success and error paths — verify the handler wraps service errors in WsException.
  • Mock the Socket.IO server — use { to: jest.fn().mockReturnValue({ emit: mockEmit }) } to verify room broadcasts.

Mocking external libraries

When a module uses a third-party library (e.g., bcrypt), mock it before importing the module under test:

const mockBcryptCompare = jest.fn();
const mockBcryptHash = jest.fn();

jest.mock('bcrypt', () => ({
  compare: (...args: unknown[]) => mockBcryptCompare(...args),
  hash: (...args: unknown[]) => mockBcryptHash(...args),
}));

E2E tests

E2E tests use the full NestJS application with supertest. The setup file (apps/back/test/setup-e2e.ts) configures environment variables:

process.env.NODE_ENV = 'test';
process.env.DATABASE_URL =
  process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/retro_test';
process.env.JWT_SECRET =
  process.env.JWT_SECRET || 'test-jwt-secret-key-must-be-at-least-32-characters-long';

Example E2E test:

import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';

describe('HealthController (e2e)', () => {
  let app: INestApplication;

  beforeEach(async () => {
    const moduleFixture = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideProvider(DATABASE_CONNECTION)
      .useValue(mockDb)
      .compile();

    app = moduleFixture.createNestApplication();
    await app.init();
  });

  afterEach(async () => {
    if (app) await app.close();
  });

  it('/health (GET)', () => {
    return request(app.getHttpServer()).get('/health').expect(200);
  });
});

Writing Frontend Tests

Component test structure

Frontend tests use @testing-library/react and @testing-library/user-event:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../button';

describe('Button', () => {
  it('renders with children text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument();
  });

  it('calls onClick handler when clicked', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick}>Click me</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('does not call onClick when disabled', async () => {
    const handleClick = jest.fn();
    const user = userEvent.setup();

    render(<Button onClick={handleClick} disabled>Click me</Button>);

    await user.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });
});

Key patterns

  • Query by role/label — prefer screen.getByRole(), screen.getByText() over getByTestId(). Use data-testid as a last resort.
  • Use userEvent.setup() — simulates real browser interactions more accurately than fireEvent.
  • Use waitFor() for async updates — when testing context providers, async state changes, or API calls.
  • Mock API client — mock the apiClient module when testing components that make API calls:
jest.mock('../../lib/api-client', () => ({
  apiClient: {
    getProfile: jest.fn(),
    login: jest.fn(),
    logout: jest.fn(),
  },
}));

Mocking framer-motion

Components using framer-motion should mock it to avoid animation issues:

jest.mock('framer-motion', () => ({
  motion: {
    div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
  },
}));

Global setup (jest.setup.ts)

The frontend setup file provides:

  • @testing-library/jest-dom — adds custom matchers like .toBeInTheDocument(), .toHaveAttribute().
  • Next.js navigation mock — stubs useRouter, usePathname, useSearchParams.
  • window.matchMedia mock — prevents errors from responsive components.
  • Console suppressionconsole.error and console.warn are silenced by default. If you need to assert on console output, restore it in your test.

Coverage

Thresholds

Coverage thresholds are enforced by Jest. The build fails if coverage drops below these minimums:

Branches Functions Lines Statements
Backend 30% 30% 30% 30%
Frontend 10% 10% 10% 10%

Backend coverage scope

Collects from all **/*.(t|j)s files under apps/back/src/. Output goes to apps/back/coverage/.

Frontend coverage scope

Collects from these directories:

  • app/**
  • components/**
  • contexts/**
  • hooks/**
  • lib/**

Excludes: .d.ts files, node_modules/, .next/, coverage/.

Viewing coverage

# Generate and open HTML coverage report (backend)
yarn workspace back test --coverage
open apps/back/coverage/lcov-report/index.html

# Frontend
yarn workspace front test:coverage
open apps/front/coverage/lcov-report/index.html

CI Integration

Tests run automatically on every push and pull request via GitHub Actions (.github/workflows/ci.yml). The pipeline has four test-related jobs:

1. lint-and-typecheck

Runs linting and type checking (builds both apps).

2. test-backend

  • Starts a PostgreSQL 16 service container.
  • Runs yarn workspace back test --coverage.
  • Uploads coverage to Codecov with the backend flag.

3. test-frontend

  • Runs yarn workspace front test --coverage.
  • Uploads coverage to Codecov with the frontend flag.

4. test-e2e

  • Starts a PostgreSQL 16 service container.
  • Sets DATABASE_URL and JWT_SECRET from CI secrets.
  • Runs yarn workspace back test:e2e.

All test jobs must pass before a PR can be merged.


What to Test

New features

  • Unit tests for service/handler logic.
  • Component tests for new UI components.
  • Test both success and error paths.

Bug fixes

  • Write a test that reproduces the bug first, then fix the code. This prevents regressions.

WebSocket handlers

  • Mock the WsAuthorizationService and domain services.
  • Verify the correct event is emitted to the correct room.
  • Test authorization rejection paths.

Guards and middleware

  • Test that the guard returns true / false for valid / invalid inputs.
  • Test edge cases (missing tokens, suspended users, expired JWTs).

What you can skip

  • Auto-generated boilerplate (module definitions, barrel exports).
  • Pure type definitions.
  • Third-party library internals.

Debugging Tests

Run a single test file

yarn workspace back test -- --testPathPattern="card.handler"
yarn workspace front test -- --testPathPattern="button"

Run a single test case

yarn workspace back test -- -t "should emit card:created"

Debug with Node inspector

yarn workspace back test:debug -- --testPathPattern="card.handler"

Then attach VS Code or Chrome DevTools to the Node inspector on port 9229.

Watch mode

yarn workspace back test:watch
yarn workspace front test:watch

Press p to filter by filename pattern, t to filter by test name.


Common Pitfalls

Problem Solution
Cannot find module '@retro/shared-types' Run yarn workspace @retro/shared-types build-lib first
E2E tests fail with connection errors Ensure PostgreSQL is running (docker-compose up -d)
Frontend test fails with act() warnings Wrap async state updates in waitFor()
Mock not resetting between tests Add jest.clearAllMocks() or mockFn.mockClear() in beforeEach
framer-motion errors in tests Mock it (see Mocking framer-motion)
useRouter not found Already handled by jest.setup.ts; if you need custom values, mock next/navigation in your test