# Write Tests

> 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.

- Skill: `jamiemason/write-tests` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add jamiemason/write-tests`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jamiemason/write-tests/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: jamiemason (https://skillmd.com/u/jamiemason)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/jamiemason/write-tests

---


# 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

1. Read 2-3 tests in the same `*_test.rs` and copy the pattern
2. Write failing test → `just test` → confirm RED
3. Ask before implementing
4. Implement minimal code → GREEN → `just format`

## Quick start

```rust
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`:

```rust
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

```rust
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](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

```bash
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

