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 jsdomCompile 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 chromiumapps/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 testCross-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-secretWhat we covered
| Layer | What | Speed |
| Unit | UsersService.findById with mocked repo | ms |
| Repository | UserRepo with a disposable database | tens of ms |
| Integration | Full app + Netron client | hundreds of ms |
| Component | <UsersPage> with mocked services | tens of ms |
| E2E | Real browser → real api | seconds |
Run the suite:
pnpm test # all packages
pnpm -F api test # just api
pnpm -F web exec playwright test # E2ECommit
git add .
git commit -m "step 5: tests across the pyramid"Troubleshooting
| Symptom | Fix |
cannot find module '@my-platform/api-contracts' in tests | Check pnpm install ran after adding the workspace dep |
| Vitest hangs | Await client disconnect and application close; release any database connections created by the test |
| Playwright fails to start servers | Check webServer.url matches reality |
act() warnings in React tests | Use findBy* (async) instead of getBy* (sync) |