Skip to main content

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.

PackageStackRunnerWhat it covers
apps/backendJest (multi-project)npm testGraphQL resolvers, managers, access-services, validators — unit (mocked) + integration (real Postgres)
apps/frontend-nextjsVitest + React Testing Library, Playwrightnpm test, npm run test:e2eComponent unit tests + end-to-end browser flows
apps/frontend-adminVitest + React Testing Library, Playwrightnpm test, npm run test:e2eAdmin UI-kit component tests + end-to-end flows
packages/uiVitest + React Testing Librarynpm run test -w @repo/uiShared design-system primitives (behavior). Storybook render-smoke via test-storybook — see Storybook
apps/iosXCTestbundle exec fastlane testAuth 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:

CommandWhat it does
npm run testturbo 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:backendBackend only — npm run test:report -w backend (the CI-style Jest run, larger heap + --workerIdleMemoryLimit=512MB)
npm run test:allThe 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:logturbo 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).

ProjecttestMatchSetup fileTimeoutTouches a DB?
unittests/unit-test/**/*.test.jstests/setup.js10 000 msNo — everything is mocked
integrationtests/integration/**/*.test.jstests/integration/setup.integration.js30 000 msYes — 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.jstests/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:

ServiceContainerImageDefault DBDefault port
postgres-testclosegram-postgres-testpostgres:15-alpineclosegram_test5433
redis-testclosegram-redis-test6380

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

CommandWhat it does
npm testFull lifecycle (docker up → migrate → all Jest projects → teardown)
npm run test:unitSame lifecycle, --selectProjects unit
npm run test:integrationSame lifecycle, --selectProjects integration --forceExit
npm run test:unit (iterate)Fast, DB-free — use this for most iteration
npm run test:watchjest --watch (no lifecycle wrapper — DB must already be up)
npm run test:integration:watchjest --selectProjects integration --watch (no lifecycle wrapper)
npm run test:coverage / :unit / :integrationAs above with --coverage; does not spin up Docker/migrate itself — start the DB first for the integration portion
npm run test:debugnode --inspect-brk … jest --runInBand
npm run test:reportUnit 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/ (excludes node_modules, coverage, tests). The model layer and schema are excluded — they're mostly declarative.
  • Output: apps/backend/coverage/. Reporters: text (terminal), lcov, and html (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 in jest.config.js), not a real target — meant to be ratcheted up once real numbers are measured via npm run test:coverage.
  • verbose: true is set globally. An optional jest-html-reporters report (./test-report/index.html, "Closegram Backend Tests") is wired up only if that package is installed — the config probes with require.resolve and silently skips it otherwise.

There's also a static drift check unrelated to Jest: npm run check:schema compares 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-nextjsfrontend-admin
Path alias@src, plus @/lib/firebasesrc/test/mocks/firebase.ts@src
Extra jsdom polyfills (vitest.setup.ts)window.matchMedia, Element.prototype.animate, Element.prototype.scrollIntoViewwindow.matchMedia, ResizeObserver
Test-utils wrapperrenderWithProviders (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-nextjsfrontend-admin
baseURL / dev porthttp://localhost:3000http://localhost:3100
Reportergithub (CI) / html (local)list
Specssmoke, auth, authenticated, public-pagessmoke, 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:

CommandWhat it does
npm run test -w frontend-nextjsVitest single run (CI-style)
npm run test:watch -w frontend-nextjsVitest watch mode
npm run test:ui -w frontend-nextjsVitest UI
npm run test:e2e -w frontend-nextjsPlaywright (auto-boots next dev --port 3000)
npm run test:e2e:ui -w frontend-nextjsPlaywright 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 onlyapps/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 test fastlane lane hardcodes a physical-device destination (id=00008120-000E644C1ED2201E). Without that exact iPhone, use test_ci instead — it omits destination and lets Xcode pick a simulator.