Testing
The kinds of tests in Neatship, where they live, how to run all of them or a single file, how the API tests use their own database, and what CI checks on every pull request.
This guide shows which tests exist, where to put new ones, and how to run them, from the whole suite down to a single file. It ends with what CI checks on every pull request. If you build with an AI assistant, it writes and runs the tests itself: yarn verify runs the ones your change touches.
The house rule: test behavior
Test what a person sees or what an API caller gets, not how the code is wired inside. Find elements by their visible text or their role (button, dialog), interact like a person would with @testing-library/user-event, and check the result. A test that only checks "this internal function was called with these arguments" breaks on every refactor and proves little.
The kinds of tests
| Kind | Tool | Where | Command |
|---|---|---|---|
| Web app: pages, components, hooks, utils | Jest and Testing Library | __tests__/*.test.ts(x) next to the code, in packages/app-front/src/ | npx nx test app-front |
| Shared helpers | Jest | packages/app-shared/src/**/__tests__/*.test.ts | npx nx test app-shared |
| Landing page | Jest and Testing Library | packages/app-website/src/**/__tests__/ | npx nx test app-website |
| Server services and utils | Jest, with NestJS's testing module and mocked dependencies | packages/app-server/src/**/__tests__/*.spec.ts | npx nx test app-server |
| API integration | Jest against the real app and a real Postgres database | packages/app-server/test/integration/<area>/*.integration-spec.ts | npx nx run app-server:test:integration |
| Design system | Vitest (unit tests), and Storybook stories run in a real browser | next to each component in packages/app-ui/src/ | npx nx test app-ui, npx nx run app-ui:storybook:test |
| Custom lint rules | Vitest, with valid and invalid code samples | packages/app-lint-rules/rules/*.spec.ts | npx nx test app-lint-rules |
| Whole app, end to end | Playwright, clicking through the app in Chromium | packages/app-e2e-testing/tests/*.spec.ts | npx nx e2e app-e2e-testing |
Everything at once, except the integration and end-to-end tests:
npx nx run-many -t testThe end-to-end tests have their own command and their own servers: see End-to-end tests.
Run a single test file
The fastest loop while you work on one thing:
# Web app
npx jest packages/app-front/src/pages/projects/__tests__/ProjectsPage.test.tsx --config=packages/app-front/jest.config.mjs
# Server unit test
npx jest packages/app-server/src/modules/project/services/__tests__/project.service.spec.ts --config=packages/app-server/jest.config.mjs
# Shared helpers
npx jest packages/app-shared/src/utils/__tests__/capitalize.test.ts --config=packages/app-shared/jest.config.mjs
# Design system
npx vitest run --root packages/app-ui --project unit <file>
# Lint rules: anything after -- is passed to the test runner
npx nx run app-lint-rules:test -- rules/no-state-useref.spec.ts
# API integration tests of one area
npx nx run app-server:test:integration -- test/integration/projectTwo more options: npx nx test <package> --configuration=watch reruns the tests when you save a file, and, in the Jest packages (app-front, app-server, app-shared, app-website), --configuration=coverage writes a coverage report. app-shared enforces a minimum coverage of 80% of statements, 90% of lines and 75% of functions.
Web app tests
Page and component tests render the real components with renderWithProviders (packages/app-front/src/testing/jest/renderWithProviders.tsx). Each test gets a fresh screen state, a fresh data cache, the toasts, a router, and a fake API that answers the mocked GraphQL responses you pass.
renderWithProviders(<ProjectsPage />, {
apolloMocks: [getProjectsMock('ACTIVE', [WEBSITE_PROJECT])],
session: getMockSession({ role: 'member' }),
});
expect(await screen.findByText('Website redesign')).toBeVisible();- The session starts as an admin;
getMockSession({ role: 'member' })gives a member,nullno one. - Type each mocked response with the generated types (
MockLink.MockedResponse<GetProjectsQuery, GetProjectsQueryVariables>), so a mock can't drift from the API. - Mock data helpers live in
packages/app-front/src/testing/mock-data/. - Tests run in mock sign-in mode. To test a component that uses Clerk, mock Clerk with
jest.mock('@clerk/react-router').
packages/app-front/src/pages/projects/__tests__/ProjectsPage.test.tsx is the example to copy: list and filter, create with validation, a duplicate name shown on the field, delete hidden for a member, delete after confirming.
Server unit tests
A service test builds only that service, with its dependencies replaced by mocks:
const testingModule = await Test.createTestingModule({
providers: [
ProjectService,
{
provide: getWorkspaceScopedRepositoryToken(ProjectEntity),
useValue: projectRepository,
},
],
}).compile();They check the decisions the service makes: what happens when a name is taken, when a project is already archived, and so on. Fake timers are on for every server unit test, so tests never depend on the real clock.
API integration tests and their database
Integration tests send real GraphQL requests to the whole server, connected to a real Postgres database.
- They use their own database.
yarn setupcreates atestdatabase next todefault, on the same Postgres (port 5433). WithNODE_ENV=test, the server readspackages/app-server/.env.testonly, so the tests never touch your development data. - The database is rebuilt before each run.
npx nx run app-server:test:integrationbuilds the server, then drops the test database's tables, runs every migration and adds the sample data. Always run them through this Nx command: plainjestcould run an old build. - Requests sign in as seeded people.
makeGraphqlApiRequest(app, { query, userEmail })sends the request with the mock sign-in header. WithoutuserEmail, the request is Ada's (admin of Acme team);'anonymous'is signed out. Every request also checks that the server didn't answer with an error 500. - Jobs run inside the request (
MESSAGE_QUEUE_DRIVER=syncin.env.test), so their effects are visible when the response comes back. - Rate limits are off (
RATE_LIMITS_ENABLED=falsein.env.test), so a test can repeat an action freely. The rate limit suite turns them on withcreateTestApp({ configOverrides: { RATE_LIMITS_ENABLED: true } }); it counts in Redis database 1 (port 6380), never in your development data. - Stripe is replaced.
.env.testholds placeholder keys, not real ones. Tests replace the Stripe library's calls withjest.spyOnon the app's Stripe client, with fixtures inpackages/app-server/test/utils/billing/. - Webhooks are signed like the real ones.
makeStripeWebhookRequestandmakeClerkWebhookRequestsign their payloads with the test secrets of.env.test, so the real signature guards, the inbox and the jobs all run. - Test files run one at a time.
Permission tests follow a pattern worth copying (packages/app-server/test/integration/project/project-permissions.integration-spec.ts): for each protected action, a person from another workspace gets NOT_FOUND, a member without the permission gets FORBIDDEN, and a signed-out request gets UNAUTHENTICATED.
it.each(otherWorkspaceTestCases)(
'should answer NOT_FOUND when a user of another workspace tries to $title a project',
async ({ context: { userEmail, query, expectedCode } }) => {
const response = await makeGraphqlApiRequest(app, { query, userEmail });
expect(response.body.errors[0].extensions.code).toBe(expectedCode);
},
);End-to-end tests
End-to-end tests open the real app in a browser (Chromium, driven by Playwright) and click through it like a person would. They live in packages/app-e2e-testing/.
# All of them (same as yarn e2e)
npx nx e2e app-e2e-testing
# One file, or the tests whose name contains some words: anything after -- goes to Playwright
npx nx e2e app-e2e-testing -- tests/projects.spec.ts
npx nx e2e app-e2e-testing -- --grep "command menu"
# Watch the browser while the tests run
npx nx e2e app-e2e-testing -- --headed
# Open the report of the last run
npx nx run app-e2e-testing:e2e:reportThe first time on a new machine, install the browser: npx playwright install chromium.
What happens during a run:
- Everything they need starts with them. Only Postgres has to be running (
yarn setup). The command builds the server, starts it on port 3300 with a database of its own callede2e(created if missing, then emptied and filled with the sample data), builds the web app and serves it on port 5380. Both stop when the tests end. Youryarn startservers (3100, 5180) and thedefaultandtestdatabases are left alone, so you can keep working during a run. If a port is taken,E2E_API_PORT=3400orE2E_WEB_APP_PORT=5480in front of the command moves it. - Mock sign-in, billing off. The web app is built without a Clerk key, and the server signs every request in as Ada, admin of Acme team. Plan upgrades say "Soon". The settings in your
.envfiles don't apply. - Another person.
test.use({ extraHTTPHeaders: { 'x-mock-user-email': 'alan@example.com' } })makes the browser send the mock sign-in header, so the app runs as Alan (a member of Acme team) or Bob (admin of Globex, another workspace).tests/permissions.spec.tsis the example. - Page objects. A test lists the person's steps; how to find each button or field lives in
packages/app-e2e-testing/lib/pom/(the app shell, the command menu, the home page, the projects page and its side panel, the appearance and billing settings). Elements are found by their role and visible text. - Each test brings its own data. A test creates what it changes, with a unique name (
getUniqueProjectName), so no test depends on another one. - When a test fails, Playwright keeps a trace: a step-by-step recording with a snapshot of the page at each step. The report command above opens it.
What they cover today (9 tests, about 20 seconds once the servers are up):
- the app opens signed in as Ada, in Acme team;
- the command menu (Ctrl+K, or ⌘K on a Mac) filters its list and opens Projects; the
gthenpshortcut opens it too; - a project is created, still there after a reload, renamed, archived (it moves to Archived), restored, and deleted after confirming (gone after a reload);
- the interface switches to French and back to English, and the theme to dark and back to light, each kept after a reload;
- the billing page shows the three plans, with upgrades marked "Soon";
- a member (Alan) sees no delete button on a project; a person from another workspace (Bob) doesn't see Acme team's projects.
What the API answers in those cases (FORBIDDEN, NOT_FOUND) is already checked by the API integration tests, so the end-to-end tests stick to what a person sees.
What CI checks
CI (continuous integration: checks that run on GitHub for every change) is defined in .github/workflows/ci.yaml. It runs on every pull request and on every push to main.
| Job | What it does |
|---|---|
forbidden-words | Runs packages/app-utils/check-forbidden-words.sh |
affected | Installs with yarn --immutable --check-cache (the lockfile must be up to date), then runs lint, typecheck, test and build for the packages affected by the change. Then it checks that the translation catalogs (web app, server, website), packages/app-server/generated/schema.graphql, packages/app-front/src/generated/graphql.ts, the design tokens and the icon dictionary are up to date |
server-integration | Starts Postgres 18 and Redis 7, runs the migrations on the test database, fails if an entity changed without a migration, checks that the background worker starts, then runs the API integration tests |
e2e | Starts Postgres 18 and Redis 7, installs Chromium, then runs the end-to-end tests. In CI a failed test is retried twice, and its errors are printed in the job's log |
design-system-stories | Runs every story of the design system in Chromium: its play function and its accessibility check |
docker-images | Builds the five production images (server, web, website, edge, backup) to check they still build; nothing is published |
ci-status-check | Fails if any job above failed. Make this the one required check in your branch protection rules |
In CI, unit tests run with coverage and one worker at a time (--configuration=ci). A newer push to the same pull request cancels the older run; runs on main are never canceled. Every third-party GitHub Action is pinned to an exact commit, and the workflow only gets read access to the repository.
Before you push, the same checks run locally. yarn verify checks what your change touched; yarn verify:full runs all of them, like CI, plus the builds, the API integration tests, the end-to-end tests and the design system's story tests (see Working with AI assistants, "The one command: yarn verify"). One by one:
npx nx run-many -t lint typecheck test build
npx nx run app-server:test:integration
npx nx e2e app-e2e-testing
npx nx run app-ui:storybook:test
npx nx run app-server:database:migrate:check
npx nx run app-server:graphql:schema && npx nx run app-front:graphql:generate
yarn check:forbidden-wordsAfter the last line of generators, git status should show no change you didn't mean to commit.