Add an E2E Test¶
Canonical example: tests-e2e/tests/dropbet-bet-place.spec.ts (84 lines).
1. Create the spec file¶
Place it in tests-e2e/tests/ with the right naming convention:
| Pattern | Playwright project | Base URL |
|---|---|---|
dropbet-*.spec.ts |
dropbet |
http://localhost:3000 (ebit-fe) |
admin-*.spec.ts |
admin |
http://localhost:3001 (admin-fe) |
smoke.spec.ts |
Both | Both |
These patterns are matched by playwright.config.ts:24,31.
Start from this template (mirrors dropbet-bet-place.spec.ts:1-10):
// tests-e2e/tests/dropbet-your-feature.spec.ts
import { expect, test } from '@playwright/test';
import { DEFAULT_DROPBET_USER, signIn } from '../support/signin.js';
import { waitForTraceByRootSpan } from '../support/jaeger.js';
const API_URL = process.env.API_URL ?? 'http://localhost:4000';
test.describe.configure({ mode: 'serial' });
test('your feature — describe what it exercises', async ({ page }) => {
test.setTimeout(90_000);
await signIn(page);
// ... test body ...
});
2. Authenticate¶
Player tests — use signIn(page) from support/signin.ts:23-65.
It drives the ebit-fe login modal, waits for access_token cookie, and is
idempotent (skips if already signed in):
Admin tests — use adminSignIn(request, context) from support/admin-signin.ts:34-71.
It drives the two-step admin auth (sign-in → verify-2fa) directly against the API,
then copies cookies onto the browser context:
import { adminSignIn, DEFAULT_ADMIN } from '../support/admin-signin.js';
test('admin test', async ({ request, context }) => {
const { startMs, adminId } = await adminSignIn(request, context);
// request now carries admin cookies
});
3. Make API assertions¶
Use page.request (shares browser cookies) or import request from the test fixture:
const startMs = Date.now();
const res = await page.request.post(`${API_URL}/your-endpoint`, {
data: { key: 'value' },
});
expect(res.status(), await res.text()).toBeLessThan(300);
const body = await res.json();
// Assert response shape
expect(typeof body.id).toBe('string');
expect(body).toHaveProperty('createdAt');
For negative cases (permission checks, validation), use a separate request context
to avoid polluting the main cookie jar (admin-bets.spec.ts:40-47):
import { request as playwrightRequest } from '@playwright/test';
const isolated = await playwrightRequest.newContext();
await isolated.post(`${API_URL}/auth/sign-in`, { data: OTHER_USER });
const forbidden = await isolated.post(`${API_URL}/admin/endpoint`, { data: {} });
expect(forbidden.status()).toBe(403);
await isolated.dispose();
4. Assert against Postgres directly¶
For state that isn't exposed via API (leaderboard rows, internal columns), query
Postgres through docker exec. Follow dropbet-leaderboard.spec.ts:9-14:
import { execSync } from 'node:child_process';
function psql(sql: string): string {
return execSync(
`sudo docker exec ebit-db psql -U ebit -d ebit -tA -c "${sql.replace(/"/g, '\\"')}"`,
{ encoding: 'utf-8' },
).trim();
}
// Usage
const count = parseInt(psql("SELECT count(*) FROM bet WHERE user_id = 2"), 10);
expect(count).toBeGreaterThan(0);
Flags: -t (tuples only, no headers), -A (unaligned, no padding).
For polling async side-effects, use expect.poll() (dropbet-leaderboard.spec.ts:95-105):
await expect
.poll(() => parseFloat(psql("SELECT amount FROM table WHERE id = 1")), {
timeout: 10_000,
message: 'amount should update after action',
})
.toBeGreaterThan(previousValue);
5. Capture trace IDs¶
Every E2E spec anchors at least one OTel trace via waitForTraceByRootSpan.
Record startMs before the request, then poll Jaeger (support/jaeger.ts:100-123):
const startMs = Date.now();
const res = await page.request.get(`${API_URL}/your-endpoint`);
// ... assertions on res ...
const trace = await waitForTraceByRootSpan({
service: 'ebit-api',
operation: 'GET /your-endpoint',
startMs,
endMsFn: () => Date.now(),
timeoutMs: 20_000,
});
expect(trace.services.has('ebit-api')).toBe(true);
Then attach metadata as annotations (dropbet-bet-place.spec.ts:72-78):
test.info().annotations.push(
{ type: 'traceId', description: trace.traceID },
{ type: 'user', description: DEFAULT_DROPBET_USER.email },
);
console.log(`[your-feature] traceId=${trace.traceID}`);
Annotations surface in the test report and in console.log output during npm test.
6. Run the tests¶
cd tests-e2e
# All tests (serial, workers=1 per playwright.config.ts:14)
npm test
# Single project
npm run test:dropbet
npm run test:admin
# Single file
npx playwright test tests/dropbet-your-feature.spec.ts
# With headed browser (useful for debugging UI sign-in)
npx playwright test tests/dropbet-your-feature.spec.ts --headed
Tests require the full stack running: ebit-api (:4000), ebit-fe (:3000), Postgres, Redis, Jaeger (:16686), and optionally ebit-admin-fe (:3001) for admin specs.