Skip to content

Commit

Permalink
feat: add MockFunction and Mock macros
Browse files Browse the repository at this point in the history
  • Loading branch information
swift-student committed Jan 24, 2025
1 parent 2c537aa commit e96d8a0
Show file tree
Hide file tree
Showing 22 changed files with 1,097 additions and 536 deletions.
10 changes: 10 additions & 0 deletions Sources/TestDRS/Macros/MockFunctionMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
//
// Created on 6/18/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import Foundation

/// Mocks the body of a function, intended for internal use only.
@attached(body)
public macro _MockFunction() = #externalMacro(module: "TestDRSMacros", type: "MockFunctionMacro")
19 changes: 19 additions & 0 deletions Sources/TestDRS/Macros/MockMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// Created on 6/20/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import Foundation

/// This macro mocks the members of the struct or class that it is applied to.
/// Properties of the attached type should not be initialized inline (using an initializer is allowed, but not required)
/// and functions should not have bodies.
/// In most circumstances the attached type should conform to a protocol,
/// so an easy way to generate the member definitions is to just apply the fixit that pops up after adopting the protocol
/// and then delete the empty bodies of the functions.
///
/// This macro adds conformance to the `StubProviding` and `Spy` protocols. This allows you to stub out each method and expect that methods were called in your tests.
@attached(extension, conformances: Mock)
@attached(member, conformances: Mock, names: named(blackBox), named(stubRegistry))
@attached(memberAttribute)
public macro Mock() = #externalMacro(module: "TestDRSMacros", type: "MockMacro")
5 changes: 2 additions & 3 deletions Sources/TestDRS/Macros/MockPropertyMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import Foundation

/// This macro is intended for use by the `AddMockMacro` to provide custom accessors.
/// It should not be applied manually to properties.
/// Mocks a property, intended for internal use only.
@attached(accessor)
public macro __MockProperty() = #externalMacro(module: "TestDRSMacros", type: "MockPropertyMacro")
public macro _MockProperty() = #externalMacro(module: "TestDRSMacros", type: "MockPropertyMacro")
57 changes: 15 additions & 42 deletions Sources/TestDRSMacros/Mocking/AddMockMacroExpansion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import SwiftSyntaxMacros
public struct AddMockMacro: PeerMacro {

private static let mockProtocolName = "Mock"
private static let macroAttributeText = "@AddMock"

public static func expansion(
of node: SwiftSyntax.AttributeSyntax,
Expand Down Expand Up @@ -75,7 +76,7 @@ public struct AddMockMacro: PeerMacro {
}
classDeclaration.modifiers += protocolDeclaration.modifiers
classDeclaration.attributes = protocolDeclaration.attributes
.filter { $0.trimmedDescription != "@AddMock" }
.filter { $0.trimmedDescription != macroAttributeText }

return classDeclaration
}
Expand All @@ -91,12 +92,18 @@ public struct AddMockMacro: PeerMacro {
}
let className = classDeclaration.name.trimmed.text

return mockClass(
var subclassDeclaration = mockClass(
named: className,
inheritanceClause: .emptyClause.appending([className, mockProtocolName]),
members: classDeclaration.memberBlock.members,
isSubclass: true
)

subclassDeclaration.modifiers += classDeclaration.modifiers
subclassDeclaration.attributes = classDeclaration.attributes
.filter { $0.trimmedDescription != macroAttributeText }

return subclassDeclaration
}

private static func mockStruct(from structDeclaration: StructDeclSyntax) -> StructDeclSyntax {
Expand All @@ -113,7 +120,7 @@ public struct AddMockMacro: PeerMacro {

mockStructDeclaration.modifiers = structDeclaration.modifiers
mockStructDeclaration.attributes = structDeclaration.attributes
.filter { $0.trimmedDescription != "@AddMock" }
.filter { $0.trimmedDescription != macroAttributeText }

return mockStructDeclaration
}
Expand Down Expand Up @@ -200,7 +207,7 @@ public struct AddMockMacro: PeerMacro {
)
}

var attributes = AttributeListSyntax { "@__MockProperty" }
var attributes = AttributeListSyntax { "@_MockProperty" }
attributes += mockProperty.attributes
mockProperty.attributes = attributes

Expand Down Expand Up @@ -282,48 +289,14 @@ public struct AddMockMacro: PeerMacro {
mockMethod.modifiers += [DeclModifierSyntax(name: .keyword(.override))]
}

mockMethod.body = mockMethodBody(for: mockMethod)
var attributes = AttributeListSyntax { "@_MockFunction" }
attributes += mockMethod.attributes
mockMethod.attributes = attributes
mockMethod.body = nil

return mockMethod
}

private static func mockMethodBody(for method: FunctionDeclSyntax) -> CodeBlockSyntax {
CodeBlockSyntax(
leftBrace: .leftBraceToken(),
statements: CodeBlockItemListSyntax {
recordCallSyntax(for: method)
ReturnStmtSyntax(expression: stubOutputSyntax(for: method))
},
rightBrace: .rightBraceToken()
)
}

private static func stubOutputSyntax(for method: FunctionDeclSyntax) -> ExprSyntax {
FunctionCallExprSyntax(callee: ExprSyntax(stringLiteral: method.isThrowing ? "throwingStubOutput" : "stubOutput")) {
if method.hasParameters {
LabeledExprSyntax(
label: "for",
expression: method.inputParameters
)
}
}
.wrappedInTry(method.isThrowing)
}

private static func recordCallSyntax(for method: FunctionDeclSyntax) -> FunctionCallExprSyntax {
FunctionCallExprSyntax(callee: ExprSyntax(stringLiteral: "recordCall")) {
if method.hasParameters {
LabeledExprSyntax(label: "with", expression: method.inputParameters)
}

if let returnClause = method.signature.returnClause {
LabeledExprSyntax(
label: "returning",
expression: ExprSyntax(stringLiteral: "\(returnClause.type.trimmed).self")
)
}
}
}
}

private extension PatternBindingSyntax {
Expand Down
31 changes: 31 additions & 0 deletions Sources/TestDRSMacros/Mocking/MockExpansionDiagnostic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Created on 6/25/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import SwiftDiagnostics

enum MockExpansionDiagnostic: String, DiagnosticMessage {

case invalidType
case propertyWithInitializer
case functionWithBody

var message: String {
switch self {
case .invalidType:
"@Mock can only be applied to classes and structs"
case .propertyWithInitializer:
"@Mock can't be applied to types containing properties that have initializers"
case .functionWithBody:
"@Mock can't be applied to types containing functions that have bodies"
}
}

var diagnosticID: MessageID {
MessageID(domain: Self.moduleDomain, id: "\(Self.self).\(rawValue)")
}

var severity: DiagnosticSeverity { .error }

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// Created on 6/18/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import SwiftDiagnostics

enum MockFunctionExpansionDiagnostic: String, DiagnosticMessage {

case existingBody

var message: String {
switch self {
case .existingBody:
"@_MockFunction can only be applied to function without an existing body as the function will be mocked and any existing body would just be ignored anyway."
}
}

var diagnosticID: MessageID {
MessageID(domain: Self.moduleDomain, id: "\(Self.self).\(rawValue)")
}

var severity: DiagnosticSeverity { .error }

}
72 changes: 72 additions & 0 deletions Sources/TestDRSMacros/Mocking/MockFunctionMacroExpansion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// Created on 6/18/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct MockFunctionMacro: BodyMacro {

public static func expansion(
of node: SwiftSyntax.AttributeSyntax,
providingBodyFor declaration: some SwiftSyntax.DeclSyntaxProtocol & SwiftSyntax.WithOptionalCodeBlockSyntax,
in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.CodeBlockItemSyntax] {
if let method = declaration as? FunctionDeclSyntax {
guard method.body == nil else {
context.diagnose(
Diagnostic(
node: Syntax(node),
message: MockFunctionExpansionDiagnostic.existingBody
)
)
return []
}
return mockMethodBody(for: method).statements.map { $0 }
}

return []
}

static func mockMethodBody(for method: FunctionDeclSyntax) -> CodeBlockSyntax {
CodeBlockSyntax(
leftBrace: .leftBraceToken(),
statements: CodeBlockItemListSyntax {
recordCallSyntax(for: method)
ReturnStmtSyntax(expression: stubOutputSyntax(for: method))
},
rightBrace: .rightBraceToken()
)
}

private static func stubOutputSyntax(for method: FunctionDeclSyntax) -> ExprSyntax {
FunctionCallExprSyntax(callee: ExprSyntax(stringLiteral: method.isThrowing ? "throwingStubOutput" : "stubOutput")) {
if method.hasParameters {
LabeledExprSyntax(
label: "for",
expression: method.inputParameters
)
}
}
.wrappedInTry(method.isThrowing)
}

private static func recordCallSyntax(for method: FunctionDeclSyntax) -> FunctionCallExprSyntax {
FunctionCallExprSyntax(callee: ExprSyntax(stringLiteral: "recordCall")) {
if method.hasParameters {
LabeledExprSyntax(label: "with", expression: method.inputParameters)
}

if let returnClause = method.signature.returnClause {
LabeledExprSyntax(
label: "returning",
expression: ExprSyntax(stringLiteral: "\(returnClause.type.trimmed).self")
)
}
}
}

}
84 changes: 84 additions & 0 deletions Sources/TestDRSMacros/Mocking/MockMacroExpansion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// Created on 6/20/24.
// Copyright © 2024 Turo Open Source. All rights reserved.
//

import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct MockMacro: ExtensionMacro, MemberMacro, MemberAttributeMacro {

public static func expansion(
of node: SwiftSyntax.AttributeSyntax,
attachedTo declaration: some SwiftSyntax.DeclGroupSyntax,
providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol,
conformingTo protocols: [SwiftSyntax.TypeSyntax],
in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.ExtensionDeclSyntax] {
guard declarationTypeIsSupported(declaration) else {
context.diagnose(
Diagnostic(
node: Syntax(node),
message: MockExpansionDiagnostic.invalidType
)
)
return []
}

let inheritedType = InheritedTypeSyntax(type: TypeSyntax(stringLiteral: "Mock"))
let typeList = InheritedTypeListSyntax(arrayLiteral: inheritedType)
let inheritanceClause = InheritanceClauseSyntax(inheritedTypes: typeList)

return [
ExtensionDeclSyntax(extendedType: type, inheritanceClause: inheritanceClause) {}
]
}

public static func expansion(
of node: SwiftSyntax.AttributeSyntax,
attachedTo declaration: some SwiftSyntax.DeclGroupSyntax,
providingAttributesFor member: some SwiftSyntax.DeclSyntaxProtocol,
in context: some SwiftSyntaxMacros.MacroExpansionContext
) throws -> [SwiftSyntax.AttributeSyntax] {
guard declarationTypeIsSupported(declaration) else { return [] }

if let variable = member.as(VariableDeclSyntax.self) {
guard variable.bindings.allSatisfy({ $0.initializer == nil }) else {
context.diagnose(
Diagnostic(
node: Syntax(variable),
message: MockExpansionDiagnostic.propertyWithInitializer
)
)
return []
}
return ["@_MockProperty"]
} else if let function = member.as(FunctionDeclSyntax.self) {
guard function.body == nil else {
context.diagnose(
Diagnostic(
node: Syntax(function),
message: MockExpansionDiagnostic.functionWithBody
)
)
return []
}
return ["@_MockFunction"]
}

return []
}

public static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext) throws -> [DeclSyntax] {
guard declarationTypeIsSupported(declaration) else { return [] }

return ["let blackBox = BlackBox()", "let stubRegistry = StubRegistry()"]
}

private static func declarationTypeIsSupported(_ declaration: some DeclGroupSyntax) -> Bool {
declaration.is(ClassDeclSyntax.self) || declaration.is(StructDeclSyntax.self)
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ enum MockPropertyExpansionDiagnostic: String, DiagnosticMessage {
var message: String {
switch self {
case .invalidType:
"@__MockProperty can only be applied to variable declarations"
"@_MockProperty can only be applied to variable declarations"
case .immutable:
"@__MockProperty can only be applied to mutable variable declarations"
"@_MockProperty can only be applied to mutable variable declarations"
case .existingAccessor:
"@__MockProperty can only be applied to variables without an existing accessor block"
"@_MockProperty can only be applied to variables without an existing accessor block"
}
}

Expand Down
2 changes: 2 additions & 0 deletions Sources/TestDRSMacros/TestDRSMacrosPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import SwiftSyntaxMacros
struct TestDRSMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
AddMockMacro.self,
MockMacro.self,
MockFunctionMacro.self,
MockPropertyMacro.self,
SetStubReturningOutputMacro.self,
SetStubThrowingErrorMacro.self,
Expand Down
Loading

0 comments on commit e96d8a0

Please sign in to comment.