Repository navigation
Testing
Job Matcher has three layers of tests. Unit tests sit next to the modules they cover and run with bun test. API integration tests in apps/api/test/ boot the real Nest application against PostgreSQL and Redis and drive it over HTTP with supertest. Playwright end-to-end tests run the API, the worker and the web app on fake providers in their own database and click through the product in Chromium. Every test runs without network access to Firecrawl, OpenRouter, Discord or the ATS APIs. This page explains how each layer is set up, how the databases are provisioned, how to run one test, and the conventions for new tests. For how CI runs them, see CI/CD and Releases.
| Layer | Location | Runner | Needs | Command |
|---|---|---|---|---|
| API unit |
apps/api/src/**/*.test.ts (39 files) |
bun test |
The generated Prisma client types (bun install generates them); no database |
bun run test |
| API integration |
apps/api/test/*.test.ts (16 files) |
bun test |
Postgres and Redis from .env, migrations applied |
bun run test |
| Web unit |
apps/web/lib/*.test.ts (3 files) |
bun test |
Nothing | bun run test |
| End-to-end |
e2e/*.spec.ts (3 files) |
Playwright, Chromium | Postgres and Redis, ports 3000 and 4000 free for the API | bun run test:e2e |
bun run test at the root runs bun --filter '*' test, which runs the test script in apps/api and apps/web (packages/shared has none). bun run check runs lint, typecheck, test and test:e2e in that order.
The API's test script is bun --env-file=../../.env test --timeout 30000: it loads the root .env and allows 30 seconds per test. apps/api/bunfig.toml sets:
| Setting | Value | Effect |
|---|---|---|
preload |
./test/setup.ts |
Runs before any test file |
coverage |
true |
Coverage is always collected |
coverageThreshold |
{ lines = 0.9, functions = 0.75 } |
Applied per loaded file: every source module a test loads must reach 90% of lines and 75% of functions, or the run fails |
coveragePathIgnorePatterns |
src/generated/**, test/**
|
The Prisma client and the test helpers are not measured |
The function threshold is 75% because Bun counts a function on each Data.TaggedError line in src/matching/errors.ts that no test can call; the comment in bunfig.toml notes every other file is at 80% or more. src/matching/modules.test.ts imports every matching module, so a module without its own test still counts against the threshold.
apps/api/test/setup.ts makes it impossible for a test to reach a real provider:
- Deletes
FIRECRAWL_API_KEY,OPENROUTER_API_KEYandFIRECRAWL_MODEfrom the environment, so no live layer can be built (this also covers the Firecrawl SDK, which uses axios rather thanfetch). - Sets
PROVIDERS=fake. - Replaces
globalThis.fetchwith a wrapper that rejects any request tofirecrawl.dev,openrouter.ai,discord.com,discordapp.com,greenhouse.io,lever.co,ashbyhq.com,smartrecruiters.comorrecruitee.com(and their subdomains) with "Tests must not call ".
The API tests do not create a separate database. They use DATABASE_URL and REDIS_URL from the root .env, which in development is the same job_matcher database the dev server uses. Isolation comes from the data, not the database:
- Each integration test signs up fresh users with random emails (
test-<uuid>@example.com) throughsignUp(), or creates rows withtest-<uuid>ids. -
afterAllhooks delete the rows the file created (deleteMany({ where: { userId } })) and remove queued BullMQ jobs. -
onboarding-backfill.test.tsruns the backfill SQL from a migration inside a transaction that is rolled back, so the shared database is left as it was.
The schema must be migrated before the tests run: bun run db:migrate in development, bun x --bun prisma migrate deploy in CI. Redis must be reachable because the API's health check, the queue and the schedulers use it.
| File | Exports | Use |
|---|---|---|
| helpers.ts |
startApp(), signUp(server)
|
startApp() calls the same createApp() as production with the logger off and initialises it. signUp() creates an account through POST /api/auth/sign-up/email and returns a supertest agent that keeps the session cookie. |
| ats.ts |
fixture(name), fakeHttp(replies) and related |
Serves scripted replies per URL (a body string or { status, body }) through the HttpFetch service; any other URL fails like an offline network. Used by the ATS adapter tests. |
| pages.ts | pageFixture(name) |
Careers page markdown as Firecrawl returns it. |
apps/api/test/fixtures/ holds the data both the tests and demo mode use. The fake providers in providers/fake.ts read listing-page.md, listings.json, resume.md and jobs/*.md at runtime, so these files ship in the API image.
| Folder or file | Contents |
|---|---|
ats/ |
Recorded Greenhouse, Lever, Ashby, Recruitee and SmartRecruiters API responses, and careers pages that embed an Ashby or Greenhouse board |
jobs/ |
Three job pages (backend-payments.md, full-stack.md, senior-platform.md), each with a Verdict: line the fake evaluator reads |
pages/ |
Careers pages in markdown and HTML, including one with no links and one with only apply links |
scrapes/ |
Scrape outcomes: a job page, a Cloudflare challenge, 403, 404 and a thin shell page |
listing-page.md, listings.json
|
The fake careers page and its extracted listings |
resume.md, job-ad.pdf
|
The fixture resume and a PDF job ad |
| File | Covers |
|---|---|
cache.test.ts |
DELETE /api/cache: auth, clears only the signed-in user's pages |
fake-providers.test.ts |
The fake pipeline end to end, scripted Firecrawl 429, search sources, local search rules, reported credits, blocked real hosts, fake Discord URL check |
matches.test.ts |
Listing, status filter, cursor paging, status updates, ownership |
onboarding.test.ts |
Step derivation, completion on first manual or scheduled run, complete, dismiss and restore, concurrency |
onboarding-backfill.test.ts |
The onboarding migration's backfill SQL |
overview.test.ts |
GET /api/overview: empty state, per-user counts, next scheduled tick |
page-cache.test.ts |
The PageCache store: misses, overwrites, per-user isolation |
process-run.test.ts |
The run pipeline: dedupe and filters, run size, context reuse and retries, OpenRouter 401, preflight and local Firecrawl stops, structured data, title filters, triage on and off, the credit guard (reserve, daily allowance shared across scheduled runs), search source skips, redelivery and recovery |
recover-run.test.ts |
Ending runs whose jobs were lost, stalled or failed |
runs-processor.test.ts |
Worker startup recovery and schedule restore, run jobs, schedule ticks |
runs.test.ts |
POST /api/runs validation, one active run per user, simultaneous starts, lost-job recovery, Redis outage; run listing and details; retrying a failed match save and its Discord alert; /api/health and /api/health/worker
|
schedule.test.ts |
enqueueRun(): queue failure marks the run FAILED; job id equals run id |
scheduled-run.test.ts |
Scheduled run creation rules |
scrape-routes.test.ts |
The ScrapeRoute store: block counting, cloud preference, reset on success |
settings.test.ts |
Defaults, saving, trimming, validation errors, scheduler sync |
sources.test.ts |
Add, list, delete, invalid and duplicate URLs, kind detection, kind override clearing the cache, search sources, ownership |
Unit tests in src/ cover each matching module and each source adapter, plus onboarding/onboarding.test.ts and overview/overview.service.test.ts.
apps/web runs bun test with no extra configuration and no coverage threshold. The three test files cover pure logic in lib/:
| File | Covers |
|---|---|
safe-next.test.ts |
The ?next= redirect validation (open-redirect cases, encoded slashes, auth pages) |
serial.test.ts |
The one-at-a-time task queue used by settings saves |
welcome-steps.test.ts |
Wizard step order, reachability and resume logic |
Components and pages are covered by the Playwright suite rather than component tests.
| Setting | Value |
|---|---|
testDir |
e2e |
timeout |
60 s per test |
retries |
1 in CI, 0 locally |
reporter |
list; in CI also html (never opened) |
use |
baseURL: http://localhost:3000, trace: retain-on-failure
|
| Projects |
chromium (Desktop Chrome) |
The config loads .env when DATABASE_URL is not already set, then appends _e2e to the database name, for example job_matcher becomes job_matcher_e2e. The first web server command runs prisma migrate deploy against that URL, which creates the database if needed. The API and worker receive PROVIDERS=fake and the _e2e DATABASE_URL, so the suite never calls a provider and never writes into development data. They share the development Redis.
| Server | Command | Ready when | Reuse |
|---|---|---|---|
| API and worker | bun x --bun prisma migrate deploy && bun run --cwd apps/api --parallel start start:worker |
http://localhost:4000/api/health/worker answers (a worker is connected, so a started run is picked up at once) |
Never: a running dev API would be on live providers. Stop bun run dev first, or port 4000 is taken. |
| Web | Locally bun run --cwd apps/web dev; in CI bun run --cwd apps/web build && bun run --cwd apps/web start
|
http://localhost:3000 answers (timeout 180 s) |
Locally a running web server on port 3000 is reused; in CI never |
The ports are fixed at 3000 and 4000. To run the suite while a development server holds those ports, run it from a separate checkout after stopping the dev API, or change the ports in a local copy of the config.
e2e/support.ts exports the values and steps the specs share:
| Export | What it does |
|---|---|
password, sourceUrl (https://board.example.com/jobs), matchTitle (Full Stack Developer) |
Shared constants. With fake providers this source lists the fixture jobs, and with no experience set only the listing without an experience range passes the filters. |
signUp(page) |
Signs up with a random email and checks the wizard opens on "How it works" |
signIn(page, email), signOut(page)
|
Account menu flows |
addResume(page) |
From the wizard's welcome, saves https://example.com/cv.pdf and checks it moves to "Matching basics" |
completeSetup(page) |
Walks the whole wizard: resume, Use defaults, adds the source, Start first run, and checks the run page opens with ?first=1 and the "What happens during a run" region |
| Spec | Tests |
|---|---|
| happy-path.spec.ts | Sign up, complete the wizard, see the run complete with a match, open See your matches, Mark applied, then find it under Applied with Move to pending |
| navigation.spec.ts | A signed-in visitor skips the landing and auth pages; a signed-out visitor returns to the requested page after sign-in; unsafe next values fall back to /home; sign-out protects the app again; a returning user who finished setup lands on /home, never the wizard; the phone navigation menu; a stale session cookie is cleared instead of looping; switching accounts in another tab resets open pages |
| onboarding.spec.ts | Leaving the wizard keeps progress and Continue setup resumes at the right step; the setup checklist can be hidden (persisted across reloads) and Setup guide brings it back |
When a test fails, Playwright keeps its trace in test-results/. CI uploads playwright-report/ and test-results/ as the playwright artifact.
# One API test file (from the repository root)
bun run --cwd apps/api test test/runs.test.ts
# Tests whose name matches a pattern
bun run --cwd apps/api test -t "stops on an OpenRouter 401"
# One web test file
bun run --cwd apps/web test lib/safe-next.test.ts
# One Playwright spec, or one test by title
bun run test:e2e e2e/happy-path.spec.ts
bun run test:e2e -g "the checklist can be hidden"
# Watch the browser
bun run test:e2e --headedRunning a single API file still applies the coverage thresholds to every source file that file loads. A file that loads modules it only partly exercises can fail on coverage even when its tests pass; run the whole suite to judge coverage.
| Convention | Detail |
|---|---|
| Placement | Unit tests next to the module as <module>.test.ts; tests that need the Nest app, Postgres or Redis in apps/api/test/; browser flows in e2e/
|
| Imports |
import { describe, expect, it } from "bun:test" (the API tests use describe/it) |
| Names | Plain sentences describing behaviour, for example it("rejects a second run while one is active")
|
| Users and data | Create fresh users with signUp() or random ids; never depend on existing rows; delete what you created in afterAll
|
| Providers | Never call real services. Use the Effect service fakes (HttpFetch.of, Firecrawl, MatchEvaluator.of, ...) or the fixture-backed layer in providers/fake.ts. Script failures with ?fake-error=<status> or ?fake-blocked URLs, or a job page's Verdict: line (match, auth, invalid). |
| Fixtures | Add files under apps/api/test/fixtures/. Recorded ATS responses go in ats/. Keep anything the demo mode reads backwards compatible. |
| Coverage | A new source file must reach 90% of lines and 75% of functions in whatever test loads it. A new matching module is loaded by modules.test.ts, so it needs tests from the start. |
| Every endpoint | Include a "rejects requests without a session" test and an ownership test ("hides another user's ..."), as the existing files do |
| e2e | Use roles and labels (getByRole, getByLabel), not CSS selectors. Next.js keeps earlier pages in the DOM hidden, so filter with { visible: true } when the same text can appear on a previous page. Reuse the steps in support.ts. |
Job Matcher · Website · Report an issue · MIT license
Using Job Matcher
System design
Matching engine
Interface and brand
Engineering