Skip to content

Commit 1d1bcdc

Browse files
authored
Better test verification (#304)
* Show fixture differences and use source location * Add custom arguments to Fixture macro * Fix indent strategy * Fix failed tests * Add testableImport and use Fixture macro in TestableImportStatementsTests * Avoid compiler crash on swift 5.10.1 * Drop SendableTests on swift 5.10 cannot avoid compiler crash
1 parent 80e5343 commit 1d1bcdc

12 files changed

Lines changed: 272 additions & 145 deletions

File tree

Sources/MockoloTestSupportMacros/Fixture.swift

Lines changed: 85 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,66 @@ import SwiftSyntax
33
import SwiftSyntaxMacros
44

55
struct Fixture: MemberMacro {
6+
struct Arguments {
7+
var imports: [String]?
8+
var testableImports: [String]?
9+
var includesConcurrencyHelpers: Bool = false
10+
}
11+
12+
static func extractArguments(from attribute: AttributeSyntax) throws -> Arguments {
13+
var result = Arguments()
14+
guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else {
15+
return result
16+
}
17+
18+
for argument in arguments {
19+
if argument.label?.text == "includesConcurrencyHelpers",
20+
let literal = argument.expression.as(BooleanLiteralExprSyntax.self) {
21+
result.includesConcurrencyHelpers = try extract(booleanLiteral: literal)
22+
}
23+
if argument.label?.text == "imports",
24+
let expr = argument.expression.as(ArrayExprSyntax.self) {
25+
result.imports = try extract(stringLiteralArray: expr)
26+
}
27+
if argument.label?.text == "testableImports",
28+
let expr = argument.expression.as(ArrayExprSyntax.self) {
29+
result.testableImports = try extract(stringLiteralArray: expr)
30+
}
31+
}
32+
if result.includesConcurrencyHelpers && !(result.imports ?? []).contains("Foundation") {
33+
result.imports = CollectionOfOne("Foundation") + (result.imports ?? [])
34+
}
35+
return result
36+
37+
func extract(stringLiteralArray: ArrayExprSyntax) throws -> [String] {
38+
return try stringLiteralArray.elements.map { element in
39+
guard let literal = element.expression.as(StringLiteralExprSyntax.self) else {
40+
throw MessageError("Must be string literal.")
41+
}
42+
guard literal.segments.count == 1 else {
43+
throw MessageError("Cannot use string interpolation.")
44+
}
45+
return literal.segments.description
46+
}
47+
}
48+
49+
func extract(booleanLiteral: BooleanLiteralExprSyntax) throws -> Bool {
50+
return switch booleanLiteral.literal.text {
51+
case "true": true
52+
case "false": false
53+
default: throw MessageError("Unexpected literal.")
54+
}
55+
}
56+
}
57+
658
static func expansion(
759
of node: AttributeSyntax,
860
providingMembersOf declaration: some DeclGroupSyntax,
961
conformingTo protocols: [TypeSyntax],
1062
in context: some MacroExpansionContext
1163
) throws -> [DeclSyntax] {
64+
let argument = try extractArguments(from: node)
65+
1266
let baseItems = declaration.memberBlock.members.filter { (item: MemberBlockItemSyntax) in
1367
if let decl = item.decl.asProtocol(WithAttributesSyntax.self) {
1468
let isFixtureAnnotated = decl.attributes.contains { (attr: AttributeListSyntax.Element) in
@@ -19,22 +73,43 @@ struct Fixture: MemberMacro {
1973
return true
2074
}
2175

76+
let baseIndent = Trivia(pieces: node.leadingTrivia.filter(\.isSpaceOrTab))
2277
let indent = BasicFormat.inferIndentation(of: declaration) ?? .spaces(4)
23-
let sourceContent = baseItems.trimmedDescription(matching: \.isNewline)
78+
var sourceContent = baseItems.trimmedDescription(matching: \.isNewline)
79+
80+
if let testableImports = argument.testableImports {
81+
sourceContent = testableImports.map {
82+
"\(baseIndent)\(indent)@testable import \($0)\n"
83+
}.joined() + "\n" + sourceContent
84+
}
85+
if let imports = argument.imports {
86+
sourceContent = imports.map {
87+
"\(baseIndent)\(indent)import \($0)\n"
88+
}.joined() + "\n" + sourceContent
89+
}
2490

25-
let varDecl = VariableDeclSyntax(
91+
var _sourceInitExpr: ExprSyntax = ExprSyntax(StringLiteralExprSyntax(
92+
multilineContent: sourceContent,
93+
endIndent: baseIndent + indent
94+
))
95+
if argument.includesConcurrencyHelpers {
96+
_sourceInitExpr = ExprSyntax(
97+
InfixOperatorExprSyntax(
98+
leftOperand: _sourceInitExpr,
99+
operator: BinaryOperatorExprSyntax(operator: .binaryOperator("+")),
100+
rightOperand: #""\n\n" + concurrencyHelpers._generatedSource"# as ExprSyntax
101+
)
102+
)
103+
}
104+
105+
return [DeclSyntax(VariableDeclSyntax(
26106
modifiers: [.init(name: .keyword(.static))],
27107
.let,
28108
name: "_source",
29-
initializer: .init(
30-
value: StringLiteralExprSyntax(
31-
multilineContent: sourceContent,
32-
endIndent: Trivia(pieces: node.leadingTrivia.filter(\.isSpaceOrTab)) + indent
33-
)
109+
initializer: InitializerClauseSyntax(
110+
value: _sourceInitExpr
34111
)
35-
)
36-
37-
return [DeclSyntax(varDecl)]
112+
))]
38113
}
39114
}
40115

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
struct MessageError: Error, CustomStringConvertible {
2+
init(_ description: String) {
3+
self.description = description
4+
}
5+
var description: String
6+
}

Tests/ConcurrencyHelpers.swift

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -64,20 +64,29 @@ final class ConcurrencyHelpersTests: MockoloTestCase {
6464

6565
verify(
6666
srcContent: src,
67-
dstContent: "import Foundation"
68-
)
67+
dstContent: """
68+
import Foundation
6969
70-
verify(
71-
srcContent: src,
72-
dstContent: concurrencyHelpers._source.split(separator: "\n").map { line in
73-
if ["func", "final class", "struct"].contains(where: {
74-
line.starts(with: $0)
75-
}) {
76-
return "fileprivate \(line)"
77-
} else {
78-
return String(line)
79-
}
80-
}.joined(separator: "\n")
70+
final class PMock: P, @unchecked Sendable {
71+
init() { }
72+
}
73+
74+
\(concurrencyHelpers._generatedSource)
75+
"""
8176
)
8277
}
8378
}
79+
80+
extension concurrencyHelpers {
81+
static var _generatedSource: String {
82+
_source.split(separator: "\n").map { line in
83+
if ["func", "final class", "struct"].contains(where: {
84+
line.starts(with: $0)
85+
}) {
86+
return "fileprivate \(line)"
87+
} else {
88+
return String(line)
89+
}
90+
}.joined(separator: "\n")
91+
}
92+
}

Tests/MockoloTestCase.swift

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class MockoloTestCase: XCTestCase {
5454
}
5555
}
5656

57-
func verify(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, useMockObservable: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false) {
57+
func verify(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, file: StaticString = #filePath, line: UInt = #line) {
5858
let dstFilePath = dstFilePath ?? defaultDstFilePath
5959
var mockList: [String]?
6060
if let mock = mockContent {
@@ -63,10 +63,10 @@ class MockoloTestCase: XCTestCase {
6363
}
6464
mockList?.append(mock)
6565
}
66-
try? verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, useMockObservable: useMockObservable, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues)
66+
try? verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line)
6767
}
6868

69-
func verifyThrows(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, useMockObservable: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, errorHandler: (Error) -> Void = { _ in }) {
69+
func verifyThrows(srcContent: String, mockContent: String? = nil, dstContent: String, header: String = "", declType: FindTargetDeclType = .protocolType, useTemplateFunc: Bool = false, testableImports: [String] = [], allowSetCallCount: Bool = false, mockFinal: Bool = false, enableFuncArgsHistory: Bool = false, dstFilePath: String? = nil, concurrencyLimit: Int? = 1, disableCombineDefaultValues: Bool = false, errorHandler: (Error) -> Void = { _ in }, file: StaticString = #filePath, line: UInt = #line) {
7070
let dstFilePath = dstFilePath ?? defaultDstFilePath
7171
var mockList: [String]?
7272
if let mock = mockContent {
@@ -76,13 +76,13 @@ class MockoloTestCase: XCTestCase {
7676
mockList?.append(mock)
7777
}
7878
XCTAssertThrowsError(
79-
try verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, useMockObservable: useMockObservable, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues),
79+
try verify(srcContents: [srcContent], mockContents: mockList, dstContent: dstContent, header: header, declType: declType, useTemplateFunc: useTemplateFunc, testableImports: testableImports, allowSetCallCount: allowSetCallCount, mockFinal: mockFinal, enableFuncArgsHistory: enableFuncArgsHistory, dstFilePath: dstFilePath, concurrencyLimit: concurrencyLimit, disableCombineDefaultValues: disableCombineDefaultValues, file: file, line: line),
8080
"No error was thrown",
8181
errorHandler
8282
)
8383
}
8484

85-
func verify(srcContents: [String], mockContents: [String]?, dstContent: String, header: String, declType: FindTargetDeclType, useTemplateFunc: Bool, useMockObservable: Bool, testableImports: [String] = [], allowSetCallCount: Bool, mockFinal: Bool, enableFuncArgsHistory: Bool, dstFilePath: String, concurrencyLimit: Int?, disableCombineDefaultValues: Bool) throws {
85+
func verify(srcContents: [String], mockContents: [String]?, dstContent: String, header: String, declType: FindTargetDeclType, useTemplateFunc: Bool, testableImports: [String] = [], allowSetCallCount: Bool, mockFinal: Bool, enableFuncArgsHistory: Bool, dstFilePath: String, concurrencyLimit: Int?, disableCombineDefaultValues: Bool, file: StaticString = #filePath, line: UInt = #line) throws {
8686
var index = 0
8787
srcFilePathsCount = srcContents.count
8888
mockFilePathsCount = mockContents?.count ?? 0
@@ -91,7 +91,7 @@ class MockoloTestCase: XCTestCase {
9191
if index < srcContents.count {
9292
let srcCreated = FileManager.default.createFile(atPath: srcFilePaths[index], contents: src.data(using: .utf8), attributes: nil)
9393
index += 1
94-
XCTAssert(srcCreated)
94+
XCTAssert(srcCreated, file: file, line: line)
9595
}
9696
}
9797

@@ -108,7 +108,7 @@ class MockoloTestCase: XCTestCase {
108108
\(macroEnd)
109109
"""
110110
let mockCreated = FileManager.default.createFile(atPath: mockFilePaths[index], contents: formattedMockContent.data(using: .utf8), attributes: nil)
111-
XCTAssert(mockCreated)
111+
XCTAssert(mockCreated, file: file, line: line)
112112
}
113113
}
114114

@@ -134,11 +134,47 @@ class MockoloTestCase: XCTestCase {
134134
concurrencyLimit: concurrencyLimit)
135135
let output = (try? String(contentsOf: URL(fileURLWithPath: self.defaultDstFilePath), encoding: .utf8)) ?? ""
136136
let outputContents = output.components(separatedBy: .newlines).filter { !$0.isEmpty && !$0.allSatisfy(\.isWhitespace) }
137-
let fixtureContents = dstContent.components(separatedBy: .newlines).filter { !$0.isEmpty && !$0.allSatisfy(\.isWhitespace) }
137+
let fixtureContents = """
138+
\(headerStr)
139+
\(macroStart)
140+
\(dstContent)
141+
\(macroEnd)
142+
""".components(separatedBy: .newlines).filter { !$0.isEmpty && !$0.allSatisfy(\.isWhitespace) }
138143
if fixtureContents.isEmpty {
139-
throw XCTSkip("empty fixture")
144+
throw XCTSkip("empty fixture", file: file, line: line)
145+
}
146+
147+
let diff = lightDiff(old: outputContents, new: fixtureContents)
148+
if !diff.isEmpty {
149+
print("output:\n\(output)")
150+
XCTFail("diff:\n" + "\(diff.joined(separator: "\n"))", file: file, line: line)
151+
}
152+
}
153+
}
154+
155+
func lightDiff(old: [String], new: [String]) -> [String] {
156+
return new.difference(from: old)
157+
.sorted { l, r in
158+
return l.offset < r.offset
159+
}
160+
.map { change in
161+
switch change {
162+
case .remove(_, let element, _):
163+
return "- \(element)"
164+
case .insert(_, let element, _):
165+
return "+ \(element)"
166+
}
167+
}
168+
}
169+
170+
extension CollectionDifference.Change {
171+
var offset: Int {
172+
switch self {
173+
case .insert(let offset, _, _):
174+
return offset
175+
case .remove(let offset, _, _):
176+
return offset
140177
}
141-
XCTAssert(outputContents.contains(subArray: fixtureContents), "output:\n" + output)
142178
}
143179
}
144180

Tests/TestFuncs/TestBasicFuncs/FixtureNonSimpleFuncs.swift

Lines changed: 37 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -329,24 +329,23 @@ import MockoloFramework
329329
}
330330

331331
@Fixture enum expected {
332-
// class NonSimpleFuncsMock: NonSimpleFuncs {
333-
// init() { }
334-
//
335-
//
336-
// private(set) var barCallCount = 0
337-
// var barHandler: ((String, Int..., [Double]) -> Float?)?
338-
// func bar(_ arg: String, x: Int..., y: [Double]) -> Float? {
339-
// barCallCount += 1
340-
// if let barHandler = barHandler {
341-
// return barHandler(arg, x, y)
342-
// }
343-
// return nil
344-
// }
345-
// }
332+
class NonSimpleFuncsMock: NonSimpleFuncs {
333+
init() { }
334+
335+
336+
private(set) var barCallCount = 0
337+
var barHandler: ((String, [Int], [Double]) -> Float?)?
338+
func bar(_ arg: String, x: Int..., y: [Double]) -> Float? {
339+
barCallCount += 1
340+
if let barHandler = barHandler {
341+
return barHandler(arg, x, y)
342+
}
343+
return nil
344+
}
345+
}
346346
}
347347
}
348348

349-
350349
@Fixture enum autoclosureArgFunc {
351350
/// @mockable
352351
protocol NonSimpleFuncs {
@@ -371,40 +370,35 @@ import MockoloFramework
371370
}
372371
}
373372

374-
@Fixture enum closureArgFunc {
373+
#if compiler(>=6.0)
374+
@Fixture enum rethrowsFunc {
375375
/// @mockable
376-
protocol NonSimpleFuncs {
377-
func cat<T>(named arg: String, tags: [String: String]?, closure: () throws -> T) rethrows -> T
378-
func more<T>(named arg: String, tags: [String: String]?, closure: (T) throws -> ()) rethrows -> T
376+
protocol RethrowsFuncs {
377+
func cat(closure: () throws -> Void) rethrows
379378
}
380379

381380
@Fixture enum expected {
382-
// class NonSimpleFuncsMock: NonSimpleFuncs {
383-
// init() { }
384-
//
385-
//
386-
// private(set) var catCallCount = 0
387-
// var catHandler: ((String, [String: String]?, () throws -> Any) throws -> Any)?
388-
// func cat<T>(named arg: String, tags: [String: String]?, closure: () throws -> T) rethrows -> T {
389-
// catCallCount += 1
390-
// if let catHandler = catHandler {
391-
// return try catHandler(arg, tags, closure) as! T
392-
// }
393-
// fatalError("catHandler returns can't have a default value thus its handler must be set")
394-
// }
395-
//
396-
// private(set) var moreCallCount = 0
397-
// var moreHandler: ((String, [String: String]?, (Any) throws -> ()) throws -> Any)?
398-
// func more<T>(named arg: String, tags: [String: String]?, closure: (T) throws -> ()) rethrows -> T {
399-
// moreCallCount += 1
400-
// if let moreHandler = moreHandler {
401-
// return try moreHandler(arg, tags, closure) as! T
402-
// }
403-
// fatalError("moreHandler returns can't have a default value thus its handler must be set")
404-
// }
405-
// }
381+
class RethrowsFuncsMock: RethrowsFuncs {
382+
init() { }
383+
384+
385+
private(set) var catCallCount = 0
386+
var catHandler: ((() throws(any Error) -> Any) throws(any Error) -> Void)?
387+
func cat<Failure: Error>(closure: () throws(Failure) -> Void) throws(Failure) {
388+
catCallCount += 1
389+
if let catHandler = catHandler {
390+
do {
391+
try catHandler(closure)
392+
} catch {
393+
throw error as! Failure
394+
}
395+
}
396+
fatalError("catHandler returns can't have a default value thus its handler must be set")
397+
}
398+
}
406399
}
407400
}
401+
#endif
408402

409403
@Fixture enum forArgClosureFunc {
410404
/// @mockable

0 commit comments

Comments
 (0)