Testing
Closegram is a monorepo with several independent test setups, one stack per package. This page is the full cross-package reference; each package's own conventions are documented in its section below.
| Package | Stack | Runner | What it covers |
|---|---|---|---|
apps/backend | Jest (multi-project) | npm test | GraphQL resolvers, managers, access-services, validators — unit (mocked) + integration (real Postgres) |
apps/frontend-nextjs | Vitest + React Testing Library, Playwright | npm test, npm run test:e2e | Component unit tests + end-to-end browser flows |
apps/frontend-admin | Vitest + React Testing Library, Playwright | npm test, npm run test:e2e | Admin UI-kit component tests + end-to-end flows |
packages/ui | Vitest + React Testing Library | npm run test -w @repo/ui | Shared design-system primitives (behavior). Storybook render-smoke via test-storybook — see Storybook |
apps/ios | XCTest | bundle exec fastlane test | Auth feature only, layered by Clean Architecture |
The root turbo run test fans the test task out across every workspace (backend Jest + each web app's Vitest run). E2E (Playwright) and Storybook are invoked per-package and are not part of the root test pipeline. Storybook has its own page: see Storybook.
Root-level commands
Root package.json also defines a few repo-wide scripts that sit above the per-package ones in the table above:
| Command | What it does |
|---|---|
npm run test | turbo run test — every workspace's own test task (backend Jest + each web app's Vitest), Turbo-orchestrated |
npm run test:frontend | @repo/ui + frontend-nextjs + frontend-admin only (no backend) |
npm run test:backend | Backend only — npm run test:report -w backend (the CI-style Jest run, larger heap + --workerIdleMemoryLimit=512MB) |
npm run test:all | The full suite: test:frontend then test:backend, piped through tee to test-results.log at the repo root (so a full run's output is inspectable afterwards, not just visible in the terminal scrollback) |
npm run test:log | turbo run test --output-logs=new-only, also tee'd to test-results.log — a lighter-weight variant of test:all that goes through Turbo instead of a plain frontend-then-backend shell pipeline |
npm run test:all is the command to run before considering any feature-work task done (see the root CLAUDE.md) — it's the only one of these that covers frontend and backend in one shot.
The TZ=UTC requirement
The backend's unit suite freezes the system clock in several tests (e.g. apps/backend/tests/unit-test/payments-subscriptions.unit.test.js, via jest.useFakeTimers() + jest.setSystemTime(...)) under the assumption that the container's timezone is UTC — the same assumption CI and production run under. Running the suite on a machine whose local timezone isn't UTC can shift date-boundary assertions (e.g. "is this timestamp still today") and produce failures that have nothing to do with the change you're testing.
Always export TZ=UTC before running backend tests locally on a non-UTC machine:
TZ=UTC npm run test:all # or npm test / npm run test:unit from apps/backend
This repo's own tooling already does this for you in one place: the Stop hook that Claude Code runs after finishing a turn invokes TZ=UTC npm run test:all for exactly this reason. Any other automation or CI pipeline that runs the backend suite should do the same.
Backend — Jest (apps/backend)
The backend uses a single Jest install with a multi-project config (apps/backend/jest.config.js) that splits tests into two projects — unit and integration — run sequentially (maxWorkers: 1, to avoid DB conflicts between integration suites).
| Project | testMatch | Setup file | Timeout | Touches a DB? |
|---|---|---|---|---|
unit | tests/unit-test/**/*.test.js | tests/setup.js | 10 000 ms | No — everything is mocked |
integration | tests/integration/**/*.test.js | tests/integration/setup.integration.js | 30 000 ms | Yes — real Postgres via Sequelize |
Directory layout
apps/backend/
├── jest.config.js
├── scripts/test.sh # full lifecycle wrapper (docker up → migrate → jest → teardown)
├── .env.test # gitignored test DB/Redis creds (must exist locally)
├── docker-compose.yml # postgres-test / redis-test services (profile: test)
└── tests/
├── setup.js # unit-project setup — mocks everything
├── helpers/
│ ├── apollo-server-helper.js # builds a real Apollo Server for integration tests
│ └── test-data-generator.js # TestDataGenerator — unique users/emails/usernames per call
├── unit-test/ # *.unit.test.js (managers, access-services, validators, utils)
│ └── resolvers/ # *.resolver.test.js, one per GraphQL resolver
│ └── user-resolver/ # large resolvers split into topic files (+ a local setup.js)
└── integration/ # *.integration.test.js — DB-backed suites
├── setup.integration.js # loads .env.test, opens/closes the Sequelize connection
└── user-resolvers/ # user.resolver integration suite, split by topic (+ setup.js)
Naming convention. Unit tests end in .unit.test.js (resolver unit tests end in .resolver.test.js / .test.js under resolvers/); integration tests end in .integration.test.js. Match the module under test with a same-name test file — e.g. message.resolver.js → tests/unit-test/resolvers/message.resolver.test.js and tests/integration/message.integration.test.js. Large resolvers (e.g. user.resolver.js) are split into topic-based files inside a <name>-resolver/ subfolder.
Unit tests
Unit tests mock the manager / access-service / DB layer and call resolvers (or managers) directly — nothing below the mock ever runs, so there is no database. The global tests/setup.js already mocks the shared surface (pubsub.service, firebase.service, s3.service, notification services, database/models, data-access-services, the admin user manager/validator, translation.service, and the permissions.requireAuth middleware), so most files inherit those and only override what they need per-file.
Pattern: jest.mock(...) the dependencies at the top, jest.clearAllMocks() in beforeEach, build a mockContext ({ user: { userId, username }, lng/lang }), then call resolvers.Query.x(...) / resolvers.Mutation.x(...) directly. If a resolver reaches into an access-service directly (not just its manager), that access-service must be mocked too, or the test will hit a live Sequelize model.
To assert an operation requires auth, set mockContext.user = null and expect an 'authentication.required' error.
Integration tests
Integration tests exercise the full path — GraphQL → resolver → manager → access-service → Postgres. They build a real (unrouted) Apollo Server from the actual schema via createUserTestServer() / createAdminTestServer() (tests/helpers/apollo-server-helper.js), drive it with createExecuteWithAuth(server) (wraps server.executeOperation, attaching a Bearer token / Accept-Language header and unwrapping the Apollo v4 response shape to { data, errors }), and use real managers/access-services against the test DB.
Register data with userManager.register(...) + TestDataGenerator.user() (unique username/email per call to avoid collisions in the shared, non-reset-per-test DB), then clean up created rows in afterAll via the relevant access-service's deleteAll/destroyAll/delete methods. The Sequelize connection itself is closed globally in setup.integration.js, not per-file. Directory-local setup.js files (e.g. tests/integration/user-resolvers/setup.js) hold shared createTestUser/createAdminUser/cleanupTestData helpers — check for one before adding new fixtures.
Test DB / Docker
Integration tests need a real Postgres and Redis. These are dedicated test containers (defined in apps/backend/docker-compose.yml under profiles: [test]), deliberately separate from the dev postgres/redis services so tests never touch dev data:
| Service | Container | Image | Default DB | Default port |
|---|---|---|---|---|
postgres-test | closegram-postgres-test | postgres:15-alpine | closegram_test | 5433 |
redis-test | closegram-redis-test | — | — | 6380 |
Credentials come from apps/backend/.env.test (gitignored — it must exist locally).
npm test (and test:unit / test:integration) run the full lifecycle via scripts/test.sh: start the containers → wait_for_postgres (polls pg_isready, 30 attempts / 2 s) → wait_for_redis (polls redis-cli ping, 20 attempts / 2 s) → NODE_ENV=test npx sequelize-cli db:migrate → Jest (with --forceExit, tee'd to logs/test-<timestamp>.log, symlinked logs/test-latest.log) → always tear the containers and their named volumes down via an EXIT trap, on success or failure. All other scripts assume the test DB is already up.
To bring the DB up manually (for watch mode, coverage, or repeated runs):
cd apps/backend
npm run docker:up:test # docker compose --env-file .env.test --profile test up -d postgres-test redis-test
npm run migrate:test # cross-env NODE_ENV=test npx sequelize-cli db:migrate
# or both at once:
npm run test:db:setup # docker up + sleep 3 + migrate
# when done:
npm run test:db:teardown
Other DB helpers: migrate:test:undo, migrate:test:undo:all, seed:test, db:test:reset (undo-all + migrate).
Backend command reference
Run from apps/backend/:
| Command | What it does |
|---|---|
npm test | Full lifecycle (docker up → migrate → all Jest projects → teardown) |
npm run test:unit | Same lifecycle, --selectProjects unit |
npm run test:integration | Same lifecycle, --selectProjects integration --forceExit |
npm run test:unit (iterate) | Fast, DB-free — use this for most iteration |
npm run test:watch | jest --watch (no lifecycle wrapper — DB must already be up) |
npm run test:integration:watch | jest --selectProjects integration --watch (no lifecycle wrapper) |
npm run test:coverage / :unit / :integration | As above with --coverage; does not spin up Docker/migrate itself — start the DB first for the integration portion |
npm run test:debug | node --inspect-brk … jest --runInBand |
npm run test:report | Unit run with a larger heap + --workerIdleMemoryLimit=512MB (CI-style) |
Only test, test:unit, and test:integration manage Docker/migrations automatically; everything else assumes the test DB is already running.
Backend coverage
- Collected from:
graphql/resolvers/,managers/,validators/,services/,data-access-services/(excludesnode_modules,coverage,tests). The model layer and schema are excluded — they're mostly declarative. - Output:
apps/backend/coverage/. Reporters:text(terminal),lcov, andhtml(browsable report). - Thresholds (global):
statements: 20,branches: 15,functions: 20,lines: 20. These are a deliberately conservative "don't let it silently go to zero" floor (noted as such injest.config.js), not a real target — meant to be ratcheted up once real numbers are measured vianpm run test:coverage. verbose: trueis set globally. An optionaljest-html-reportersreport (./test-report/index.html, "Closegram Backend Tests") is wired up only if that package is installed — the config probes withrequire.resolveand silently skips it otherwise.
There's also a static drift check unrelated to Jest:
npm run check:schemacompares schema fields against exported resolvers with no DB or test runner needed. See Backend Architecture.
Web apps — Vitest + Playwright (apps/frontend-nextjs, apps/frontend-admin)
Both Next.js apps share an identical testing stack — Vitest + React Testing Library for unit/component tests and Playwright for e2e — wired up nearly identically, with a few app-specific differences.
Unit tests (Vitest)
Configured in each app's vitest.config.ts: environment: 'jsdom', globals: true, setupFiles: ['./vitest.setup.ts'], and the glob include: ['src/**/*.test.{ts,tsx}'] (excluding node_modules, e2e/, and *.stories.tsx). Test files are co-located next to the component they test — there is no separate __tests__/ directory.
App-specific differences:
| frontend-nextjs | frontend-admin | |
|---|---|---|
| Path alias | @ → src, plus @/lib/firebase → src/test/mocks/firebase.ts | @ → src |
Extra jsdom polyfills (vitest.setup.ts) | window.matchMedia, Element.prototype.animate, Element.prototype.scrollIntoView | window.matchMedia, ResizeObserver |
| Test-utils wrapper | renderWithProviders (Apollo MockedProvider + Theme + Toast, optional Auth) | renderWithIntl (NextIntlClientProvider with a small Spanish testMessages bag) |
| Unit test files (current) | 8 (src/components/**) | 11 (mostly src/components/ui/**) |
The Firebase alias in frontend-nextjs means any component importing the real Firebase client transparently gets src/test/mocks/firebase.ts in every unit test — no per-test mocking needed. Neither app configures coverage in vitest.config.ts — coverage collection isn't wired up in either web app today.
Use the app's renderWith* wrapper for provider-heavy components; plain RTL render is fine when a component needs no providers. Query by accessible role/name, not implementation details:
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '@/test/test-utils'; // frontend-nextjs
import { ThemeToggle } from './ThemeToggle';
it('flips the aria-label after being clicked', async () => {
renderWithProviders(<ThemeToggle />);
const btn = screen.getByRole('button');
const before = btn.getAttribute('aria-label');
await userEvent.click(btn);
expect(btn.getAttribute('aria-label')).not.toBe(before);
});
End-to-end tests (Playwright)
Specs live in each app's e2e/*.spec.ts. Both configs use a single chromium project (no Firefox/WebKit), retry only in CI, capture a trace on first retry, and auto-boot the app with npm run dev unless E2E_BASE_URL is set — letting you point Playwright at an already-running server + backend instead.
| frontend-nextjs | frontend-admin | |
|---|---|---|
baseURL / dev port | http://localhost:3000 | http://localhost:3100 |
| Reporter | github (CI) / html (local) | list |
| Specs | smoke, auth, authenticated, public-pages | smoke, auth, authenticated |
The admin app has no public-pages spec (no public marketing pages). Style-wise, frontend-nextjs smoke tests assert a hard redirect to /login; admin's are written more defensively (status < 500, "login route OR password field present") since its login flow may render at various URLs.
Point e2e at an already-running stack:
E2E_BASE_URL=http://localhost:3000 npm run test:e2e -w frontend-nextjs
E2E_BASE_URL=http://localhost:3100 npm run test:e2e -w frontend-admin
Web command reference
Scripts are identical in both apps (test, test:watch, test:e2e, test:e2e:ui, test:ui). Run from the repo root with -w, or cd into the app first:
| Command | What it does |
|---|---|
npm run test -w frontend-nextjs | Vitest single run (CI-style) |
npm run test:watch -w frontend-nextjs | Vitest watch mode |
npm run test:ui -w frontend-nextjs | Vitest UI |
npm run test:e2e -w frontend-nextjs | Playwright (auto-boots next dev --port 3000) |
npm run test:e2e:ui -w frontend-nextjs | Playwright UI mode |
Swap frontend-nextjs for frontend-admin (its e2e auto-boots on port 3100). Repo-wide: npm run test (turbo run test, all workspaces incl. backend) or npm run test:frontend (@repo/ui + both apps). There is no root-level e2e pipeline — Playwright is invoked per-app.
Shared UI library — Vitest (packages/ui)
@repo/ui has its own Vitest install for behavior tests of the design-system primitives (vitest.config.ts: jsdom, globals: true, include: ['src/**/*.test.{ts,tsx}']). There are 15 test files — 13 component *.test.tsx (one per Primitives/* component) plus 2 plain-logic *.test.ts (src/styles/boxes.test.ts, src/utils/cx.test.ts) — each co-located next to the component's implementation and its .stories.tsx (e.g. src/Button/{Button.tsx, Button.stories.tsx, Button.test.tsx}).
Tests use React Testing Library + @testing-library/user-event + Vitest, asserting render output and behavior (click handlers, disabled state, conditional rendering) through accessible queries:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
it('fires onClick when clicked', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});
Vitest tests and Storybook stories are complementary: *.test.tsx covers behavior; *.stories.tsx covers visual states, and — in packages/ui only — gets an automated render-smoke pass via test-storybook. See Storybook for that runner.
npm run test -w @repo/ui # vitest run
npm run test:watch -w @repo/ui # vitest (watch)
npm run test:ui -w @repo/ui # vitest --ui
iOS — XCTest (apps/ios)
The iOS app uses XCTest (no Swift Testing). The appTests target is a real PBXNativeTarget (unit-test bundle com.closegram.appTests) wired into the app scheme, so the suite is buildable and runnable from Xcode. There is no UI-test target.
Tests mirror the app's Clean Architecture layering and are scoped to the Auth feature only — apps/ios/app/appTests/Tests/AuthTests/:
AuthTests/
├── Data/MockAuthRepository.swift # hand-rolled protocol mock (call-count/arg tracking, configurable success/failure/delay)
├── Domain/UseCases/ # LoginUseCaseTests, RegisterUseCaseTests
└── Presentation/
├── Store/ # AuthReducerTests (pure state transitions), AuthStoreTests (async + Combine publisher)
└── ViewModels/ # Login / Register / ForgotPassword / Password view-model tests
That's roughly 156 test methods covering login, register, phone OTP, social login, password reset/change, and logout across every layer (use-case validation, Redux-style reducer transitions, the async AuthStore and its Combine statePublisher, and view-model presentation logic). No other feature area (Feed, Profile, Messaging, …) has tests yet — this is a single-feature suite, not app-wide. The repo's own docs acknowledge this: apps/ios/README.md lists Repository coverage as "⏳ Pending" and the project-status table says "Testing: 0%". There are no integration tests against a real backend/GraphQL layer and no UI/snapshot tests.
How to run
# Xcode: open app/app.xcodeproj, select the `app` scheme, ⌘U
# Fastlane (matches CI) — from apps/ios
bundle install # first time
SKIP_GIT_CHECK=true bundle exec fastlane test # local/simulator run, HTML + JUnit output
bundle exec fastlane test_ci # CI lane (simulator, no device UUID needed)
bundle exec fastlane test_coverage # test + xcov report (70% min gate)
# Wrapper script (thin — calls fastlane test / test_coverage)
apps/ios/scripts/test.sh [--coverage]
# Raw xcodebuild
xcodebuild test \
-workspace apps/ios/app/app.xcodeproj/project.xcworkspace \
-scheme app \
-destination 'platform=iOS Simulator,name=iPhone 15 Pro'
The
testfastlane lane hardcodes a physical-device destination (id=00008120-000E644C1ED2201E). Without that exact iPhone, usetest_ciinstead — it omitsdestinationand lets Xcode pick a simulator.