Skip to content

Commit

Permalink
Leif/associated types (#9)
Browse files Browse the repository at this point in the history
* Combine windows os code

* Add support for associated types and classes

* Update tests and documentation

* Simplify type sorting
  • Loading branch information
0xLeif authored Nov 3, 2024
1 parent 3ed3df1 commit 1ec968d
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 79 deletions.
2 changes: 0 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ let GUISwiftSettings: [SwiftSetting] = [
]
let GUILinkerSettings: [LinkerSetting] = [
]
#endif

#if os(Windows)
let macroTarget: Target = Target.macro(
name: "MockedMacros",
dependencies: [
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ let mock = MockedCustomProtocol(
print(mock.defaultMethod()) // Output: "default"
```

### Advanced Usage

The `Mocked` macro can be used with more complex protocols, including those with associated types, `async` methods, `throws` methods, or a combination of both.

```swift
@Mocked
protocol ComplexProtocol {
associatedtype ItemType
associatedtype ItemValue: Codable
func fetchData() async throws -> ItemType
func processData(input: Int) -> Bool
func storeValue(value: ItemValue) -> Void
}

let mock = MockedComplexProtocol<String, Int>(
fetchData: { return "Mocked Data" },
processData: { input in return input > 0 }
)

// Usage in a test
Task {
do {
let data = try await mock.fetchData()
print(data) // Output: "Mocked Data"
} catch {
XCTFail("Unexpected error: \(error)")
}
}

let isValid = mock.processData(input: 5)
XCTAssertTrue(isValid)
```

### Edge Cases and Warnings

- **Non-Protocol Usage**: The `@Mocked` macro can only be applied to protocols. Using it on other types will result in a compilation error.
Expand Down
149 changes: 106 additions & 43 deletions Sources/Mocked/Mocked.swift
Original file line number Diff line number Diff line change
@@ -1,46 +1,109 @@
/// The `Mocked` macro is used to automatically generate a mocked implementation of a protocol.
///
/// This macro attaches a peer struct prefixed with `Mocked` that provides implementations of all the methods and properties defined in the protocol.
///
/// # Usage
/// Apply the `@Mocked` attribute to a protocol declaration to generate a mock implementation of that protocol. This mock implementation can be used for unit testing purposes to easily verify interactions with the protocol methods and properties.
///
/// Example:
/// ```swift
/// @Mocked
/// protocol MyProtocol {
/// var title: String { get set }
/// func performAction() -> Void
/// }
/// ```
///
/// The code above will generate a `MockedMyProtocol` struct that implements `MyProtocol`.
///
/// # Edge Cases and Warnings
/// - **Non-Protocol Usage**: This macro can only be applied to protocol definitions. Attempting to use it on other types, such as classes or structs, will lead to a compilation error.
/// - **Unimplemented Methods**: Any method that is not explicitly overridden will call `fatalError()` when invoked, which will crash the program. Ensure all necessary methods are mocked when using the generated struct.
/// - **Async and Throwing Methods**: The macro correctly handles protocols with `async` and/or `throws` functions. Be mindful to provide appropriate closures during initialization.
///
/// # Example of Generated Code
/// For the protocol `MyProtocol`, the generated mock implementation would look like this:
/// ```swift
/// struct MockedMyProtocol: MyProtocol {
/// var title: String
/// private let performActionOverride: (() -> Void)?
///
/// init(title: String, performAction: (() -> Void)? = nil) {
/// self.title = title
/// self.performActionOverride = performAction
/// }
///
/// func performAction() {
/// guard let performActionOverride else {
/// fatalError("Mocked performAction was not implemented!")
/// }
/// performActionOverride()
/// }
/// }
/// ```
/**
The `Mocked` macro is used to automatically generate a mocked implementation of a protocol, including support for associated types and automatic detection of class requirements.
This macro attaches a peer struct or class prefixed with `Mocked`, which provides implementations of all the methods and properties defined in the protocol. This is particularly useful for unit testing, where creating mock objects manually can be cumbersome and error-prone. With `@Mocked`, developers can easily generate mock implementations that allow precise control over protocol methods and properties, enabling more effective and focused testing.
# Usage
Apply the `@Mocked` attribute to a protocol declaration to generate a mock implementation of that protocol. The generated mock will have the same properties and methods as the protocol, but they can be overridden through closures provided during initialization. This mock implementation can be used for unit testing purposes to easily verify interactions with the protocol methods and properties.
Example:
```swift
@Mocked
protocol MyProtocol {
var title: String { get set }
func performAction() -> Void
}
```
The code above will generate a `MockedMyProtocol` struct that implements `MyProtocol`. This struct allows defining the behavior of `performAction()` by providing a closure during initialization, making it easy to set up test scenarios without writing extensive boilerplate code.
# Features
The `@Mocked` macro provides several key features:
- **Automatic Mock Generation**: Generates a mock implementation for any protocol, saving time and reducing boilerplate code.
- **Closure-Based Method Overrides**: Methods and properties can be overridden by providing closures during mock initialization, giving you full control over method behavior in different test scenarios.
- **Support for Associated Types**: Handles protocols with associated types by using Swift generics, providing flexibility for complex protocol requirements.
- **Automatic Detection of Class Requirements**: If the protocol conforms to `AnyObject`, the macro generates a class instead of a struct, ensuring reference semantics are maintained where needed.
- **Support for `async` and `throws` Methods**: The generated mock can handle methods marked as `async` or `throws`, allowing you to create mock behaviors that include asynchronous operations or errors.
- **Automatic Default Property Implementations**: Provides straightforward storage for properties defined in the protocol, which can be accessed and modified as needed.
# Edge Cases and Warnings
- **Non-Protocol Usage**: This macro can only be applied to protocol definitions. Attempting to use it on other types, such as classes or structs, will lead to a compilation error.
- **Unimplemented Methods**: Any method that is not explicitly overridden will call `fatalError()` when invoked, which will crash the program. This behavior is intentional to alert developers that the method was called without being properly mocked. Always ensure that all necessary methods are mocked when using the generated struct to avoid runtime crashes. Mocks should only be used in tests or previews, where such crashes are acceptable for ensuring proper setup.
- **Async and Throwing Methods**: Be mindful to provide appropriate closures during initialization to match the behavior of `async` or `throws` methods. If no closure is provided, the default behavior will result in a `fatalError()`.
- **Value vs. Reference Semantics**: The generated mock defaults to being a struct, which means it follows value semantics. If the protocol requires reference semantics (e.g., it conforms to `AnyObject`), the macro will generate a class instead.
# Example of Generated Code
For the protocol `MyProtocol`, the generated mock implementation would look like this:
```swift
struct MockedMyProtocol: MyProtocol {
// Properties defined by the protocol
var title: String
// Closure to override the behavior of `performAction()`
private let performActionOverride: (() -> Void)?
// Initializer to provide custom behavior for each method or property
init(title: String, performAction: (() -> Void)? = nil) {
self.title = title
self.performActionOverride = performAction
}
// Method implementation that uses the provided closure or triggers a `fatalError`
func performAction() {
guard let performActionOverride else {
fatalError("Mocked performAction was not implemented!")
}
performActionOverride()
}
}
```
In the generated code:
- The `title` property is stored directly within the struct, allowing you to get or set its value just like a normal property.
- The `performAction` method uses a closure (`performActionOverride`) provided during initialization. If no closure is provided, calling `performAction()` will result in a `fatalError`, ensuring you never accidentally call an unmocked method.
# Advanced Usage
The `Mocked` macro can be used with more complex protocols, including those with associated types, `async` methods, `throws` methods, or a combination of both. This allows developers to test various scenarios, such as successful asynchronous operations or handling errors, without needing to write dedicated mock classes manually.
```swift
@Mocked
protocol ComplexProtocol {
associatedtype ItemType
associatedtype ItemValue: Codable
func fetchData() async throws -> ItemType
func processData(input: Int) -> Bool
func storeValue(value: ItemValue) -> Void
}
let mock = MockedComplexProtocol<String, Int>(
fetchData: { return "Mocked Data" },
processData: { input in return input > 0 }
)
// Usage in a test
Task {
do {
let data = try await mock.fetchData()
print(data) // Output: "Mocked Data"
} catch {
XCTFail("Unexpected error: \(error)")
}
}
let isValid = mock.processData(input: 5)
XCTAssertTrue(isValid)
```
# Limitations
- **Associated Types**: The `@Mocked` macro currently supports protocols with associated types using generics. However, there may be scenarios where creating a type-erased wrapper could be beneficial, especially for protocols with complex associated type relationships.
- **Protocol Inheritance**: When mocking protocols that inherit from other protocols, the `@Mocked` macro will not automatically generate parent mocks for child protocols. Instead, extend the parent protocols or the child protocol to provide the necessary values or functions to conform to the inherited requirements.
# Best Practices
- **Define Clear Protocols**: Define small, focused protocols that capture a specific piece of functionality. This makes the generated mocks easier to use and understand.
- **Avoid Over-Mocking**: Avoid mocking too much behavior in a single test, as it can lead to brittle tests that are difficult to maintain. Instead, focus on the specific interactions you want to verify.
- **Use Closures Thoughtfully**: Provide closures that simulate realistic behavior to make your tests more meaningful. For example, simulate network delays with `async` closures or return specific error types to test error handling paths.
*/
@attached(peer, names: prefixed(Mocked))
public macro Mocked() = #externalMacro(
module: "MockedMacros",
Expand Down
41 changes: 12 additions & 29 deletions Sources/MockedClient/main.swift
Original file line number Diff line number Diff line change
@@ -1,37 +1,20 @@
import Mocked

protocol ThisBreaksShit {
var broken: String { get }
}

@Mocked
protocol SomeParameter: Sendable {
var title: String { get set }
var description: String { get }
protocol ExampleProtocol: Sendable {
associatedtype ItemType: Codable
associatedtype ItemValue: Equatable

func someMethod()
func someMethod(parameter: Int)
func someMethod(with parameter: Int)
var name: String { get set }
var count: Int { get }
var isEnabled: Bool { get set }

func someOtherMethod() throws -> String
func someOtherMethod() async throws -> String
func fetchItem(withID id: Int) async throws -> ItemType
func saveItem(_ item: ItemType) throws -> Bool

func someAsyncMethod() async -> String

func someOptionalMethod() -> String?
func processAllItems() async
func reset()
func optionalItem() -> ItemType?
}

Task { @MainActor in
let mockedParameter = MockedSomeParameter(
title: "Hello",
description: "Descrip",
someMethodParameter: { print("\($0)") },
someOtherMethodAsyncThrows: { "?" }
)

mockedParameter.someMethod(parameter: 3)
let value = try await mockedParameter.someOtherMethod()

print(value)

}
let mock = MockedExampleProtocol<String, String>(name: "Leif", count: 0, isEnabled: true)
32 changes: 31 additions & 1 deletion Sources/MockedMacros/MockedMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,41 @@ public struct MockedMacro: PeerMacro {
let functionVariableInitDefinitions: String = functionVariableInitDefinitions(functions: functions)
let functionVariableInitAssignments: String = functionVariableInitAssignments(functions: functions)
let functionImplementations: String = functionImplementations(functions: functions)

// Check if the protocol conforms to AnyObject
let requiresClassConformance = protocolDecl.inheritanceClause?.inheritedTypes.contains(where: {
$0.type.description.trimmingCharacters(in: .whitespacesAndNewlines) == "AnyObject"
}) ?? false

let objectType: String = requiresClassConformance ? "class" : "struct"

// Check for associated types in the protocol
var associatedTypes: [String] = []

for member in protocolDecl.memberBlock.members {
if let associatedTypeDecl = member.decl.as(AssociatedTypeDeclSyntax.self) {
let name = associatedTypeDecl.name.text
let constraint = associatedTypeDecl.inheritanceClause?.description.trimmingCharacters(in: .whitespacesAndNewlines)

if let constraint {
associatedTypes.append("\(name)\(constraint)")
} else {
associatedTypes.append(name)
}
}
}

// Construct generic type parameters if there are associated types
let genericValues = if associatedTypes.isEmpty {
""
} else {
"<" + associatedTypes.joined(separator: ", ") + ">"
}

return [
"""
/// Mocked version of \(raw: protocolDecl.name.text)
struct \(raw: mockClassName): \(raw: protocolDecl.name.text) {
\(raw: objectType) \(raw: mockClassName)\(raw: genericValues): \(raw: protocolDecl.name.text) {
// MARK: - \(raw: mockClassName) Variables
\(raw: variablesDefinitions)
Expand Down
12 changes: 8 additions & 4 deletions Tests/MockedTests/MockedTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ final class MockedMacroTests: XCTestCase {
@Mocked
protocol ExampleProtocol: Sendable {
associatedtype ItemType
associatedtype ItemValue: Codable
associatedtype ItemKey: Hashable
var name: String { get set }
var count: Int { get }
Expand All @@ -187,6 +189,8 @@ final class MockedMacroTests: XCTestCase {
expandedSource: """
protocol ExampleProtocol: Sendable {
associatedtype ItemType
associatedtype ItemValue: Codable
associatedtype ItemKey: Hashable
var name: String { get set }
var count: Int { get }
Expand All @@ -201,7 +205,7 @@ final class MockedMacroTests: XCTestCase {
}
/// Mocked version of ExampleProtocol
struct MockedExampleProtocol: ExampleProtocol {
struct MockedExampleProtocol<ItemType, ItemValue: Codable, ItemKey: Hashable>: ExampleProtocol {
// MARK: - MockedExampleProtocol Variables
var name: String
Expand Down Expand Up @@ -308,7 +312,7 @@ final class MockedMacroTests: XCTestCase {
}
@Mocked
protocol CustomProtocol: DefaultProtocol {
protocol CustomProtocol: DefaultProtocol, AnyObject {
func customMethod() -> Bool
}
""",
Expand All @@ -322,12 +326,12 @@ final class MockedMacroTests: XCTestCase {
return "default"
}
}
protocol CustomProtocol: DefaultProtocol {
protocol CustomProtocol: DefaultProtocol, AnyObject {
func customMethod() -> Bool
}
/// Mocked version of CustomProtocol
struct MockedCustomProtocol: CustomProtocol {
class MockedCustomProtocol: CustomProtocol {
// MARK: - MockedCustomProtocol Variables
Expand Down

0 comments on commit 1ec968d

Please sign in to comment.