From 2a26499b6c19be158a840bd77821c1284aa8c547 Mon Sep 17 00:00:00 2001 From: Leif Date: Sat, 2 Nov 2024 12:00:59 -0600 Subject: [PATCH] init --- README.md | 74 ++++++ Sources/Mocked/Mocked.swift | 55 ++++- Sources/MockedClient/main.swift | 37 ++- Sources/MockedMacros/MockedMacro.swift | 263 ++++++++++++++++++++-- Sources/MockedMacros/Types/Function.swift | 62 +++++ Sources/MockedMacros/Types/Variable.swift | 8 + Tests/MockedTests/MockedTests.swift | 160 +++++++++++-- 7 files changed, 611 insertions(+), 48 deletions(-) create mode 100644 README.md create mode 100644 Sources/MockedMacros/Types/Function.swift create mode 100644 Sources/MockedMacros/Types/Variable.swift diff --git a/README.md b/README.md new file mode 100644 index 0000000..fb4be03 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# Mocked + +Mocked is a Swift compiler macro that automatically generates mock implementations for protocols. This can be especially useful for unit testing, allowing you to easily create mock objects to verify behavior and interactions in your tests. + +## Features + +- **Automatic Mock Generation**: Simply annotate your protocol with `@Mocked`, and a mock implementation will be generated. +- **Supports Properties and Methods**: Generates mock versions of properties and methods, including `async` and `throws` variants. +- **Configurable Behavior**: Easily override behavior by providing closures during initialization of the mock. + +## Installation + +To use Mocked in your project, add it as a dependency using Swift Package Manager. Add the following to your `Package.swift` file: + +```swift +.package(url: "https://github.com/0xLeif/Mocked.git", from: "1.0.0") +``` + +And add it as a dependency to your target: + +```swift +.target( + name: "YourTargetName", + dependencies: [ + "Mocked" + ] +) +``` + +## Usage + +To generate a mock for a protocol, simply annotate it with `@Mocked`: + +```swift +@Mocked +protocol MyProtocol { + var title: String { get set } + func performAction() -> Void +} +``` + +This will generate a mock struct named `MockedMyProtocol` that conforms to `MyProtocol`. You can use this mock in your unit tests to validate behavior. + +### Example + +```swift +@Mocked +protocol MyProtocol { + var title: String { get set } + func performAction() -> Void +} + +let mock = MockedMyProtocol( + title: "Test Title", + performAction: { print("Action performed") } +) + +mock.performAction() // Output: "Action performed" +``` + +### 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. +- **Unimplemented Methods**: Any method that is not overridden will call `fatalError()` if invoked. Ensure all required methods are implemented when using the generated mock. +- **Async and Throwing Methods**: The generated mocks handle `async` and `throws` methods appropriately, but be sure to provide closures that match the method signatures. + +## Contributing + +Contributions are welcome! If you have suggestions, issues, or improvements, feel free to open a pull request or issue on the [GitHub repository](https://github.com/0xLeif/Mocked). + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more information. + diff --git a/Sources/Mocked/Mocked.swift b/Sources/Mocked/Mocked.swift index 09e15fb..0b3e010 100644 --- a/Sources/Mocked/Mocked.swift +++ b/Sources/Mocked/Mocked.swift @@ -1,11 +1,48 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -/// A macro that produces both a value and a string containing the -/// source code that generated the value. For example, +/// The `Mocked` macro is used to automatically generate a mocked implementation of a protocol. /// -/// #stringify(x + y) +/// This macro attaches a peer struct prefixed with `Mocked` that provides implementations of all the methods and properties defined in the protocol. /// -/// produces a tuple `(x + y, "x + y")`. -@freestanding(expression) -public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "MockedMacros", type: "StringifyMacro") +/// # 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() +/// } +/// } +/// ``` +@attached(peer, names: prefixed(Mocked)) +public macro Mocked() = #externalMacro( + module: "MockedMacros", + type: "MockedMacro" +) diff --git a/Sources/MockedClient/main.swift b/Sources/MockedClient/main.swift index 12f3c24..98f06da 100644 --- a/Sources/MockedClient/main.swift +++ b/Sources/MockedClient/main.swift @@ -1,8 +1,37 @@ import Mocked -let a = 17 -let b = 25 +protocol ThisBreaksShit { + var broken: String { get } +} -let (result, code) = #stringify(a + b) +@Mocked +protocol SomeParameter: Sendable { + var title: String { get set } + var description: String { get } -print("The value \(result) was produced by the code \"\(code)\"") + func someMethod() + func someMethod(parameter: Int) + func someMethod(with parameter: Int) + + func someOtherMethod() throws -> String + func someOtherMethod() async throws -> String + + func someAsyncMethod() async -> String + + func someOptionalMethod() -> String? +} + +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) + +} diff --git a/Sources/MockedMacros/MockedMacro.swift b/Sources/MockedMacros/MockedMacro.swift index eadebb4..54a18ab 100644 --- a/Sources/MockedMacros/MockedMacro.swift +++ b/Sources/MockedMacros/MockedMacro.swift @@ -3,31 +3,262 @@ import SwiftSyntax import SwiftSyntaxBuilder import SwiftSyntaxMacros -/// Implementation of the `stringify` macro, which takes an expression -/// of any type and produces a tuple containing the value of that expression -/// and the source code that produced the value. For example +/// The `MockedMacro` is a peer macro that generates a mocked implementation of a given protocol. /// -/// #stringify(x + y) -/// -/// will expand to -/// -/// (x + y, "x + y") -public struct StringifyMacro: ExpressionMacro { +/// This macro can only be applied to protocols, and it creates a struct with mock implementations +/// of the protocol's methods and properties. The generated struct is named `Mocked`. +public struct MockedMacro: PeerMacro { public static func expansion( - of node: some FreestandingMacroExpansionSyntax, + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext - ) -> ExprSyntax { - guard let argument = node.arguments.first?.expression else { - fatalError("compiler bug: the macro does not have any arguments") + ) throws -> [DeclSyntax] { + // Ensure the macro is applied to a protocol + guard let protocolDecl = declaration.as(ProtocolDeclSyntax.self) else { + fatalError("MockedMacro can only be applied to protocols") + } + + let mockClassName = "Mocked\(protocolDecl.name.text)" + + let members = protocolDecl.memberBlock.members + + // Variables + + let variables: [Variable] = variableBuilder(members: members) + + let variablesDefinitions: String = variableDefinitions(variables: variables) + let variablesInitDefinitions: String = variablesInitDefinitions(variables: variables) + let variablesInitAssignments: String = variablesInitAssignments(variables: variables) + + + // Functions + + let functions: [Function] = functionBuilder( + protocolDecl: protocolDecl, + members: members + ) + + let functionVariableDefinitions: String = functionVariableDefinitions(functions: functions) + let functionVariableInitDefinitions: String = functionVariableInitDefinitions(functions: functions) + let functionVariableInitAssignments: String = functionVariableInitAssignments(functions: functions) + let functionImplementations: String = functionImplementations(functions: functions) + + return [ + """ + /// Mocked version of \(raw: protocolDecl.name.text) + struct \(raw: mockClassName): \(raw: protocolDecl.name.text) { + // MARK: - \(raw: mockClassName) Variables + + \(raw: variablesDefinitions) + + // MARK: - \(raw: mockClassName) Function Overrides + + \(raw: functionVariableDefinitions) + + // MARK: - \(raw: mockClassName) init + + init( + \(raw: variablesInitDefinitions) + \(raw: functionVariableInitDefinitions) + ) { + \(raw: variablesInitAssignments) + \(raw: functionVariableInitAssignments) + } + + + // MARK: - \(raw: mockClassName) Functions + + \(raw: functionImplementations) + } + """ + ] + } + + // MARK: - Variable helpers + + private static func variableBuilder(members: MemberBlockItemListSyntax) -> [Variable] { + members.compactMap { member in + guard + let variable = member.decl.as(VariableDeclSyntax.self) + else { return nil } + + guard let binding = variable.bindings.first else { + return nil + } + + guard let type = binding.typeAnnotation?.type else { + fatalError("\(String(describing: binding.initializer?.syntaxNodeType))") + } + + let name = binding.pattern + + return Variable( + name: "\(name)", + type: "\(type)" + ) + } + } + + private static func variableDefinitions( + variables: [Variable] + ) -> String { + variables + .map { "var \($0.declaration)" } + .joined(separator: "\n") + } + + private static func variablesInitDefinitions( + variables: [Variable] + ) -> String { + variables + .map { "\($0.declaration)," } + .joined(separator: "\n") + } + + private static func variablesInitAssignments( + variables: [Variable] + ) -> String { + variables + .map { "self.\($0.name) = \($0.name)" } + .joined(separator: "\n") + } + + // MARK: - Function helpers + + private static func functionBuilder( + protocolDecl: ProtocolDeclSyntax, + members: MemberBlockItemListSyntax + ) -> [Function] { + let inheritsSendable = protocolDecl.inheritanceClause?.inheritedTypes.contains { inheritedType in + inheritedType.type.description.trimmingCharacters(in: .whitespacesAndNewlines) == "Sendable" + } ?? false + + return members.compactMap { member in + guard + let function = member.decl.as(FunctionDeclSyntax.self) + else { return nil } + + let name = function.name.text + var parameters: [Variable] = [] + let returnType = function.signature.returnClause?.type ?? "Void" + + let isAsync = function.signature.effectSpecifiers?.asyncSpecifier != nil + let canThrow = function.signature.effectSpecifiers?.throwsClause?.throwsSpecifier != nil + + for parameter in function.signature.parameterClause.parameters { + let parameterName = parameter.firstName.text + let parameterType = parameter.type.description.trimmingCharacters(in: .whitespacesAndNewlines) + + parameters.append( + Variable( + name: parameterName, + type: parameterType + ) + ) + } + + return Function( + name: "\(name)", + parameters: parameters, + isSendable: inheritsSendable, + isAsync: isAsync, + canThrow: canThrow, + returnType: "\(returnType)" + ) } + } + + private static func functionVariableDefinitions( + functions: [Function] + ) -> String { + functions + .map { "private let \($0.overrideClosure)" } + .joined(separator: "\n") + } + + private static func functionVariableInitDefinitions( + functions: [Function] + ) -> String { + functions + .map { "\($0.closure) = nil" } + .joined(separator: ",\n") + } + + private static func functionVariableInitAssignments( + functions: [Function] + ) -> String { + functions + .map { "self.\($0.overrideName) = \($0.uniqueName)" } + .joined(separator: "\n") + } + + private static func functionImplementations( + functions: [Function] + ) -> String { + functions.map { function in + let parameters: String = function.parameters + .map { function in + "\(function.name): \(function.type)" + } + .joined(separator: ", ") + let parameterUsage: String = function.parameters + .map(\.name) + .joined(separator: ", ") + - return "(\(argument), \(literal: argument.description))" + let effectSignature: String = if function.canThrow && function.isAsync { + "async throws " + } else if function.canThrow { + "throws " + } else if function.isAsync { + "async " + } else { + "" + } + + let callSignature: String = if function.canThrow && function.isAsync { + "try await " + } else if function.canThrow { + "try " + } else if function.isAsync { + "await " + } else { + "" + } + + if parameters.isEmpty { + return """ + func \(function.name)() \(effectSignature)-> \(function.returnType ?? "Void") { + guard let \(function.overrideName) else { + fatalError("Mocked \(function.closure) was not implemented!") + } + + return \(callSignature)\(function.overrideName)() + } + """ + } else { + return """ + func \(function.name)( + \(parameters) + ) \(effectSignature)-> \(function.returnType ?? "Void") { + guard let \(function.overrideName) else { + fatalError("Mocked \(function.closure) was not implemented!") + } + + return \(callSignature)\(function.overrideName)( + \(parameterUsage) + ) + } + """ + } + } + .joined(separator: "\n\n") } } @main -struct MockedPlugin: CompilerPlugin { +struct Plugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ - StringifyMacro.self, + MockedMacro.self, ] } diff --git a/Sources/MockedMacros/Types/Function.swift b/Sources/MockedMacros/Types/Function.swift new file mode 100644 index 0000000..3ca7a10 --- /dev/null +++ b/Sources/MockedMacros/Types/Function.swift @@ -0,0 +1,62 @@ +struct Function { + let name: String + let parameters: [Variable] + let isSendable: Bool + let isAsync: Bool + let canThrow: Bool + let returnType: String? + + var uniqueName: String { + unique(name: name) + } + + var overrideName: String { + unique(name: "\(name)Override") + } + + var closure: String { + closure(name: uniqueName) + } + + var overrideClosure: String { + closure(name: overrideName) + } + + private func closure(name: String) -> String { + let parameters = parameters + .map { parameter in + "_ \(parameter.declaration)" + } + .joined(separator: ", ") + + let effectSignature: String = if canThrow && isAsync { + "async throws" + } else if canThrow { + "throws" + } else if isAsync { + "async" + } else { + "" + } + + return if effectSignature.isEmpty { + "\(name): (\(isSendable ? "@Sendable " : "")(\(parameters)) -> \(returnType ?? "Void"))?" + } else { + "\(name): (\(isSendable ? "@Sendable " : "")(\(parameters)) \(effectSignature) -> \(returnType ?? "Void"))?" + } + } + + private func unique(name: String) -> String { + let effectSignature: String = if canThrow && isAsync { + "AsyncThrows" + } else if canThrow { + "Throws" + } else if isAsync { + "Async" + } else { + "" + } + + return "\(name)\(effectSignature)\(parameters.map { $0.name.capitalized }.joined(separator: ""))" + } +} diff --git a/Sources/MockedMacros/Types/Variable.swift b/Sources/MockedMacros/Types/Variable.swift new file mode 100644 index 0000000..8ba1dc1 --- /dev/null +++ b/Sources/MockedMacros/Types/Variable.swift @@ -0,0 +1,8 @@ +struct Variable { + let name: String + let type: String + + var declaration: String { + "\(name): \(type)" + } +} diff --git a/Tests/MockedTests/MockedTests.swift b/Tests/MockedTests/MockedTests.swift index d7a12c7..4fd89f9 100644 --- a/Tests/MockedTests/MockedTests.swift +++ b/Tests/MockedTests/MockedTests.swift @@ -9,7 +9,7 @@ import XCTest import MockedMacros let testMacros: [String: Macro.Type] = [ - "stringify": StringifyMacro.self, + "Mocked": MockedMacro.self, ] #endif @@ -18,27 +18,149 @@ final class MockedTests: XCTestCase { #if canImport(MockedMacros) assertMacroExpansion( """ - #stringify(a + b) + @Mocked + protocol SomeParameter: Sendable { + var title: String { get set } + var description: String { get } + + func someMethod() + func someMethod(parameter: Int) + func someMethod(with parameter: Int) + + func someOtherMethod() throws -> String + func someOtherMethod() async throws -> String + + func someAsyncMethod() async -> String + + func someOptionalMethod() -> String? + } """, expandedSource: """ - (a + b, "a + b") - """, - macros: testMacros - ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif - } + protocol SomeParameter: Sendable { + var title: String { get set } + var description: String { get } - func testMacroWithStringLiteral() throws { - #if canImport(MockedMacros) - assertMacroExpansion( - #""" - #stringify("Hello, \(name)") - """#, - expandedSource: #""" - ("Hello, \(name)", #""Hello, \(name)""#) - """#, + func someMethod() + func someMethod(parameter: Int) + func someMethod(with parameter: Int) + + func someOtherMethod() throws -> String + func someOtherMethod() async throws -> String + + func someAsyncMethod() async -> String + + func someOptionalMethod() -> String? + } + + /// Mocked version of SomeParameter + struct MockedSomeParameter: SomeParameter { + // MARK: - MockedSomeParameter Variables + + var title: String + var description: String + + // MARK: - MockedSomeParameter Function Overrides + + private let someMethodOverride: (@Sendable () -> Void)? + private let someMethodOverrideParameter: (@Sendable (_ parameter: Int) -> Void)? + private let someMethodOverrideWith: (@Sendable (_ with: Int) -> Void)? + private let someOtherMethodOverrideThrows: (@Sendable () throws -> String)? + private let someOtherMethodOverrideAsyncThrows: (@Sendable () async throws -> String)? + private let someAsyncMethodOverrideAsync: (@Sendable () async -> String)? + private let someOptionalMethodOverride: (@Sendable () -> String?)? + + // MARK: - MockedSomeParameter init + + init( + title: String , + description: String , + someMethod: (@Sendable () -> Void)? = nil, + someMethodParameter: (@Sendable (_ parameter: Int) -> Void)? = nil, + someMethodWith: (@Sendable (_ with: Int) -> Void)? = nil, + someOtherMethodThrows: (@Sendable () throws -> String)? = nil, + someOtherMethodAsyncThrows: (@Sendable () async throws -> String)? = nil, + someAsyncMethodAsync: (@Sendable () async -> String)? = nil, + someOptionalMethod: (@Sendable () -> String?)? = nil + ) { + self.title = title + self.description = description + self.someMethodOverride = someMethod + self.someMethodOverrideParameter = someMethodParameter + self.someMethodOverrideWith = someMethodWith + self.someOtherMethodOverrideThrows = someOtherMethodThrows + self.someOtherMethodOverrideAsyncThrows = someOtherMethodAsyncThrows + self.someAsyncMethodOverrideAsync = someAsyncMethodAsync + self.someOptionalMethodOverride = someOptionalMethod + } + + + // MARK: - MockedSomeParameter Functions + + func someMethod() -> Void { + guard let someMethodOverride else { + fatalError("Mocked someMethod: (@Sendable () -> Void)? was not implemented!") + } + + return someMethodOverride() + } + + func someMethod( + parameter: Int + ) -> Void { + guard let someMethodOverrideParameter else { + fatalError("Mocked someMethodParameter: (@Sendable (_ parameter: Int) -> Void)? was not implemented!") + } + + return someMethodOverrideParameter( + parameter + ) + } + + func someMethod( + with: Int + ) -> Void { + guard let someMethodOverrideWith else { + fatalError("Mocked someMethodWith: (@Sendable (_ with: Int) -> Void)? was not implemented!") + } + + return someMethodOverrideWith( + with + ) + } + + func someOtherMethod() throws -> String { + guard let someOtherMethodOverrideThrows else { + fatalError("Mocked someOtherMethodThrows: (@Sendable () throws -> String)? was not implemented!") + } + + return try someOtherMethodOverrideThrows() + } + + func someOtherMethod() async throws -> String { + guard let someOtherMethodOverrideAsyncThrows else { + fatalError("Mocked someOtherMethodAsyncThrows: (@Sendable () async throws -> String)? was not implemented!") + } + + return try await someOtherMethodOverrideAsyncThrows() + } + + func someAsyncMethod() async -> String { + guard let someAsyncMethodOverrideAsync else { + fatalError("Mocked someAsyncMethodAsync: (@Sendable () async -> String)? was not implemented!") + } + + return await someAsyncMethodOverrideAsync() + } + + func someOptionalMethod() -> String? { + guard let someOptionalMethodOverride else { + fatalError("Mocked someOptionalMethod: (@Sendable () -> String?)? was not implemented!") + } + + return someOptionalMethodOverride() + } + } + """, macros: testMacros ) #else