Skip to content

Testing

Tomoda's test strategy has six layers, each with a clear cost / coverage tradeoff:

Layer Where Runs against Invocation
Unit backend/internal/**/*_test.go, frontend/__tests__/ In-process mocks (testify/mock) + miniredis for Redis-backed code task test:unit / npm test
In-process integration backend/tests/integration/, frontend/__tests__/integration/ miniredis (backend) / mocked fetch + WebSocket (frontend). No docker required. go test ./tests/integration/... / npm test
Endpoint (real routes) backend/tests/integration/endpoint_harness_test.go (tests named *Integration*) Full Wire graph + real route table (wiring.SetupRouter) over the dev Postgres in an isolated schema; miniredis; external providers faked with httptest servers via config URLs (Google.PlacesDetailsURL, Resolution.SerperSearchURL, Photon.URL, Semantic.BaseURL) task test:integration (skips when Postgres is down)
Real-stack integration backend/**/*_test.go (Go tests with Integration in name) Real Postgres + Redis + MinIO from docker-compose.dev.yml task test:integration
Smoke backend/cmd/server/main_test.go, backend/tests/smoke/, e2e/tests/{smoke,pages,auth}.spec.ts Wire DI graph assembly + a minimal health-only chi router (backend); mocked-API Playwright (frontend) go test ./cmd/server/... ./tests/smoke/... / ./scripts/run_e2e.sh
Simulation backend/cmd/test/sim-workers/ Real DB + Redis, simulated user fleet task test:simulation

Where a test lives (canonical layout)

The kind of test decides its home. This is enforced, not a suggestion:

  • Unit — co-located foo_test.go beside foo.go under internal/**. No real infra; must pass under -short. May reach unexported symbols (same package) or use the foo_test external package.
  • Integration — under backend/tests/integration/<service>/, package integration (or <service>_integration), written against the service's exported API only (no unexported access). Anything that needs a real Postgres (testutil.NewPostgres), miniredis, or MinIO is an integration test and belongs here, not co-located in internal/**. Test functions carry Integration in the name so task test:integration selects them.
  • Smokebackend/tests/smoke/ (package smoke): Wire DI graph + startup wiring.
  • Shared helpers — reusable fixtures/builders live in backend/tests/testsupport/ (an importable package); the throwaway-schema DB harness stays in internal/testutil. Don't duplicate helpers per package.

task test:unit runs the co-located unit tests (-short); task test:integration runs backend/tests/integration/... against the dev Postgres (PostGIS + PGroonga); task test:smoke runs backend/tests/smoke/.... All three run in CI (the integration job stands up a PostGIS+PGroonga service).

Unit tests

Backend

task test:unit            # go test -v -short ./...

Unit tests live next to the code they test (foo.gofoo_test.go). The -short flag skips anything that needs a live database.

Test doubles: the project uses testify/mock — hand-rolled Mock<Iface> structs with mock.Mock embedded. Repository interfaces have these doubles in the same package (see e.g. backend/internal/services/mock_*_test.go). There is no gomock / mockgen step; if you need a new mock for an interface that doesn't have one, add it by hand following the existing pattern.

Redis-backed code uses miniredis (github.com/alicebob/miniredis/v2) instead of mocking the redis.Client interface — that catches real pub/sub, SETNX, ZRANGEBYSCORE, and TTL semantics that mocks miss. Pattern:

mr := miniredis.RunT(t)  // auto-closes on test end
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
// pass rdb where the production code expects redis.UniversalClient / Cmdable

miniredis.RunT(t) gives a fresh server per test — no shared state. Note that miniredis uses a fake clock for TTLs; call mr.FastForward(d) to advance it when testing expiry behavior.

Frontend

cd frontend
npm test

Frontend unit tests are at frontend/__tests__/ and use Jest + @testing-library/react-native. Coverage includes hooks (useSearch, useLocalizedName), contexts (AuthContext, FriendsContext), service clients (chatService, eventService, friendService, discoveryService), pure utilities (date, location, password validator), and a handful of UI primitives. Mock the network boundary via jest.mock of the service module, or stub global.fetch / global.WebSocket for service-level tests.

In-process integration tests

cd backend && go test ./tests/integration/...
cd frontend && npm test   # picks up __tests__/integration/ automatically

These wire multiple components together but don't require docker.

  • Backend (backend/tests/integration/, package integration): miniredis-backed flows that exercise contracts across packages. Examples: two websocket.Hub instances exchanging messages via chat:event:* pub/sub with origin-dedup; three scheduler.Scheduler instances electing a single leader and failing over; Worker queue ownership; ChatService cache hit/miss + trim; auth refresh-token full lifecycle.
  • Frontend (frontend/__tests__/integration/): AuthContext + login flow with a mocked fetch; chatService WS lifecycle (connect → message → reconnect → max-attempts → heartbeat); chat screen render-and-send with a mocked WebSocket.

These run on every go test ./... / npm test and are the best catch-net for breakage from refactors that unit tests miss but a full stack-up integration test would be too slow to debug.

Real-stack integration tests

task test:integration     # go test -v -run Integration ./...

These spin up against the already-running local Docker stack — Postgres, Redis, MinIO. Make sure task dev (or at least docker-compose -f docker-compose.dev.yml up -d) is running first, otherwise the connection setup in the test will fail.

Use this layer for things miniredis can't fake — real SQL behavior, JSONB serialization, PostGIS spatial queries, pgx type handling, AWS SDK round-trips against MinIO.

Smoke tests

Backend

go test ./cmd/server/... ./tests/smoke/...

Fast checks that the binary's startup wiring is sound:

  • backend/cmd/server/main_test.go — table-driven test of the --mode resolver (full / multi-hub / async / api-hub / ws-hub) and a Wire DI smoke that calls the real InitializeApp against miniredis + sqlite to assert every top-level component is non-nil.
  • backend/tests/smoke/ — runtime-shape checks: Hub.Run starts without panic (with + without Redis), /health responds, the mode matrix is coherent (api-hub ∪ ws-hub == multi-hub).

These don't exercise behavior — they catch infrastructure regressions (broken DI graph, missing health endpoint, mode misconfiguration).

Frontend (Playwright)

E2E tests live at e2e/ in the repo root (config: e2e/playwright.config.ts). The Playwright suite is treated as smoke — shallow, fast, broad — not deep flow verification:

  • e2e/tests/smoke.spec.ts — backend /health + frontend / reach.
  • e2e/tests/pages.spec.ts — each top-level route loads without console errors, with the backend API stubbed via page.route('**/api/v1/**', ...).
  • e2e/tests/auth.spec.ts — auth gate redirects unauthed users, login form renders the right fields, login submit posts and redirects out of /auth/login.

The driver script scripts/run_e2e.sh orchestrates the full stack:

  1. Detects Docker or Podman + a compose tool.
  2. npm install + npx playwright install chromium in e2e/.
  3. Brings up the full application stack with docker compose up -d --build (uses docker-compose.yml, the production-style compose file with backend + frontend containers built locally).
  4. Polls http://localhost:8080/health until the backend is healthy (max ~60s).
  5. Polls http://localhost:8081 until the frontend returns 200 OK.
  6. Runs npx playwright test.
  7. Optionally tears the stack down.
./scripts/run_e2e.sh

The report ends up at e2e/playwright-report/index.html. On CI, playwright.config.ts enables 2 retries and forces workers: 1 for deterministic runs.

Trace on first retry

trace: 'on-first-retry' means Playwright records a trace the second time a test runs after failing. View it with npx playwright show-trace path/to/trace.zip — far more useful than raw logs.

Simulation tests

Simulation tests stress the system from the user side — simulated workers that move around, send messages, create events, and trigger discovery / presence updates. They're how we shake out load-related bugs that don't show up under single-user testing.

task test:simulation

What it does:

  1. Runs backend/cmd/test/data — drops the schema, recreates the extensions, migrates, then re-seeds fresh test data (~20+ users, events, chats) and clears Redis keys.
  2. Runs task db:seed:catalog — pulls the published location seed set from GCS (minus the global Overture file) and loads it into the fresh DB, so sim workers have real places to reference. Needs gcloud login.
  3. Runs backend/cmd/test/sim-workers — spins up the worker pool that simulates user behavior. Ctrl+C to stop.

The infra (Postgres, Redis, MinIO) must already be up via task dev or task docker:up. The script doesn't bring up its own dependencies.

Coverage

task test:coverage

Runs the full backend suite with -coverprofile=coverage.out, then renders an HTML report at backend/coverage.html and opens it in your browser. Treat the number as a guide, not a target. Many integration paths are covered indirectly by the in-process integration tests, smoke tests, real-stack E2E, and simulation — none of which contribute to Go's coverage profile for the package under test.

What runs in CI

The backend CI workflow (.github/workflows/backend-ci.yml) runs verify:backend, the sqlc drift guard, test:smoke, test:unit, and test:integration. The integration step stands up a PostGIS + PGroonga Postgres service (built from docker/postgres) so backend/tests/integration/... runs against a real database, then build:backend.

task ci is the umbrella target — lint, vet, format, typecheck, test, build. Run it locally before pushing if you want a fast confidence check that CI will be happy.

CI itself is Cloud Build for image builds (see Cloud Build); the full test pipeline (task ci) is typically run from a separate CI system or locally pre-push — verify the actual CI configuration with the team.

Playwright in CI

The Playwright suite needs npm install && npx playwright install before running. The driver script scripts/run_e2e.sh handles both. Headless Chromium binaries are not committed; CI must install them.