Cuckoo Mocking Framework for Swift

repository·master·Indexed 23 days ago

https://github.com/brightify/cuckoo

A mocking framework for Swift that provides a DSL similar to Mockito. Cuckoo uses a code generation tool called Cuckoonator to overcome Swift's reflection limitations, enabling the mocking of classes and protocols for unit testing. It supports iOS 13+, macOS 10.15+, tvOS 13+, and watchOS 8+. Key features include stubbing with custom implementations, call verification with matchers, argument capturing, and a bridge to OCMock for Objective-C classes.

Tokens
5.2K
Snippets
11
Records
24
Agent score
81%

What's inside Cuckoo

  1. How Cuckoo works

    master

    Cuckoo is a mocking framework for Swift that operates using two components: a runtime and a command-line tool called Cuckoonator.

    Because Swift lacks full reflection, Cuckoo uses a compile-time generation approach. The Cuckoonator tool scans your specified files and generates supporting structs and classes. These generated files allow the runtime to create mocks via inheritance and protocol adoption.

    Key Limitation: Only overridable code can be mocked. If a component cannot be overridden (e.g., due to final modifiers or being a struct), Cuckoo cannot mock it directly.

  2. Supported and unsupported mocking targets in Cuckoo

    master

    Cuckoo relies on inheritance and protocol adoption to create mocks. This determines what can and cannot be mocked.

    Supported

    • Inheritance (including grandparent methods)
    • Generics
    • Simple type inference for instance variables (via initializers, as TYPE notation, or explicit type specification)
    • Objective-C mocks (utilizing OCMock)

    Unsupported

    Due to Swift's language constraints, the following cannot be mocked:

    • struct (Workaround: use a common protocol instead)
    • Anything with final or private modifiers
    • Global constants and functions
    • Static properties and methods
  3. Configure the Cuckoo run script build options

    master

    The Cuckoo run script manages the cuckoonator generator. These options affect how the generator is obtained and do not change the behavior of the generated mocks themselves.

    • Default behavior: Running the script without parameters searches for cuckoonator. If missing, it builds it from source and then runs the generator.
    • --download: Use this as the first argument (run --download) to download the generator from GitHub instead of building it from source. This is recommended for CI environments to reduce build times.
    • --clean: Forces a fresh build or download of the specified version, even if the generator is already present. Use this only to resolve compilation issues, as it increases test execution time.

    Handling GitHub API Rate Limits

    If using --download in CI and encountering GitHub API rate limits, provide a GitHub token via the GITHUB_ACCESS_TOKEN environment variable in your build phase script (placed above the run call):

    export GITHUB_ACCESS_TOKEN="XXXXXXX"
  4. Install Cuckoo via Swift Package Manager

    master

    Add Cuckoo to your test targets only.

    1. Add the Cuckoo repository URL: https://github.com/Brightify/Cuckoo.git.
    2. In your test target's Build Phases, add the CuckooPluginSingleFile plug-in to the Run Build Tool Plug-ins section.

    If your project has multiple modules and you want to generate separate mock files per dependency, use CuckooPluginModular instead. This plugin automatically detects source modules from your test target's dependencies and generates a dedicated mock file for each one (e.g., GeneratedMocks_<ModuleName>.swift).

    URL: `https://github.com/Brightify/Cuckoo.git`
  5. Install Cuckoo via CocoaPods

    master

    To install Cuckoo using CocoaPods, add the following line to your test target in your Podfile:

    pod 'Cuckoo', '~> 2.0'

    Then, add a Run script build phase to your test target's Build Phases (ensure it is placed above the Compile Sources phase) with the following script:

    # Skip for indexing
    if [ $ACTION == "indexbuild" ]; then
      exit 0
    fi
    
    # Skip for preview builds
    if [ "${ENABLE_PREVIEWS}" = "YES" ]; then
      exit 0
    fi
    
    "${PODS_ROOT}/Cuckoo/run"

    Important Notes:

    • Xcode 15+: You must change the ENABLE_USER_SCRIPT_SANDBOXING setting in Build Settings to No to allow the script to access files.
    • Post-Installation: After running the script once, locate GeneratedMocks.swift and drag it into your Xcode test target group.
    • Paths: All paths in the Run script must be absolute. Use the PROJECT_DIR variable to point to your project directory.
    • Inheritance: Remember to include paths to inherited Classes and Protocols for mocking/stubbing parent and grandparents.
  6. Set up the Cuckoo development environment

    master

    To contribute to Cuckoo or run the project locally, follow these steps:

    1. Clone the repository.
    2. Install Mise.
    3. Run make at the root of the repository. This installs dependencies, generates the project using Tuist, and opens the workspace in Xcode.
    4. Important: Re-run make whenever you switch branches to ensure the project files are correctly regenerated by Tuist.

    Project Structure

    When opening Cuckoo.xcworkspace in Xcode, the project is organized as follows:

    • Source: Contains the runtime sources.
    • Tests: Contains tests for the runtime.
    • Generator.xcodeproj: Contains the generator source code. Use the Generator scheme to run the generator code.
    make
  7. Handle Xcode project integration

    master

    Cuckoo can automatically discover source files by inspecting an .xcodeproj file.

    If you provide an xcodeproj path and a target name in your module configuration:

    • The generator will locate the .xcodeproj file.
    • It will find the specific target within that project.
    • It will extract all source files associated with that target.

    Note on Merging: If you define both an xcodeproj and a manual list of sources, Cuckoo will merge both lists together to form the complete set of input files. If glob is enabled in your options, Cuckoo will also expand any glob patterns found within the source paths.

  8. Configure Cuckoofile.toml

    master

    Create a Cuckoofile.toml file at the root of your project to configure mock generation.

    For CuckooPluginSingleFile

    You can define a global output and specific [modules.<ModuleName>] configurations. Each module can specify its own output, imports, publicImports, testableImports, sources, exclude patterns, and regex filters. You can also configure options like keepDocumentation and omitHeaders.

    For CuckooPluginModular

    This plugin is recommended for Swift Packages with multiple targets. It generates a separate GeneratedMocks_<ModuleName>.swift for each dependency. You must provide a [modules.<TargetName>] entry for every module you want to mock. The plugin matches the entry name to the test target's name (e.g., [modules.TargetATests]).

    # You can define a fallback output for all modules that don't define their own.
    output = "Tests/Swift/Generated/GeneratedMocks.swift"
    
    [modules.MyProject]
    output = "Tests/Swift/Generated/GeneratedMocks+MyProject.swift"
    # Standard imports added to the generated file(s).
    imports = ["Foundation"]
    # Public imports if needed due to imports being internal by default from Swift 6.
    publicImports = ["ExampleModule"]
    # @testable imports if needed.
    testableImports = ["RxSwift"]
    sources = [
        "Tests/Swift/Source/*.swift",
    ]
    exclude = ["ExcludedTestClass"]
    # Optionally, you can use a regular expression to filter only specific classes/protocols.
    # regex = ""
    
    [modules.MyProject.options]
    # glob = false
    # Docstrings are preserved by default, comments are omitted.
    keepDocumentation = false
    # enableInheritance = false
    # protocolsOnly = true
    # omitHeaders = true
    
    # If specified, Cuckoo can also get sources for the module from an Xcode target.
    [modules.MyProject.xcodeproj]
    # Path to folder with .xcodeproj, omit this if it's at the same level as Cuckoofile.
    path = "Generator"
    target = "Cuckoonator"
    
    # You can define as many modules as you need, each with different sources/options/output.
    [modules.AnotherProject]
    # ...
  9. Configure Cuckoo generation filters

    master

    When generating mocks, you can filter which classes and protocols are processed using several options in the Module configuration:

    • Protocols only: If protocolsOnly is enabled, all classes are ignored, and only protocols are used to generate mocks.
    • Regex matching: You can provide a regex pattern. Only classes or protocols whose names match this regular expression will be included.
    • Exclusion list: You can provide a list of names in exclude. Any class or protocol matching a name in this list will be ignored.

    These filters are applied during the generation process to ensure only the desired types are mocked.

  10. Enable inheritance and NSObject support in mocks

    master

    Cuckoo can handle inheritance hierarchies during mock generation. If enableInheritance is set to true in your module options, the generator performs two main tasks:

    1. Merging Inheritance: It merges member containers across files to resolve inheritance relationships.
    2. NSObject Support: It identifies protocols that recursively inherit from NSObjectProtocol and applies inheritNSObject logic to ensure compatibility with Objective-C/NSObject-based types.

    This is particularly useful when mocking types that rely on the NSObject hierarchy.

  11. Capture arguments with ArgumentCaptor

    master

    Use ArgumentCaptor to inspect the actual arguments passed to a mock during verification. This is recommended for verification rather than stubbing.

    let argumentCaptor = ArgumentCaptor<Int>()
    
    // Perform actions
    mock.readWriteProperty = 10
    mock.readWriteProperty = 20
    mock.readWriteProperty = 30
    
    // Verify and capture
    verify(mock, times(3)).readWriteProperty.set(argumentCaptor.capture())
    
    print(argumentCaptor.value)    // Returns the last captured argument: 30
    print(argumentCaptor.allValues) // Returns all captured values: [10, 20, 30]
    let argumentCaptor = ArgumentCaptor<Int>()
    verify(mock, times(3)).readWriteProperty.set(argumentCaptor.capture())
    argumentCaptor.value // Returns 30
    argumentCaptor.allValues // Returns [10, 20, 30]
  12. Enable default implementation for Mocks

    master

    You can provide an instance of the original class to a mock so that any method/property not explicitly stubbed uses the original implementation.

    For Classes:

    let original = OriginalClass<Int>(value: 12)
    mock.enableDefaultImplementation(original)

    For Structs: If you are mocking a protocol that a struct conforms to, you must decide if you need to track changes to the struct.

    • enableDefaultImplementation(_:): Creates a copy of the struct. Changes to the original struct are not reflected in the mock.
    • enableDefaultImplementation(mutating:): Takes a reference to the struct. Changes made during mock calls are reflected in the original struct.
    let original = ConformingStruct<String>(value: "Hello, Cuckoo!")
    mock.enableDefaultImplementation(original)
    // or to track changes:
    mock.enableDefaultImplementation(mutating: &original)