Testing in this repo
UI tests: get the assertion strings right first
If the test drives a UI (packages/pds/tests/{oauth,account-manager}.test.ts, or anything using the puppeteer PageHelper), the assertion strings are the whole problem — they are French and branch-dependent, so they can't be guessed from the JSX. Read the playwright skill before asserting on any of them: the fastest source is the fr message catalog, with a browser walkthrough as the fallback when the rendered branch is unclear.
Full workflow, PageHelper API, and dev-env boot instructions: playwright skill. Come back here for runner and tsconfig questions.
Choosing a runner
Vitest is the standard and now covers the majority of the repo (25 packages, ~270 test files) — including 165 files), among them bsky, all of packages/lex/*, packages/oauth/*, and the tested packages/internal/*. Jest remains in 13 unmigrated packages (pds, ozone, api, repo, and xrpc-server. Jest is deprecated: don't introduce it anywhere new, and prefer migrating a file to vitest over deepening its jest usage.
Decide from the package's own config files, not from a memorized list — they are the only drift-proof signal:
- Package has a
vite.config.*→ Vitest, configured inside that Vite config (never a separatevitest.config.ts). See Vite-based packages. - Package has a
vitest.config.ts→ Vitest. See references/vitest.md. - Package has a
jest.config.cjs→ Jest. See references/jest.md. - None of the above → the package has no test setup. Adopt vitest: create
vitest.config.tsandtsconfig.test.jsonfirst, following the setup section, then write the test.
If the test imports @atproto/lex or any @atproto/lex-* package, also read the lex-schema skill — schemas expose $parse / $safeParse / $matches, which make better assertions than hand-rolled shape checks.
Where tests go
- Unit tests sit next to their subject:
foo.ts+foo.test.ts. This is the default whenever the test needs no infra and no cross-package setup. - Integration / end-to-end tests go in the package's top-level
./testsfolder (packages/{pds,bsky,ozone}/tests,packages/lex/lex/tests, …). Use it for tests that boot real services, hit a database, or exercise several modules together. - Shared helpers go in
./tests/_util.ts(or a./tests/_util/directory). The leading underscore keeps them out of glob-based test discovery — see packages/bsky/tests/_util.ts and packages/ws-client/tests/_util/.
Jest packages predate the colocation convention and keep everything in ./tests — packages/api/src/age-assurance.test.ts is the lone exception. Don't relocate existing files just to match the convention.
Integration tests use the dev-env fixture
Almost every integration test in pds, bsky, ozone, and lexicon-resolver boots a real service constellation through @atproto/dev-env rather than mocking. Reach for it instead of hand-wiring servers:
TestNetwork.create({ dbPostgresSchema })— PDS + AppView + bsync + ozone + PLC. Needs postgres and redis. The schema must be unique per test file: it's the isolation boundary (the AppView and ozone databases are derived from it), so parallel files sharing one corrupt each other's data.TestNetworkNoAppView.create({ pds, plc })— PDS + PLC only, on a temp-dir store and a mock PLC database. Lighter; use it when the test doesn't touch the AppView. It forwards only thepdsandplcsub-options, so passing a top-leveldbPostgresSchemahere does nothing.network.getSeedClient()plus a seed exported from@atproto/dev-env(basicSeed,usersSeed,quotesSeed, …) populates accounts and records.await network.processAll()flushes the firehose so the AppView has caught up before you assert.
Tear down with afterAll(async () => network?.close()) — the prevailing pattern, and the only option for TestNetworkNoAppView, which exposes close() but no Symbol.asyncDispose. Only TestNetwork implements it.
A test that resolves identities (handles, did:plc, did:web) against localhost must construct its resolver with fetch: globalThis.fetch — @atproto/identity's default fetch is SSRF-protected and refuses localhost/private-IP targets silently.
Snapshot assertions go through each package's forSnapshot() helper in tests/_util.ts, which swaps DIDs, CIDs, and timestamps for stable placeholders so snapshots don't churn on every run. Refresh them with pnpm test:updateSnapshot (defined in bsky, pds, ozone, bsync).
TypeScript config for tests
Tested packages split their TS config, each part referenced from the package's tsconfig.json:
{
"include": [],
"references": [
{ "path": "./tsconfig.config.json" },
{ "path": "./tsconfig.build.json" },
{ "path": "./tsconfig.test.json" }
]
}
tsconfig.build.json—./src, excludes**/*.test.ts, emits to./dist.tsconfig.test.json— test code, extendingtsconfig/vitest.tsconfig.jsonortsconfig/jest.tsconfig.json. Both setnoEmitandcomposite: false; the jest one additionally pulls in@types/jest.includeis["./tests", "./src/**/*.test.ts"]in nearly every package, regardless of runner.tsconfig.config.json— root-level config/script files (vitest.config.ts,jest.config.cjs, …) that neither of the above covers, extending tsconfig/config.tsconfig.json withinclude: ["./*.ts", "./*.js", "./*.cjs", "./*.mjs"].excludeany root file that imports./srcor./tests(e.g. ajest.setup.ts), or it drags the whole tree into this stricter program. Present whenever the package has such a file — which is nearly all of them.
The build is a TS project graph, so references is what makes imports resolve. ./tsconfig.build.json alone covers anything the package already depends on at build time — which is most workspace imports. Add a further entry only for a package the tests import but src doesn't: in practice that means { "path": "../dev-env/tsconfig.build.json" }, and only in the six packages with integration tests (api, bsky, lexicon-resolver, ozone, pds, sync).
Create tsconfig.test.json before writing the first test in a package that lacks one — packages/did has jest tests without one, so don't take its layout as the pattern. Exact contents per runner: vitest / jest.
Running tests
Run from inside the package directory:
pnpm test # full package suite
pnpm test path/to/file.test.ts # single file
Packages whose tests need docker infra wrap the runner in a dev-infra script — bsky, pds, ozone, and sync use with-test-redis-and-db.sh; bsync uses with-test-db.sh. Always go through pnpm test; invoking vitest or jest directly skips the script, so postgres and redis aren't running and the suite dies on connection errors. pds also offers pnpm test:sqlite for a faster loop that skips the docker infra.
From the repo root, pnpm test runs every package's suite with infra up, and pnpm test:unit runs only the projects registered in vitest.config.ts. That list is not the full set of vitest packages: bsky is commented out because it needs infra, and lexicon-resolver is simply absent. Run either from its own directory.
Code style
- Always use
usingfor spies, mocks and any object that implementsDisposableorAsyncDisposable.