Skip to content

feat(vitest): recognize __mocks__ specifiers as virtual#265

Merged
BartWaardenburg merged 1 commit intofallow-rs:mainfrom
fmguerreiro:upstream/vitest-mocks
May 3, 2026
Merged

feat(vitest): recognize __mocks__ specifiers as virtual#265
BartWaardenburg merged 1 commit intofallow-rs:mainfrom
fmguerreiro:upstream/vitest-mocks

Conversation

@fmguerreiro
Copy link
Copy Markdown
Contributor

Problem

Vitest's manual-mock convention places mock factories at <package>/__mocks__/<module>.ts and triggers them via vi.mock('<module>'). Some test setups also import directly from @<scope>/__mocks__ paths via package.json#imports aliases or workspace virtual paths:

```ts
import { mockS3Send } from '@aws-sdk/mocks';
```

Fallow flags these as unlisted-dep, and the auto-fix suggests "install @aws-sdk/__mocks__" — a package that doesn't exist on npm. Same for @sentry/__mocks__, @supabase/__mocks__, @mapbox/__mocks__, etc.

Fix

Adds a sibling abstraction to the existing virtual_module_prefixes (prefix-based matching used by Nuxt, Docusaurus, etc.):

Existing New
Trait method virtual_module_prefixes() virtual_package_suffixes()
Default &[] &[]
Match shape starts_with on package name ends_with on package name
Example user Nuxt: "#" Vitest: "/__mocks__"

VitestPlugin::virtual_package_suffixes returns &["/__mocks__"]. The find_unlisted_dependencies filter skips any package_name ending with a registered suffix.

Workspace aggregation

Critical detail: the suffix list must be merged from per-workspace plugin runs into the root AggregatedPluginResult. When Vitest is in a workspace's package.json (not the root) — common in monorepos — the suffix gets registered locally and would be dropped before the analyzer reads it. run_plugins in lib.rs now merges virtual_package_suffixes alongside virtual_module_prefixes and generated_import_patterns via a small extend_unique helper that consolidates the three identical dedup loops.

Tests

  • 2 unit tests on VitestPlugin::virtual_package_suffixes: scoped/unscoped match plus 6-case adversarial negative (__mocks__-helper, my__mocks__pkg, @scope/__mocks__-utils etc.).
  • 2 unit tests on find_unlisted_dependencies: @aws-sdk/__mocks__ and some-pkg/__mocks__ fully suppressed.
  • Registry test asserting Vitest contributes the suffix when active.
  • 2 integration tests with fixtures:
    • tests/fixtures/vitest-mocks-virtual/: single-package, Vitest at root.
    • tests/fixtures/vitest-mocks-workspace/: monorepo, Vitest only in apps/mrv/package.json. Reproduces the workspace-aggregation case the unit tests alone cannot catch.

API surface

Plugin::virtual_package_suffixes(&self) -> &'static [&'static str] with default &[]. Backwards-compatible: existing plugins inherit the default. New AggregatedPluginResult.virtual_package_suffixes: Vec<String> field flows through the existing aggregation path.

Verified on

cargo test --workspace (1909 lib + 299 integration tests passing), cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all -- --check. Real-world: this fork-pinned binary running in a Turborepo monorepo CI cleared all 5 @*/__mocks__ false positives.

Vitest's manual-mock convention places mock factories at
`<package>/__mocks__/<module>.ts` and triggers them via
`vi.mock('<module>')`. Some test setups also import directly from
`@<scope>/__mocks__` paths via package.json `imports` aliases or
workspace virtual paths:

    import { mockS3Send } from '@aws-sdk/__mocks__';

Fallow flags these as `unlisted-dep`, and the auto-fix suggests
'install this package' — but `@aws-sdk/__mocks__`,
`@sentry/__mocks__`, `@supabase/__mocks__` etc. do not exist on
npm.

Adds a sibling abstraction to the existing `virtual_module_prefixes`
(prefix-based matching used by Nuxt etc.):

- New `Plugin` trait method `virtual_package_suffixes() -> &[&'static str]`
  with default `&[]`, propagated through `AggregatedPluginResult`.
- `VitestPlugin` returns `&["/__mocks__"]`.
- `find_unlisted_dependencies` skips any extracted `package_name`
  ending with a registered suffix.
- `run_plugins` merges `virtual_package_suffixes` from per-workspace
  plugin runs into the root `AggregatedPluginResult`, mirroring the
  existing merge for `virtual_module_prefixes` and
  `generated_import_patterns`. Without this, when vitest is only in a
  workspace's `package.json` (not the root), the suffix gets registered
  locally and dropped before the analyzer reads it.

Tests:
- 5 unit tests in `vitest.rs` and `unused_deps_tests` covering
  scoped (`@aws-sdk/__mocks__`), unscoped (`some-pkg/__mocks__`),
  and non-mocks specifiers.
- Registry test asserting vitest contributes the suffix.
- 2 integration tests with fixtures: `vitest-mocks-virtual`
  (single-package, vitest at root) and `vitest-mocks-workspace`
  (monorepo, vitest only in a workspace's package.json) — the second
  fixture reproduces the workspace-aggregation case the unit tests
  alone cannot catch.
@BartWaardenburg BartWaardenburg merged commit 49cf01e into fallow-rs:main May 3, 2026
18 checks passed
BartWaardenburg added a commit that referenced this pull request May 3, 2026
Three follow-ups surfaced during pre-ship review of #263 and #265:

1. #263 added five tests for the Next.js dynamic-import re-export fix.
   Four are regression-strength but `dynamic_import_named_without_matching_export_still_flagged`
   is coverage-adjacent: the wrapper exports `Bar` and the duplicate
   detection looks for `Foo` between two unrelated modules, so the
   wrapper's `matches_export` check is never on the path that decides
   the test outcome. Removing the Named branch of `matches_export`
   does not make this test fail. Add a true regression test
   (`dynamic_import_named_mismatched_with_wrapper_export_still_flagged`)
   where the wrapper exports `Foo` AND dynamically imports a different
   name `Bar` from source, so without the matches_export Named branch
   the wrapper would be registered as a re-export edge and the
   (source, wrapper) `Foo` duplicate would be silently suppressed.
   Empirically verified: removing the Named branch makes this test
   fail and leaves the other four passing.

2. #265 added `Plugin::virtual_package_suffixes()` but only via an
   explicit `impl Plugin for VitestPlugin` block. The three
   `define_plugin!` macro variants in `plugins/mod.rs` still listed
   `virtual_module_prefixes` without a peer entry for the new field,
   so a future plugin author using the macro would have no path to
   declare suffixes. Extend all three variants to accept
   `virtual_package_suffixes:` and add a synthetic smoke test to guard
   future macro regressions.

3. `.claude/rules/plugins.md` `## Plugin trait extensions` section
   listed `path_aliases` and `virtual_module_prefixes` but not the new
   `virtual_package_suffixes`. Add it with a one-line description and
   a Vitest example.
@BartWaardenburg
Copy link
Copy Markdown
Collaborator

Released in v2.63.0. Thanks @fmguerreiro.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants