Documentation/Learn/5. Tests
In this section

Step 5 — Tests

By the end: the app has tests at every level of the testing pyramid.

Add Vitest

# In each app:
cd apps/api
pnpm add -D vitest @omnitron-dev/testing

cd ../web
pnpm add -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdom

Compile backend decorators

Titan's constructor injection needs emitted decorator metadata. Run the backend tests against TypeScript's compiled output rather than relying on Vitest's default TypeScript transform.

apps/api/tsconfig.test.json:

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "rootDir": ".", "outDir": ".test-build" },
  "include": ["src/**/*.ts", "test/**/*.ts"]
}

apps/api/vitest.config.ts:

import { defineConfig } from 'vitest/config';
export default defineConfig({
  test: { include: ['.test-build/**/*.test.js'] },
});

Set the API's test script to "test": "tsc -p tsconfig.test.json && vitest run" and add .test-build/ to .gitignore. Set DATABASE_URL to a disposable database before running repository or integration tests; the tests below do not roll back their writes.

Repository integration test (api)

apps/api/src/users/user.repo.test.ts:

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestApplication } from '@omnitron-dev/testing/titan';
import { AppModule }       from '../app.module.js';
import { UserRepo }        from './user.repo.js';

describe('UserRepo', () => {
  let app: TestApplication;
  let repo: UserRepo;

  beforeEach(async () => {
    // `create` bootstraps the module graph with logging off and graceful
    // shutdown disabled — test defaults you would otherwise set by hand.
    app = await TestApplication.create(AppModule);
    repo = app.get(UserRepo);
  });

  afterEach(() => app.close());

  it('creates and finds', async () => {
    const email = `test-${crypto.randomUUID()}@example.com`;
    const u = await repo.create({ email, name: 'Alice' });
    expect(u.email).toBe(email);

    const fetched = await repo.findById(u.id);
    expect(fetched).toEqual(u);
  });

  it('returns null on miss', async () => {
    const u = await repo.findById('missing');
    expect(u).toBeNull();
  });
});

TestApplication has no transaction mode of its own: isolation is the repository's business. @omnitron-dev/titan-database gives you withTransaction, so wrap the body and let it roll back, or point the test at a schema you drop afterwards — app.addCleanup(fn) runs teardown in close().

Service test with mocks

apps/api/src/users/users.service.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { Container } from '@omnitron-dev/titan/nexus';
import { UsersService } from './users.service.js';
import { UserRepo, type User } from './user.repo.js';

describe('UsersService', () => {
  it('throws NOT_FOUND on miss', async () => {
    const container = new Container();
    // `register(token, provider)` — the token is the first argument, not a
    // `provide` key inside the provider. Nexus has no NestJS-style
    // `{ provide, useValue }` single-argument form.
    container.register(UserRepo, {
      useValue: { findById: vi.fn().mockResolvedValue(null) } as any,
    });
    container.register(UsersService, { useClass: UsersService });

    const service = await container.resolveAsync(UsersService);

    await expect(service.findById('missing')).rejects.toMatchObject({
      code: 'NOT_FOUND',
    });
  });
});

Pure DI test — no real DB, no real Application.

Integration test (real api over the wire)

This example exercises the public service from step 2. After adding authentication in step 3, sign in with a seeded test account and attach its token before making protected calls. Use a disposable test database.

apps/api/test/users.integration.test.ts:

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Application } from '@omnitron-dev/titan';
import { HttpTransport } from '@omnitron-dev/titan/netron/transport/http';
import { createClient }    from '@omnitron-dev/netron-browser';
import { AppModule }       from '../src/app.module.js';

describe('users service end-to-end', () => {
  let app:    Application;
  let client: ReturnType<typeof createClient>;

  beforeAll(async () => {
    app = await Application.create(AppModule, { disableGracefulShutdown: true });
    if (!app.netron) throw new Error('Netron is unavailable');
    app.netron.registerTransport('http', () => new HttpTransport());
    app.netron.registerTransportServer('http', {
      name: 'api',
      options: { host: '127.0.0.1', port: 0 },
    });
    await app.start();
    const server = app.netron.transportServers.get('http');
    if (!server) throw new Error('HTTP server did not start');
    const { port } = server as typeof server & { port: number };
    client = createClient({ url: `http://127.0.0.1:${port}` });
    await client.connect();
  });

  afterAll(async () => {
    await client.disconnect();
    await app.stop();
  });

  it('CRUD round-trip', async () => {
    const email = `wire-${crypto.randomUUID()}@example.com`;
    const u = await client.invoke('users@1.0.0', 'create', [{ email, name: 'I' }]);
    expect(u.email).toBe(email);

    const fetched = await client.invoke('users@1.0.0', 'findById', [u.id]);
    expect(fetched).toEqual(u);
  });

  it('rejects missing user with NOT_FOUND', async () => {
    await expect(client.invoke('users@1.0.0', 'findById', ['missing']))
      .rejects.toMatchObject({ code: 'NOT_FOUND' });
  });
});

Real wire format, real serialisation, real error round-trip. Catches a class of bugs unit tests miss.

Frontend component test

apps/web/src/UsersPage.test.tsx:

import { describe, it, expect } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { TestNetronProvider } from '@omnitron-dev/netron-react/test';
import { UsersPage } from './UsersPage.js';

describe('<UsersPage>', () => {
  it('renders user list', async () => {
    render(
      <TestNetronProvider
        testConfig={{
          mocks: [
            {
              service:  'users',
              method:   'list',
              response: [
                { id: '1', name: 'Alice', email: 'a@b.c', roles: ['user'] },
                { id: '2', name: 'Bob',   email: 'b@c.d', roles: ['admin'] },
              ],
            },
          ],
        }}
      >
        <UsersPage />
      </TestNetronProvider>
    );

    await screen.findByText('Alice — a@b.c — [user]');
    expect(screen.getByText('Bob — b@c.d — [admin]')).toBeInTheDocument();
  });
});

Vitest config for the web app:

apps/web/vitest.config.ts:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test:    {
    environment: 'jsdom',
    globals:     false,
  },
});

E2E test (Playwright)

cd apps/web
pnpm add -D @playwright/test
npx playwright install --with-deps chromium

apps/web/playwright.config.ts:

import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  use: { baseURL: 'http://localhost:5173' },
  webServer: [
    {
      command: 'cd ../api && JWT_SECRET=test-secret pnpm dev',
      url:     'http://localhost:3001/healthz',
      reuseExistingServer: !process.env.CI,
    },
    {
      command: 'pnpm dev',
      url:     'http://localhost:5173',
      reuseExistingServer: !process.env.CI,
    },
  ],
});

apps/web/e2e/sign-in.spec.ts:

import { test, expect } from '@playwright/test';

test('signs in and sees users', async ({ page }) => {
  await page.goto('/');

  // Redirected to sign-in
  await expect(page).toHaveURL(/\/sign-in$/);

  await page.getByPlaceholder('Email').fill('admin@example.com');
  await page.getByPlaceholder('Password').fill('correct-horse-battery-staple');
  await page.getByRole('button', { name: /sign in/i }).click();

  // Lands on home
  await expect(page).toHaveURL(/\/$/);
  await expect(page.getByText('admin@example.com')).toBeVisible();
});

Run:

pnpm exec playwright test

Cross-runtime test (a utility)

If you publish a utility consumed by Bun / Deno users:

packages/api-contracts/test/cross.test.ts:

import { loadRuntimeAdapter } from '@omnitron-dev/testing';

const t = await loadRuntimeAdapter();

t.test('User type is structural', () => {
  const u = { id: '1', email: 'a@b.c', name: 'A', roles: ['user'] };
  t.expect(u.email).toBe('a@b.c');
});

Runs on Node + Bun + Deno via the adapter.

CI workflow

.github/workflows/test.yml:

name: test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env: { POSTGRES_PASSWORD: dev, POSTGRES_DB: platform_test }
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres" --health-interval 10s
          --health-timeout 5s --health-retries 5
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
        with: { version: 9 }
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: psql -h localhost -U postgres -d platform_test < apps/api/migrations/001_users.sql
        env: { PGPASSWORD: dev }
      - run: psql -h localhost -U postgres -d platform_test < apps/api/migrations/002_auth.sql
        env: { PGPASSWORD: dev }
      - run: pnpm test
        env:
          DATABASE_URL: postgres://postgres:dev@localhost__LUMEN_COLON__5432/platform_test
          REDIS_URL:    redis://localhost:6379
          JWT_SECRET:   test-secret

What we covered

LayerWhatSpeed
UnitUsersService.findById with mocked repoms
RepositoryUserRepo with a disposable databasetens of ms
IntegrationFull app + Netron clienthundreds of ms
Component<UsersPage> with mocked servicestens of ms
E2EReal browser → real apiseconds

Run the suite:

pnpm test                  # all packages
pnpm -F api test           # just api
pnpm -F web exec playwright test    # E2E

Commit

git add .
git commit -m "step 5: tests across the pyramid"

Troubleshooting

SymptomFix
cannot find module '@my-platform/api-contracts' in testsCheck pnpm install ran after adding the workspace dep
Vitest hangsAwait client disconnect and application close; release any database connections created by the test
Playwright fails to start serversCheck webServer.url matches reality
act() warnings in React testsUse findBy* (async) instead of getBy* (sync)