Skip to content

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):

await signIn(page);
// page.request now carries the auth cookie for all API calls

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.

You're done — test by...

cd tests-e2e
npx playwright test tests/dropbet-your-feature.spec.ts
# Should output: [your-feature] traceId=<hex> ...
# Open http://localhost:16686 → search ebit-api → paste traceId to verify spans