Skip to main content

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 jsdom

Unit test (api)

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

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

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

beforeEach(async () => {
app = await createTestApp({
modules: [AppModule],
database: 'rollback',
});
repo = await app.resolve(UserRepo);
});

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

it('creates and finds', async () => {
const u = await repo.create({ email: 'a@b.c', name: 'Alice' });
expect(u.email).toBe('a@b.c');

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();
});
});

database: 'rollback' wraps each test in BEGIN ... ROLLBACK — fast, isolated, no cleanup boilerplate.

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();
container.register({
provide: UserRepo,
useValue: { findById: vi.fn().mockResolvedValue(null) } as any,
});
container.register({ provide: 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)

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

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

describe('users service end-to-end', () => {
let app: any;
let client: any;

beforeAll(async () => {
app = await createTestApp({
modules: [AppModule],
netron: { http: { port: 0 } }, // 0 → pick free port
database: 'rollback',
});
await app.start();

const port = app.netron.getPort('http');
client = createClient({ url: `http://localhost:${port}` });
await client.connect();
});

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

it('CRUD round-trip', async () => {
const u = await client.invoke('users', 'create', [{ email: 'i@t.c', name: 'I' }]);
expect(u.email).toBe('i@t.c');

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

it('rejects missing user with NOT_FOUND', async () => {
await expect(client.invoke('users', '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, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MockProvider, mockService } from '@omnitron-dev/netron-react/test';
import { UsersPage } from './UsersPage.js';

describe('<UsersPage>', () => {
it('renders user list', async () => {
const users = mockService<any>('users', {
list: vi.fn().mockResolvedValue([
{ id: '1', name: 'Alice', email: 'a@b.c', roles: ['user'] },
{ id: '2', name: 'Bob', email: 'b@c.d', roles: ['admin'] },
]),
});

render(
<MockProvider services={[users]}>
<UsersPage />
</MockProvider>
);

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:5432/platform_test
REDIS_URL: redis://localhost:6379
JWT_SECRET: test-secret

What we covered

LayerWhatSpeed
UnitUsersService.findById with mocked repoms
ModuleUserRepo with real DB + rollbacktens 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"

Next

Step 6 — Deploy → — package everything into Docker + ship to a server.

Troubleshooting

SymptomFix
cannot find module '@my-platform/api-contracts' in testsCheck pnpm install ran after adding the workspace dep
Vitest hangsLikely an unhandled promise from a real DB connection — use database: 'rollback'
Playwright fails to start serversCheck webServer.url matches reality
act() warnings in React testsUse findBy* (async) instead of getBy* (sync)