Skip to content

Add CuckooPluginPerModule for per-module mock generation#595

Open
SylvainRX wants to merge 2 commits intoBrightify:masterfrom
SylvainRX:feature/per-module-plugin
Open

Add CuckooPluginPerModule for per-module mock generation#595
SylvainRX wants to merge 2 commits intoBrightify:masterfrom
SylvainRX:feature/per-module-plugin

Conversation

@SylvainRX
Copy link

@SylvainRX SylvainRX commented Mar 11, 2026

Summary

Adds a new CuckooPluginPerModule build tool plugin that generates separate mock files for each module dependency, improving modularity and build performance. Also adds compound module name support for per-target mock configuration.

Related issue: #555

Motivation

The existing CuckooPluginSingleFile generates all mocks into a single GeneratedMocks.swift file, which forces test targets to depend on mocks for all modules. This creates unnecessary coupling and can slow down incremental builds when using multiple test targets in a Swift Package.

Additionally, when multiple test targets depend on the same module but need different mocks generated (e.g., different source files from that module), there was no way to configure this per-target.

Changes

New Plugin: CuckooPluginPerModule

  • Generates one mock file per module: GeneratedMocks_<ModuleName>.swift
  • Each module gets its own build command with CUCKOO_MODULE_NAME environment variable
  • Test targets only include mocks for their direct dependencies
  • Located in Generator/Plugin/PerModule/

Performance Fix: Incremental Build Optimization

Added content-equality guards in GenerateCommand.swift to prevent unnecessary file writes:

  1. Normal path: Skip write if output content hasn't changed
  2. Early-exit path: Skip write if empty placeholder file already exists

This prevents mtime churn on generated files, which was causing Swift to recompile all test sources on every build even when mocks were unchanged.

Compound Module Name Support

Enables test targets to override shared module mock generation:

  • Plugin: Passes CUCKOO_COMPOUND_MODULE_NAME environment variable with format TARGET/MODULE (e.g., ManualCardEntryFeatureTests/PaymentMethodOnFileAPI)
  • Plugin: Generates build command for the test target itself, not just its dependencies
  • Generator: Prioritizes compound module key over plain module name when filtering Cuckoofile.toml entries
  • Generator: Supports suppressor pattern - entries with empty sources array generate empty output files

This allows Cuckoofile.toml to specify different mock sources for the same module when used in different test targets.

Example use case: MyFirstModuleTests needs mocks from MySharedModule's Client.swift but not from MySharedModule's Generated/Types.swift (which has unwanted dependencies).

Backward compatible: Falls back to CUCKOO_MODULE_NAME if compound key not found.

Usage

Basic Per-Module Plugin

Just add the plugin - it automatically generates mocks for all protocols/classes in your dependencies.

Package.swift:

.testTarget(
    name: "MyFirstModuleTests",
    dependencies: [
        "MyFirstModule",
        .product(name: "Cuckoo", package: "Cuckoo")
    ],
    plugins: [
        .plugin(name: "CuckooPluginPerModule", package: "Cuckoo")
    ]
)

Output:

DerivedData/.../MyFirstModuleTests/CuckooPluginPerModule/GeneratedMocks_MyFirstModule.swift

No Cuckoofile.toml needed - mocks are generated from all source files in MyFirstModule.

Advanced: Per-Target Configuration

Use Cuckoofile.toml to customize which source files from each module get mocked. Useful when:

  • A shared module has files you don't want mocked in certain test targets
  • Different test targets need mocks from different files of the same module
  • You want to suppress mock generation for specific module/target combinations

Package.swift:

.testTarget(
    name: "MyFirstModuleTests",
    dependencies: [
        "MyFirstModule",
        "MySharedModule",  // Shared dependency
        .product(name: "Cuckoo", package: "Cuckoo")
    ],
    plugins: [
        .plugin(name: "CuckooPluginPerModule", package: "Cuckoo")
    ]
),
.testTarget(
    name: "MySecondModuleTests",
    dependencies: [
        "MySecondModule",
        "MySharedModule",  // Same shared dependency, different needs
        .product(name: "Cuckoo", package: "Cuckoo")
    ],
    plugins: [
        .plugin(name: "CuckooPluginPerModule", package: "Cuckoo")
    ]
)

Cuckoofile.toml:

# Shared configuration - used by MySecondModuleTests and others
[modules.MySharedModule]
imports = ["Foundation"]
testableImports = ["MySharedModule"]
sources = [
    "Sources/MySharedModule/Generated/Types.swift"
]

# Override for MyFirstModuleTests - different sources from same module
[modules.MyFirstModuleTests]
imports = ["Foundation"]
testableImports = ["MySharedModule"]
sources = [
    "Sources/MySharedModule/Client.swift"
]

Result:

  • MyFirstModuleTests gets mocks from Client.swift (from test target config) AND Types.swift (from shared config)
  • MySecondModuleTests gets mocks from Types.swift only
  • Both test targets use the same MySharedModule dependency but generate different mocks

Note: The plugin generates mocks for both the test target itself and its dependencies. If you need to prevent the shared config from also running (e.g., to avoid compilation overhead or mock conflicts), you can add a suppressor entry with empty sources using the compound module name format ["modules"."MyFirstModuleTests/MySharedModule"].

Breaking Changes

None. All changes are backward compatible with existing plugins and configurations.

- Create new CuckooPluginPerModule plugin that generates separate mock files per module dependency
- Add content-equality guards in GenerateCommand to prevent unnecessary file rewrites
- Skip writing output files when content unchanged

This allows test targets to depend only on specific module mocks rather than all mocks in a single file, while maintaining the original plugin for existing users.
Enables test targets to override shared module mock generation by introducing CUCKOO_COMPOUND_MODULE_NAME (TARGET/MODULE format).

- Plugin:
  - Pass CUCKOO_COMPOUND_MODULE_NAME env var for each dependency
  - Generate build command for test target itself (not just deps)
- Generator:
  - Prioritize compound module key over plain module name
  - Support suppressor pattern (empty sources = empty output)

This allows Cuckoofile.toml to specify different mock sources for the
same module when used in different test targets, fixing issues where
shared dependencies generate unwanted mocks.
Comment on lines +61 to +78
var modules: [Module]
if let compoundModuleName {
let compoundMatches = allModules.filter { $0.name == compoundModuleName }
if !compoundMatches.isEmpty {
// Compound key (TARGET/MODULE) found – use it exclusively.
// An entry with empty sources acts as a suppressor, producing an empty output file.
modules = compoundMatches
} else if let requestedModuleName {
// No compound override – fall back to the plain module name.
modules = allModules.filter { $0.name == requestedModuleName }
} else {
modules = []
}
} else if let requestedModuleName {
modules = allModules.filter { $0.name == requestedModuleName }
} else {
modules = allModules
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the allModules aren't used (and I can't think of why they would be), I'd like this filtering logic moved there instead, keeping this high-level implementation easy to read.

targets: ["CuckooPluginSingleFile"]
),
.plugin(
name: "CuckooPluginPerModule",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use "CuckooPluginModular" instead, does that make sense for this implementation?

Comment on lines 91 to 132
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure comparing potentially large strings on every generation run is the way to go here. I'd rather cache the mocked file names and their latest modification date which is then compared to the generated file modification date. I'm not asking you to implement that, buut I'd rather leave the logic as it is until that is implemented.

Copy link
Collaborator

@MatyasKriz MatyasKriz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! I appreciate you taking the time to improve the project like this. Especially the automatic source detection might be nice for small projects.

One more thing, though – please mention this new modular build tool in README, so that users don't miss it. 🙂

@SylvainRX
Copy link
Author

@MatyasKriz Thank you for the review and feedbacks! I'll address them as soon as I have some time.

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