Skip to content

Conversation

ThomasDutartre
Copy link
Contributor

@ThomasDutartre ThomasDutartre commented Jul 9, 2025

🚀 Enhancement: Spy Class Inheritance Support with inheritedType

Approach n°3 of this issue : #47
I started my work from the pr of @MaxenceMottard
#136

📋 Summary

This pull request adds support for class inheritance in the @Spyable macro via the new inheritedType parameter, allowing a spy class to inherit from another spy class. This feature also includes special handling of the open access level to support modular architectures.

🎯 Main Objective: Spy Class Inheritance

Identified Need

Enable a generated spy class to inherit from another spy class to create coherent test hierarchies:

// Step 1: Base protocol
@Spyable
protocol BaseService {
    func baseMethod()
}
// Generates: BaseServiceSpy

// Step 2: Specialized protocol inheriting from base spy
@Spyable(inheritedType: "BaseServiceSpy")
protocol UserService: BaseService {
    func getUserData()
}
// Generates: UserServiceSpy: BaseServiceSpy, UserService

Concrete Use Cases

  1. Protocol hierarchies: Create spies that respect inheritance relationships
  2. Organized tests: Reuse base spy behaviors
  3. Modular architecture: Separate base protocols from specialized protocols

🏗️ Technical Challenge: Modular Architectures

Separate Package Problem

In certain architectures, tests are not in the same package as the protocols. We therefore need to offer the possibility of inheriting spy classes between packages.

Discovered Edge Case: open Access Level

To be able to override an init from a class that comes from another package, the class must be open. That's why I added support for open.

However, Swift does not allow open initializers:

// ❌ Compilation Error
@Spyable(accessLevel: .open, inheritedType: "BaseServiceSpy")
protocol UserService: BaseService {
    func getUserData()
}

// Generated:
open class UserServiceSpy: BaseServiceSpy, UserService {
    open init() { } // ❌ Swift forbids 'open' initializers
}

✅ Implemented Solution

1. New inheritedType Parameter

Added a simple parameter for spy class inheritance:

@attached(peer, names: suffixed(Spy))
public macro Spyable(
  behindPreprocessorFlag: String? = nil,
  accessLevel: SpyAccessLevel? = nil,
  inheritedType: String? = nil  // ✅ New parameter
) = #externalMacro(module: "SpyableMacro", type: "SpyableMacro")

2. Smart open Initializer Handling

The macro automatically detects when access level is open and generates the initializer as public:

// ✅ Now works
@Spyable(accessLevel: .open, inheritedType: "BaseServiceSpy")
protocol UserService: BaseService {
    func getUserData()
}

// Generates:
open class UserServiceSpy: BaseServiceSpy, UserService {
    override public init() { } // ✅ Public instead of open for initializer
    open func getUserData() { ... } // ✅ Open for other members
}

3. Current Limitation: Single Inheritance Only

For now, only single type inheritance is supported. Multiple inheritance is not supported at this time, supporting a single protocol is our only path forward.

🔧 Technical Changes

Main Modified Files

  1. Spyable.swift

    • Added inheritedType: String? parameter
    • Documentation with inheritance examples
  2. AccessLevelModifierRewriter.swift

    • Special logic: detection of open + InitializerDeclSyntax
    • Automatic conversion openpublic for initializers
  3. Extractor.swift

    • extractInheritedType() method to extract inherited type
    • Single type extraction
  4. SpyFactory.swift

    • Adaptation to handle single inherited type
    • Generation of override when necessary
  5. SpyableMacro.swift

    • Integration of inherited type extraction

Comprehensive Tests Added

  • UT_AccessLevelModifierRewriter.swift: 12 tests for access level handling
  • UT_SpyableMacro.swift: 6 tests for new use cases with inheritedType

🎯 Enabled Use Cases

1. Simple Inheritance

@Spyable
protocol BaseProtocol { func base() }

@Spyable(inheritedType: "BaseProtocolSpy")
protocol SpecializedProtocol: BaseProtocol { func specialized() }
// SpecializedProtocolSpy inherits from BaseProtocolSpy

2. Inheritance Chain

@Spyable(inheritedType: "EquatableSpy")
protocol A: Equatable { func a() }

@Spyable(inheritedType: "ASpy")
protocol B: A { func b() }
// BSpy inherits from ASpy which inherits from EquatableSpy

3. Modular Architecture with open

// Core Package
@Spyable(accessLevel: .open)
public protocol CoreService { func core() }

// Features Package (separate tests)
@Spyable(accessLevel: .open, inheritedType: "CoreServiceSpy")
public protocol FeatureService: CoreService { func feature() }
// ✅ Works perfectly across packages

@ThomasDutartre ThomasDutartre force-pushed the add-inherited-types-macro-parameter branch 2 times, most recently from 18b1511 to 6d23806 Compare July 9, 2025 11:46
@ThomasDutartre ThomasDutartre force-pushed the add-inherited-types-macro-parameter branch from 6d23806 to 6edc036 Compare July 9, 2025 11:50
Copy link

codecov bot commented Jul 9, 2025

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 95.78%. Comparing base (5ee55d3) to head (2a3a659).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #144      +/-   ##
==========================================
+ Coverage   95.56%   95.78%   +0.22%     
==========================================
  Files          22       22              
  Lines        1330     1401      +71     
==========================================
+ Hits         1271     1342      +71     
  Misses         59       59              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ThomasDutartre
Copy link
Contributor Author

ThomasDutartre commented Jul 9, 2025

@Matejkob If you're ok with the idea of this PR, i will add the documentation before merge

@ThomasDutartre ThomasDutartre marked this pull request as ready for review July 9, 2025 13:26
@sbo-nemlig
Copy link

@ThomasDutartre should the new open access Level not be part of the SpyAccessLevel enum ?

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.

3 participants