Swift-Spyable

repository·main·Indexed 19 days ago

https://github.com/matejkob/swift-spyable

A Swift macro-based tool that automates the creation of spy classes for protocols to simplify unit testing, SwiftUI previews, and dummy implementations. It provides built-in interaction tracking for calls, arguments, and return values, with specialized support for method overloading (polymorphism) through a deterministic naming algorithm for generated properties.

Tokens
3.4K
Snippets
7
Records
15
Agent score
65%

What's inside Swift-Spyable

  1. How polymorphism support works in Swift-Spyable

    main

    Swift-Spyable supports method overloading (polymorphism) by generating unique spy properties for each distinct method signature. This allows you to independently mock and verify different overloads of the same method name within a single spy class.

    When a protocol is marked with @Spyable, the macro inspects the parameter names, parameter types, and return types to ensure each overload has its own set of tracking properties (e.g., CallsCount, Called, ReceivedValue, and ReturnValue).

    @Spyable
    protocol DataProcessor {
        func compute(value: String) -> String
        func compute(value: Int) -> String
        func compute(value: Bool) -> String
    }
  2. How Spyable handles overloaded methods

    main

    When a protocol contains multiple functions with the same name (polymorphism), @Spyable uses a naming algorithm to ensure each generated spy property is unique.

    Identifiers are constructed using:

    1. Function Name
    2. Parameter Names (capitalized, ignoring _)
    3. Parameter Types (in descriptive mode)
    4. Return Type (in descriptive mode)
    5. Special Keywords (e.g., async, throws, escaping, Sendable)

    Type sanitization converts complex types into valid identifiers (e.g., [Type] becomes ArrayType, [Key: Value] becomes DictionaryKeyValue, and ? becomes Optional).

    @Spyable
    protocol DataService {
        func loadData() -> String
        func loadData() -> Int
    }
    
    // The generated spy will have distinct identifiers like:
    // public var loadDataStringCallsCount = 0
    // public var loadDataIntCallsCount = 0
  3. Understand the Swift-Spyable Naming Convention Algorithm

    main

    The Swift-Spyable macro automatically generates unique variable names for tracking method calls, arguments, and return values in spy classes. It uses a two-mode approach to ensure uniqueness:

    1. Non-Descriptive Mode (Default): Used when a method's signature is unique within its protocol. It combines the function name with capitalized parameter names.

      • Pattern: functionName + CapitalizedParameterFirstNames
      • Example: func display(text: String, name: String) $\rightarrow$ displayTextName
    2. Descriptive Mode (Conflict Resolution): Automatically triggered if multiple methods share the same base prefix (polymorphism). It includes parameter types and the return type to ensure uniqueness.

      • Pattern: functionName + CapitalizedParameterFirstNamesWithTypes + SanitizedReturnType
      • Example: func display(text: String, name: String) -> String $\rightarrow$ displayTextStringNameStringString

    This mechanism allows the spy to distinguish between overloaded methods (methods with the same name but different signatures) without manual configuration.

  4. How parameter names and types are sanitized

    main

    When generating variable names, Swift-Spyable applies specific rules to parameter names and types to ensure they are valid identifiers:

    Parameter Name Rules

    • First Parameter Names: Uses the external parameter name (e.g., func foo(external internal: String) uses external).
    • Underscore Parameters: Completely ignored (e.g., func foo(_ text: String, name: String) $\rightarrow$ fooName).
    • Capitalization: The first letter of each parameter name is capitalized.

    Type Sanitization Rules

    • Collection Conversion: Shorthand syntax is converted to descriptive names: [Type] $\rightarrow$ ArrayType and [Key: Value] $\rightarrow$ DictionaryKeyValue.
    • Optional Handling: Trailing ? marks are converted to an Optional prefix (e.g., String? $\rightarrow$ OptionalString, String?? $\rightarrow$ OptionalOptionalString).
    • Attribute Handling: Function attributes are converted to their non-attributed form (e.g., @escaping $\rightarrow$ escaping, @MainActor $\rightarrow$ MainActor).
    • Forbidden Characters: The following are removed: :, [, ], <, >, (, ), ,, , -, &.
  5. Install Spyable via Swift Package Manager

    main

    Add Spyable to your Package.swift dependencies and then add the product to your target.

    // In Package.swift
    dependencies: [
      .package(url: "https://github.com/Matejkob/swift-spyable", from: "0.3.0")
    ]
    
    // In your target definition
    .product(name: "Spyable", package: "swift-spyable")
  6. Best practices for using Swift-Spyable

    main

    To ensure predictable and readable spy classes, follow these best practices:

    1. Use Unique Parameter Names: Avoid using generic parameter names that might cause collisions across different methods in the same protocol.
    2. Leverage Return Types for Overloads: If you have multiple methods with the same name and parameters, ensure they have different return types to help the algorithm generate distinct descriptive names.
    3. Monitor Name Length: Be cautious with complex, nested types (like nested dictionaries or optionals), as they will significantly increase the length of the generated variable names in descriptive mode.
    4. Verify Generated Spies: Always inspect the generated spy code to confirm that the variable names match your expectations for testing assertions.
  7. Quick Start with Spyable

    main

    Spyable uses a Swift macro to automatically generate spy classes for your protocols. This is useful for testing, SwiftUI previews, or creating dummy implementations.

    To use it:

    1. Import the module: import Spyable.
    2. Annotate your protocol with @Spyable.
    3. The macro generates a class named [ProtocolName]Spy that conforms to your protocol and tracks all interactions.

    The generated spy provides properties to inspect method calls, such as [methodName][paramName]CallsCount, [methodName][paramName]ReceivedArg, and [methodName][paramName]ReturnValue.

    import Spyable
    
    @Spyable
    public protocol ServiceProtocol {
      var name: String { get }
      func fetchConfig(arg: UInt8) async throws -> [String: String]
    }
    
    // Usage in a test
    func testFetchConfig() async throws {
      let serviceSpy = ServiceProtocolSpy()
      
      // Set a stubbed return value
      serviceSpy.fetchConfigArgReturnValue = ["key": "value"]
    
      // ... execute code under test ...
    
      // Verify interactions
      XCTAssertEqual(serviceSpy.fetchConfigArgCallsCount, 1)
      XCTAssertEqual(serviceSpy.fetchConfigArgReceivedInvocations, [1])
    }
  8. Identify naming limitations and edge cases

    main

    When working with complex Swift types, be aware of how the naming algorithm might produce unexpected results:

    • Type Ambiguity: The algorithm does not distinguish between type aliases and actual types, nor does it handle module-qualified types (e.g., Swift.String vs String) differently.
    • Generic Constraints: Constraints and where clauses are lost. func process<T: Codable>(item: T) becomes processItemTT.
    • Protocol Composition: Composition is concatenated without separators (e.g., Codable & Hashable $\rightarrow$ CodableHashable).
    • Unnamed Tuples: Elements without labels are simply concatenated (e.g., (Int, Int) $\rightarrow$ IntInt).
    • Case Sensitivity: The algorithm is case-sensitive. func process(Data: String) and func process(data: String) will result in a naming conflict.
    • Complex Function Types: Highly nested function types or complex generics may result in very long, hard-to-read variable names.
  9. Test polymorphic methods with Swift-Spyable

    main

    To test overloaded methods, use the generated unique property names to set up return values and verify calls for each specific signature independently.

    func testPolymorphism() {
        let spy = DataProcessorSpy()
        
        // 1. Setup different return values for each overload
        spy.computeValueStringStringReturnValue = "String result"
        spy.computeValueIntStringReturnValue = "Int result"
        spy.computeValueBoolStringReturnValue = "Bool result"
        
        // 2. Call different overloads
        let stringResult = spy.compute(value: "test")
        let intResult = spy.compute(value: 42)
        let boolResult = spy.compute(value: true)
        
        // 3. Verify each overload was called exactly once
        XCTAssertEqual(spy.computeValueStringStringCallsCount, 1)
        XCTAssertEqual(spy.computeValueIntStringCallsCount, 1)
        XCTAssertEqual(spy.computeValueBoolStringCallsCount, 1)
        
        // 4. Verify correct arguments were captured
        XCTAssertEqual(spy.computeValueStringStringReceivedValue, "test")
        XCTAssertEqual(spy.computeValueIntStringReceivedValue, 42)
        XCTAssertEqual(spy.computeValueBoolStringReceivedValue, true)
    }
  10. Restrict spy availability with preprocessor flags

    main

    You can prevent the generated spy code from being compiled in certain environments (e.g., production) by using the behindPreprocessorFlag parameter. This wraps the generated class in a #if directive.

    @Spyable(behindPreprocessorFlag: "DEBUG")
    protocol DebugProtocol {
      func logSomething()
    }
    // Generates: 
    // #if DEBUG
    // internal class DebugProtocolSpy: DebugProtocol { ... }
    // #endif
  11. Configure access levels for generated spies

    main

    By default, the generated spy class and its members inherit the access level of the annotated protocol. You can override this using the accessLevel argument in the @Spyable macro.

    Supported accessLevel values:

    • .public
    • .package
    • .internal
    • .fileprivate
    • .private
    @Spyable(accessLevel: .fileprivate)
    public protocol CustomProtocol {
      func restrictedTask()
    }
    // Generates: fileprivate class CustomProtocolSpy: CustomProtocol