Write Tests
Golden rules
- Use
TestBuilder — never construct Context manually.
- Assert with
expect(&ctx).to_have_instances(vec![...]) — never index ctx.instances.
- Async-first:
#[tokio::test] + .run().await is the canonical entry.
TDD
- Read 2-3 tests in the same
*_test.rs and copy the pattern
- Write failing test →
just test → confirm RED
- Ask before implementing
- Implement minimal code → GREEN →
just format
Quick start
use {
crate::{
instance::{FixableInstance::*, InstanceState, SuspectInstance::*, UnfixableInstance::*, ValidInstance::*},
test::{
builder::TestBuilder,
expect::{expect, ExpectedInstance},
},
},
serde_json::json,
};
#[tokio::test]
async fn pinned_version_replaces_anything_different() {
let ctx = TestBuilder::new()
.with_package(json!({
"name": "package-a",
"version": "1.0.0",
"devDependencies": {"foo": "workspace:*"}
}))
.with_version_group(json!({
"dependencies": ["foo"],
"pinVersion": "1.2.0"
}))
.run()
.await;
expect(&ctx).to_have_instances(vec![
ExpectedInstance {
state: InstanceState::valid(IsLocalAndValid),
dependency_name: "package-a",
id: "package-a in /version of package-a",
actual: "1.0.0",
expected: Some("1.0.0"),
overridden: None,
},
ExpectedInstance {
state: InstanceState::fixable(DiffersToPin),
dependency_name: "foo",
id: "foo in /devDependencies of package-a",
actual: "workspace:*",
expected: Some("1.2.0"),
overridden: None,
},
]);
}
Sub-module organisation
Group related scenarios under nested modules — see pinned_test.rs, catalog_defs_test.rs:
mod local {
use super::*;
#[tokio::test] async fn refuses_to_pin_local_version() { ... }
}
mod normal {
use super::*;
#[tokio::test] async fn an_already_pinned_version_is_valid() { ... }
}
mod registry_updates {
use super::*;
#[tokio::test] async fn def_marked_outdated_when_registry_has_newer_version() { ... }
}
File location
| Test type |
Location |
| Version-group behaviour (pin, ban, ranges …) |
src/version_group/<group>_test.rs |
| Catalog discovery wiring |
src/version_group/{catalog,bun_catalog}_test.rs |
| Fix mutations |
src/commands/fix_test.rs |
| Format pass |
src/visit_formatting/format_test.rs |
| Other unit tests |
Co-located: src/foo.rs ↔ src/foo_test.rs |
Builder methods
Source of truth: src/test/builder.rs.
| Method |
Purpose |
.with_package(json!({...})) |
Add one package.json |
.with_packages(vec![...]) |
Add many |
.with_version_group(json!({...})) |
Add one version group |
.with_version_groups(vec![...]) |
Add many |
.with_semver_group(json!({...})) |
Add a semver group |
.with_config(json!({...})) |
Base config (e.g. customTypes, dependencyGroups) |
.with_strict(bool) |
Strict mode (Suspect → error) |
.with_subcommand("update") |
Override subcommand (default: lint, or update if registry set) |
.with_pnpm_catalogs(yaml) |
Inject pnpm-workspace.yaml; implies pnpm PM |
.with_bun_catalogs(json!({...})) |
Synthetic Bun root with /catalog, /catalogs/{n}; implies Bun PM |
.with_bun_workspaces_catalogs(json!({...})) |
Same, nested under /workspaces/ |
.with_{pnpm,bun,npm,yarn,unknown}_package_manager() |
Force PM detection |
.with_registry_updates(json!({"react":[...]})) |
Mock npm registry; implies subcommand=update |
.with_update_target(UpdateTarget::Minor) |
Bound update target |
.run().await → Context |
Primary end-to-end (full pipeline through disk + discovery) |
.build() |
Sync, no visit — context-wiring tests |
.build_and_visit_packages() |
Sync + visit_packages — fix tests, older suites |
.build_and_visit_formatting() |
Sync + visit_formatting |
.build_with_registry_and_visit().await |
Sync wiring + async registry mock + visit |
ExpectedInstance fields
ExpectedInstance {
state: InstanceState::fixable(DiffersToPin), // valid / fixable / unfixable / suspect
dependency_name: "react", // = `internal_name` (alias-aware)
id: "react in /dependencies of package-a", // {dep} in {/path} of {package_or_yaml}
actual: "17.0.0", // raw specifier on disk
expected: Some("18.0.0"), // None = ignore; Some("") = remove
overridden: None, // semver-group override target, if any
}
id location examples:
/dependencies, /devDependencies, /peerDependencies
/version of package-a (local version)
/packageManager, /engines/node
/catalog of pnpm-workspace.yaml, /catalogs/<name> of pnpm-workspace.yaml
/customVersion, /custom/config/version (via customTypes)
Patterns
→ patterns.md: banned, pinned, sameRange, pnpm catalogs, bun catalogs, semver ranges, registry updates.
Fix tests
src/commands/fix_test.rs builds with .build_and_visit_packages() then runs fix::run(ctx, &SilentReporter, &disk). dry_run = true is the default (set by mock::config_from_mock), so is_dirty() and post-fix contents stay observable. Set ctx.config.cli.dry_run = false only when asserting writes through a recording MockDiskIo (see pnpm_fix_writes_yaml_to_disk).
Common mistakes
| Wrong |
Right |
#[test] fn foo() + .run().await |
#[tokio::test] async fn foo() |
use crate::instance_state::* |
use crate::instance::* |
"pinned": "1.0.0" |
"pinVersion": "1.0.0" |
Context { ... } |
TestBuilder::new()... |
ctx.instances[0] |
expect(&ctx).to_have_instances(vec![...]) |
.build() then check states |
.run().await (or .build_and_visit_packages() for sync) |
Missing SuspectInstance::* import |
Import all 4: FixableInstance::*, ValidInstance::*, SuspectInstance::*, UnfixableInstance::* |
Running
just test # all
cargo test pinned_test # pattern match
cargo test test_name -- --nocapture # with stdout
Reference tests
src/version_group/banned_test.rs — banned + custom types
src/version_group/pinned_test.rs — sub-modules, semver-group interaction
src/version_group/same_range_test.rs — range satisfaction
src/version_group/catalog_test.rs — pnpm catalogs
src/version_group/bun_catalog_test.rs — bun catalogs (sync .build())
src/version_group/preferred_semver_test.rs — registry updates, update targets
1---2name: write-tests3description: Write tests for Syncpack using the TestBuilder pattern. Use when adding tests for commands, validation logic, or any new functionality. Covers TestBuilder API, assertion patterns, and common test scenarios.4---56# Write Tests78## Golden rules910- Use `TestBuilder` — never construct `Context` manually.11- Assert with `expect(&ctx).to_have_instances(vec![...])` — never index `ctx.instances`.12- Async-first: `#[tokio::test]` + `.run().await` is the canonical entry.1314## TDD15161. Read 2-3 tests in the same `*_test.rs` and copy the pattern172. Write failing test → `just test` → confirm RED183. Ask before implementing194. Implement minimal code → GREEN → `just format`2021## Quick start2223```rust24use {25 crate::{26 instance::{FixableInstance::*, InstanceState, SuspectInstance::*, UnfixableInstance::*, ValidInstance::*},27 test::{28 builder::TestBuilder,29 expect::{expect, ExpectedInstance},30 },31 },32 serde_json::json,33};3435#[tokio::test]36async fn pinned_version_replaces_anything_different() {37 let ctx = TestBuilder::new()38 .with_package(json!({39 "name": "package-a",40 "version": "1.0.0",41 "devDependencies": {"foo": "workspace:*"}42 }))43 .with_version_group(json!({44 "dependencies": ["foo"],45 "pinVersion": "1.2.0"46 }))47 .run()48 .await;49 expect(&ctx).to_have_instances(vec![50 ExpectedInstance {51 state: InstanceState::valid(IsLocalAndValid),52 dependency_name: "package-a",53 id: "package-a in /version of package-a",54 actual: "1.0.0",55 expected: Some("1.0.0"),56 overridden: None,57 },58 ExpectedInstance {59 state: InstanceState::fixable(DiffersToPin),60 dependency_name: "foo",61 id: "foo in /devDependencies of package-a",62 actual: "workspace:*",63 expected: Some("1.2.0"),64 overridden: None,65 },66 ]);67}68```6970## Sub-module organisation7172Group related scenarios under nested modules — see `pinned_test.rs`, `catalog_defs_test.rs`:7374```rust75mod local {76 use super::*;77 #[tokio::test] async fn refuses_to_pin_local_version() { ... }78}7980mod normal {81 use super::*;82 #[tokio::test] async fn an_already_pinned_version_is_valid() { ... }83}8485mod registry_updates {86 use super::*;87 #[tokio::test] async fn def_marked_outdated_when_registry_has_newer_version() { ... }88}89```9091## File location9293| Test type | Location |94| -------------------------------------------- | ------------------------------------------- |95| Version-group behaviour (pin, ban, ranges …) | `src/version_group/<group>_test.rs` |96| Catalog discovery wiring | `src/version_group/{catalog,bun_catalog}_test.rs` |97| Fix mutations | `src/commands/fix_test.rs` |98| Format pass | `src/visit_formatting/format_test.rs` |99| Other unit tests | Co-located: `src/foo.rs` ↔ `src/foo_test.rs` |100101## Builder methods102103Source of truth: `src/test/builder.rs`.104105| Method | Purpose |106| ------------------------------------------------------- | -------------------------------------------------------------------- |107| `.with_package(json!({...}))` | Add one package.json |108| `.with_packages(vec![...])` | Add many |109| `.with_version_group(json!({...}))` | Add one version group |110| `.with_version_groups(vec![...])` | Add many |111| `.with_semver_group(json!({...}))` | Add a semver group |112| `.with_config(json!({...}))` | Base config (e.g. `customTypes`, `dependencyGroups`) |113| `.with_strict(bool)` | Strict mode (Suspect → error) |114| `.with_subcommand("update")` | Override subcommand (default: `lint`, or `update` if registry set) |115| `.with_pnpm_catalogs(yaml)` | Inject `pnpm-workspace.yaml`; implies pnpm PM |116| `.with_bun_catalogs(json!({...}))` | Synthetic Bun root with `/catalog`, `/catalogs/{n}`; implies Bun PM |117| `.with_bun_workspaces_catalogs(json!({...}))` | Same, nested under `/workspaces/` |118| `.with_{pnpm,bun,npm,yarn,unknown}_package_manager()` | Force PM detection |119| `.with_registry_updates(json!({"react":[...]}))` | Mock npm registry; implies subcommand=`update` |120| `.with_update_target(UpdateTarget::Minor)` | Bound update target |121| `.run().await` → `Context` | **Primary** end-to-end (full pipeline through disk + discovery) |122| `.build()` | Sync, no visit — context-wiring tests |123| `.build_and_visit_packages()` | Sync + visit_packages — fix tests, older suites |124| `.build_and_visit_formatting()` | Sync + visit_formatting |125| `.build_with_registry_and_visit().await` | Sync wiring + async registry mock + visit |126127## ExpectedInstance fields128129```rust130ExpectedInstance {131 state: InstanceState::fixable(DiffersToPin), // valid / fixable / unfixable / suspect132 dependency_name: "react", // = `internal_name` (alias-aware)133 id: "react in /dependencies of package-a", // {dep} in {/path} of {package_or_yaml}134 actual: "17.0.0", // raw specifier on disk135 expected: Some("18.0.0"), // None = ignore; Some("") = remove136 overridden: None, // semver-group override target, if any137}138```139140`id` location examples:141142- `/dependencies`, `/devDependencies`, `/peerDependencies`143- `/version of package-a` (local version)144- `/packageManager`, `/engines/node`145- `/catalog of pnpm-workspace.yaml`, `/catalogs/<name> of pnpm-workspace.yaml`146- `/customVersion`, `/custom/config/version` (via `customTypes`)147148## Patterns149150→ [patterns.md](patterns.md): banned, pinned, sameRange, pnpm catalogs, bun catalogs, semver ranges, registry updates.151152## Fix tests153154`src/commands/fix_test.rs` builds with `.build_and_visit_packages()` then runs `fix::run(ctx, &SilentReporter, &disk)`. `dry_run = true` is the default (set by `mock::config_from_mock`), so `is_dirty()` and post-fix contents stay observable. Set `ctx.config.cli.dry_run = false` only when asserting writes through a recording `MockDiskIo` (see `pnpm_fix_writes_yaml_to_disk`).155156## Common mistakes157158| Wrong | Right |159| ------------------------------------------------ | -------------------------------------------------------------------------------------- |160| `#[test] fn foo()` + `.run().await` | `#[tokio::test] async fn foo()` |161| `use crate::instance_state::*` | `use crate::instance::*` |162| `"pinned": "1.0.0"` | `"pinVersion": "1.0.0"` |163| `Context { ... }` | `TestBuilder::new()...` |164| `ctx.instances[0]` | `expect(&ctx).to_have_instances(vec![...])` |165| `.build()` then check states | `.run().await` (or `.build_and_visit_packages()` for sync) |166| Missing `SuspectInstance::*` import | Import all 4: `FixableInstance::*, ValidInstance::*, SuspectInstance::*, UnfixableInstance::*` |167168## Running169170```bash171just test # all172cargo test pinned_test # pattern match173cargo test test_name -- --nocapture # with stdout174```175176## Reference tests177178- `src/version_group/banned_test.rs` — banned + custom types179- `src/version_group/pinned_test.rs` — sub-modules, semver-group interaction180- `src/version_group/same_range_test.rs` — range satisfaction181- `src/version_group/catalog_test.rs` — pnpm catalogs182- `src/version_group/bun_catalog_test.rs` — bun catalogs (sync `.build()`)183- `src/version_group/preferred_semver_test.rs` — registry updates, update targets