Files
TS_Proto/TESTING.md
T
nilesh.pandClaude Opus 4.8 4d3eb18a6c Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-13 11:38:01 +05:30

17 KiB

Testing TravelHub

Three suites, each answering a different question.

Suite What it proves Where Count
Go The API behaves — including tenant isolation and seat accounting — against a real PostgreSQL backend/internal/**/*_test.go 115
Angular Components, services, guards and the HTTP interceptor behave, in isolation frontend/src/**/*.spec.ts 114
Playwright The whole stack works in a real browser, the way an agent uses it e2e/tests/*.spec.ts 82

Every test is listed in TEST-CATALOG.md, generated from the sources by npm run docs:tests. CI fails if it is out of date, so it cannot drift.


Running everything

npm run test          # Go, then Angular, then Playwright
npm run test:unit     # Go + Angular only — no browser, a few seconds

Individually:

npm run test:go             # go test ./...
npm run test:go:race        # with the race detector
npm run test:go:cover       # with a coverage report
npm run test:web            # Angular unit tests, one pass
npm run test:web:watch      # Angular unit tests, re-running on save
npm run test:e2e            # Playwright, headless
npm run test:e2e:ui         # Playwright's interactive runner
npm run test:e2e:headed     # watch the browser work
npm run test:e2e:report     # open the last HTML report
npm run lint                # go vet + typecheck the Playwright suite

First-time setup

npm run setup       # installs frontend and e2e dependencies, plus Chromium
npm run db:test     # creates the empty travelhub_test database Go tests use

The Go suite needs an empty database it can own; it applies the schema itself. Nothing else needs preparing.


The Go suite

These are integration tests, not mocked unit tests. They run against a real PostgreSQL, because the behaviour worth protecting here lives in SQL: the tenant_id filter on every query, the SELECT ... FOR UPDATE that prevents overselling, the ON DELETE CASCADE relationships. A mocked database would assert that the code calls the functions it calls, which proves nothing.

Isolation between tests

internal/testsupport gives each test its own tenant, created fresh and dropped afterwards. Because the entire application is tenant-scoped, a per-test tenant is a private world — no truncating tables between runs, no ordering constraints, and every query exercises the tenant scoping on the way past.

func TestSomething(t *testing.T) {
    f := testsupport.Setup(t)              // new tenant, admin + agent users
    flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 10, Price: 250})
    // ... f.Repo, f.Tenant, f.Agent, f.SeatsAvailable(t, flight.ID)
}

Cross-tenant tests simply call Setup twice and confirm the two cannot see each other.

Configuration

Credentials come from backend/.env, so if the app runs, the tests run. To point elsewhere:

TEST_DATABASE_URL=postgres://user:pass@host:5432/some_test_db go test ./...

What is covered

Package Coverage Notable cases
config 97% DSN escaping for passwords containing @ : / # %, .env precedence and parent-directory discovery
repository 84% Tenant isolation on every read and write, concurrent overselling, cancel idempotency, PNR uniqueness
handlers 83% Status codes, payload validation, unknown-field rejection, cross-tenant 404s
middleware 81% Basic auth accept and reject paths, subdomain fallback, the admin gate, CORS

Three tests are worth knowing about by name:

  • TestCreateBookingDoesNotOversellUnderConcurrency fires eight simultaneous bookings at a flight with one seat and asserts exactly one succeeds. Run under -race in CI.
  • TestCancelBookingIsNotRepeatable confirms a second cancel does not credit the seats a second time.
  • TestLoadEscapesAwkwardPasswords round-trips real-world passwords through the DSN builder and back out via the pgx parser.

The Angular suite

Vitest with jsdom, via ng test. Services and guards are tested directly; components are tested through TestBed with fake collaborators, asserting on both component state and rendered DOM.

What is covered:

  • AuthService — credentials are verified against the chosen tenant before being stored, a rejected sign-in leaves no trace, stored credentials are re-validated on restore, and unreadable localStorage degrades to signed out rather than throwing.
  • authInterceptor — stamps Authorization and X-Tenant on API calls, leaves other requests alone, never overwrites a header a caller set deliberately, and signs out on a 401 to a stamped request but not an unstamped one (which would loop on a failed sign-in).
  • authGuard / guestGuard — redirects, and the returnTo round trip.
  • ApiService — query-string construction, including that empty filters are dropped while a zero offset is kept.
  • Components — pagination arithmetic, fare recalculation as travellers are added, seat-shortage warnings, error-message mapping by status code, and the null-vs-[] shape Go produces for empty slices.

The Playwright suite

Coded UI tests driving Chromium against the real Angular app and the real Go API.

e2e/
├── playwright.config.ts   projects, reporters, and the servers to start
├── fixtures/
│   ├── accounts.ts        the seeded demo logins
│   └── test.ts            page-object fixtures + the console-error guard
├── pages/                 one page object per screen
└── tests/                 the specs

It starts the stack for you

playwright.config.ts declares both servers under webServer, so npm run test:e2e boots the Go API and the Angular dev server, waits for both to answer, and shuts them down afterwards. If they are already running locally it reuses them.

Page objects, not selectors in tests

Every screen has a page object exposing intent (search.searchRoute('JFK', 'LHR'), bookingDetail.cancel(reference)) rather than selectors. A markup change is then one edit, not twenty.

Destructive actions name their target: cancel(expectedReference) asserts that booking is the one on screen before it clicks. These tests share a database with seeded demo data, and a mis-step earlier in a test must not be able to void somebody else's booking.

Every test also asserts a clean console

The consoleErrors fixture fails any test that produced an uncaught exception or a console.error. A stack trace in the console is a real defect even when the assertions happen to pass. The browser's own "Failed to load resource" notice for deliberate 401s and 404s is filtered out — it says nothing about whether the app handled the response.

What is covered

  • auth.spec.ts — the agency picker, demo shortcuts, wrong passwords, valid credentials aimed at the wrong agency, session persistence across reloads, the returnTo round trip, and stale stored credentials being discarded.
  • search.spec.ts — filtering by route and cabin, party-size filtering, all three sort orders (asserted by reading the rendered fares and durations back and checking they are ordered), pagination, and the empty state.
  • booking.spec.ts — the full sale: fare carried into checkout, repricing as travellers change, validation, confirmation with a PNR, the booking appearing in the list, seats leaving inventory and coming back on cancel, and the two-step cancel being cancellable itself.
  • tenant-isolation.spec.ts — the same route priced differently per agency, one agency's booking invisible to the other including by deep link, per-agency revenue, and a deep link to another agency's flight being refused.
  • dashboard.spec.ts — headline figures, and a delta-based check that a sale moves revenue and seats sold, and cancelling moves them back.
  • responsive.spec.ts — no horizontal scrolling at 390px on any screen, wide tables scrolling inside their own container, and the sidebar collapsing.
  • simulation.spec.ts — the UI under simulated backend conditions; see below.

Other simulation techniques available

Playwright gives more than route interception, and these are worth reaching for rather than reinventing:

Technique How Good for
Record a test npx playwright codegen http://localhost:4200 Drafting a spec by clicking through the app; it emits selectors and assertions you then tidy into a page object
Replay a failure npx playwright show-trace test-results/<dir>/trace.zip A DOM snapshot, network log and console for every step of a failed run. Already configured: trace: 'retain-on-failure'
Device emulation devices['Pixel 7'] in a project Real viewport, user agent and touch behaviour. The mobile-chrome project already does this
Throttled network CDP Network.emulateNetworkConditions Slow 3G, high latency. For per-request latency the slow() helper is simpler
Visual regression await expect(page).toHaveScreenshot() Catching unintended layout drift. Not enabled here: snapshots are platform-specific, so CI needs its own baselines committed per OS
Accessibility audit @axe-core/playwright Contrast, labelling and ARIA violations. Not installed; one dependency away
Clock control await page.clock.setFixedTime(...) Freezing "today" so date-dependent screens are deterministic

The run report: evidence from what passed

Playwright's own HTML report is a debugging tool — it keeps screenshots and traces only when something fails, which tells you nothing about what the suite actually proved. A second reporter fills that gap.

npm run test:e2e       # generates the report as part of every run
npm run report         # opens it from disk
npm run report:serve   # serves it at http://localhost:9400 instead

npx playwright show-report will not show this report. That command serves Playwright's own report from e2e/playwright-report/, which is a different artifact for debugging failures. Run with no path argument from the repo root and it binds port 9323 to a directory that does not exist there, so every request returns 404 — a server that appears to be up but serves nothing.

Output lands in e2e/evidence-report/:

File Purpose
index.html The report: a verdict, a summary by capability, then every scenario with its data and screenshots
report.md The same content as Markdown, for pasting into a ticket or a PR
report.json Machine readable, for a dashboard or a diff between runs
shots/ The screenshots

It is structured as a document rather than a log: cover page with the verdict and run details, §1 Summary with a pass-rate table per area, then §2 Detailed results numbered 2.3.4 so a specific scenario can be cited in an email. Areas are named after what they cover ("Keeping agencies separate"), not after spec files.

Exporting to PDF

Three ways, all producing the same layout:

npm run report:pdf           # everything — about 99 pages, 11.5 MB
npm run report:pdf:summary   # summary + the narrated scenarios — 29 pages, 2.3 MB

Or open the report and press Save as PDF in the toolbar, which is just your browser's print dialog.

The scripted export uses the Chromium that Playwright already installed, so there is no extra dependency and CI can attach a PDF to a build. Write it elsewhere with --out:

cd e2e && node scripts/report-pdf.mjs --out ../release-evidence.pdf

Two details the export has to handle, and does: a collapsed <details> prints only its one-line summary, so everything is opened first; and lazily-loaded images never load off-screen because the PDF renderer does not scroll, so they are forced eager and awaited before printing.

Narrating a scenario. The evidence fixture records a named moment — a screenshot plus whatever the test observed:

await evidence.step('Seats removed from inventory', {
  flight: flightNumber,
  seatsBefore,
  seatsAfterSale,
  difference: seatsBefore - seatsAfterSale,
});

That becomes a titled entry in the report with a data table and the screenshot beneath it. evidence.note(...) records data with no screenshot, for values compared across pages; evidence.fullPage(...) captures the whole scrollable page instead of the viewport.

The headline journeys are narrated this way — sign-in, route search and fare sorting, the full booking lifecycle, per-agency pricing, cross-tenant invisibility, and the dashboard before and after a sale. Every other test still contributes its end-of-test screenshot automatically (screenshot: 'on'), so the report covers the whole run; the toolbar filters to the narrated ones.

A typical full run produces 96 screenshots across 82 scenarios, about 14 MB. The directory is git-ignored and CI keeps it as an artifact for 30 days.

Simulated-condition tests

simulation.spec.ts is the odd one out: it intercepts /api/** in the browser and answers it directly, so the real backend is never called.

This is for the states you cannot practically stage for real — a 500 from the database, a two-second response, a seat sold by somebody else in the half second between opening checkout and confirming, an agency with 98,432 flights, a passenger whose name is 200 characters long. Staging those against a live database means fixture gymnastics and flaky timing; intercepting takes a line.

await simulateApi(page, { flights: fail(500, 'boom') });
await page.goto('/search');
await expect(page.locator('.alert-error')).toContainText('Could not load flights');

simulateApi fills in a healthy default for every endpoint the scenario does not mention, so a test spells out only the one condition it is about. Three helpers cover the rest:

Helper Meaning
fail(status, message?) An error response in the API's own shape
slow(body, delayMs) A successful but slow response, for loading states
reply({status, body, delayMs}) Anything else
{ offline: true } Abort every call, as though the network were gone

What it covers: failing and slow APIs on every screen, going offline mid-session, an expired session bouncing to sign-in and clearing storage, an empty agency, a brand-new agency whose topRoutes is null, the last seat flagged urgent, a 409 at the moment of sale, a party larger than the cabin, a 98,432-result page, 200-character names not widening the page, markup in data being escaped rather than rendered, and keyboard-only operation.

These are the cheap ones: 19 tests in about 20 seconds, no database writes. They complement the real-stack specs rather than replacing them — those prove the system works, these prove the UI degrades well.

One trap worth knowing about. The reply envelope is branded with a symbol, not recognised by shape. Domain objects here have a status field of their own — a flight is scheduled, a booking is confirmed — so duck-typing on status silently reads a booking as an HTTP response. That bug cost five failing tests before it was found.

Two deliberate constraints

Single worker. These tests share one database and several assert on seat counts and booking totals. workers: 1 keeps those assertions meaningful. The suite runs in about 90 seconds, so the parallelism is not missed.

The suite writes to your development database. Tests cancel every booking they create and assert in deltas rather than absolutes, so they are safe to re-run and do not care what the database already held. If you would rather keep development data pristine, point the API at a scratch database before running:

DB_NAME=travelhub_e2e npm run test:e2e

Continuous integration

.github/workflows/ci.yml runs three jobs on every push and pull request:

  1. backend — go vet, a gofmt check, then go test -race against a PostgreSQL 17 service container, with coverage uploaded as an artifact.
  2. frontend — npm ci, unit tests, and a production build.
  3. e2e — migrates a fresh database, installs Chromium, typechecks the suite, and runs Playwright. The HTML report and any failure traces are uploaded as artifacts and kept for 14 days.

The e2e job waits for the other two, so a broken unit test fails fast without spending browser time.


Adding tests

A new API endpoint gets repository tests for the SQL (including a cross-tenant case that must return ErrNotFound) and handler tests for the status codes and validation.

A new screen gets a page object in e2e/pages/, component tests for its logic, and at least one Playwright test for the path a user actually takes.

A bug fix gets a test that fails before the fix. The two CSS bugs found while building this suite — a 1fr grid track refusing to shrink below its table's min-content width, and a text-transform: capitalize applied to email addresses — are both now guarded, the first by responsive.spec.ts and the second by the shell component test.

One rule worth keeping: assert on behaviour the user can observe, not on implementation. expect(seatsAfter).toBe(seatsBefore - 2) survives a refactor; asserting that a particular method was called does not.