Skip to content

Testing

Tomoda's test strategy has five 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
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 + in-process gin 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

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, H3 spatial queries, full GORM hooks, 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 /Users/zayaanm/workspace/tomoda/e2e/ (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 — clears the DB tables + Redis keys and re-seeds fresh test data (~20+ users, events, chats).
  2. 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

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.