Skip to content

Commit cdcc63c

Browse files
Show a warning when some failure is generated outside of a test by the host app. (#48)
* wip * wip * Improve fuzzy matching heuristics * wip * wip * wip * wip * wip * Better test output * fix linux --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent 7cfb9f5 commit cdcc63c

File tree

3 files changed

+126
-3
lines changed

3 files changed

+126
-3
lines changed

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ EXPECTED_STRING = This is expected to fail!
66
EXPECTED = \033[31m\"$(EXPECTED_STRING)\"\033[0m
77

88
test:
9-
@TEST_FAILURE=true swift test 2>&1 | grep '$(EXPECTED_STRING)' > /dev/null \
9+
@swift build --build-tests \
10+
&& TEST_FAILURE=true swift test 2>&1 | grep '$(EXPECTED_STRING)' > /dev/null \
1011
&& (echo "$(PASS) $(XCT_FAIL) was called with $(EXPECTED)" && exit) \
1112
|| (echo "$(FAIL) expected $(XCT_FAIL) to be called with $(EXPECTED)" >&2 && exit 1)
1213

Sources/XCTestDynamicOverlay/XCTFail.swift

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#if DEBUG
2-
#if canImport(ObjectiveC)
3-
import Foundation
2+
import Foundation
43

4+
#if canImport(ObjectiveC)
55
/// This function generates a failure immediately and unconditionally.
66
///
77
/// Dynamically creates and records an `XCTIssue` under the hood that captures the source code
@@ -12,6 +12,7 @@
1212
/// results.
1313
@_disfavoredOverload
1414
public func XCTFail(_ message: String = "") {
15+
let message = appendHostAppWarningIfNeeded(message)
1516
guard
1617
let currentTestCase = XCTCurrentTestCase,
1718
let XCTIssue = NSClassFromString("XCTIssue")
@@ -45,6 +46,7 @@
4546
/// results.
4647
@_disfavoredOverload
4748
public func XCTFail(_ message: String = "", file: StaticString, line: UInt) {
49+
let message = appendHostAppWarningIfNeeded(message)
4850
_XCTFailureHandler(nil, true, "\(file)", line, "\(message.isEmpty ? "failed" : message)", nil)
4951
}
5052

@@ -64,6 +66,83 @@
6466
@_disfavoredOverload
6567
public func XCTFail(_ message: String = "", file: StaticString, line: UInt) {}
6668
#endif
69+
70+
func appendHostAppWarningIfNeeded(_ originalMessage: String) -> String {
71+
guard _XCTIsTesting else { return originalMessage }
72+
if Bundle.main.bundleIdentifier == "com.apple.dt.xctest.tool" // Apple platforms
73+
|| Bundle.main.bundleIdentifier == nil // Linux
74+
{
75+
// XCTesting is providing a default host app.
76+
return originalMessage
77+
}
78+
79+
if Thread.callStackSymbols.contains(where: { $0.range(of: "XCTestCore") != nil }) {
80+
// We are apparently performing a sync test
81+
return originalMessage
82+
}
83+
84+
if testCaseSubclass(callStackSymbols: Thread.callStackSymbols) != nil {
85+
// We are apparently performing an async test.
86+
// We're matching a `() -> ()` function that starts with `test`, from a `XCTestCase` subclass
87+
return originalMessage
88+
}
89+
90+
let message = """
91+
Warning! This failure occurred while running tests hosted by the main app.
92+
93+
Testing using the main app as a host can lead to false positive test failures created by the \
94+
app accessing unimplemented values itself when it is spun up.
95+
96+
- Test host: \(Bundle.main.bundleIdentifier ?? "Unknown")
97+
98+
You can find more information and workarounds in the "Testing/Testing Gotchas" section of \
99+
Dependencies' documentation at \
100+
https://pointfreeco.github.io/swift-dependencies/main/documentation/dependencies/testing/.
101+
"""
102+
103+
return [originalMessage, "", message].joined(separator: "\n")
104+
}
105+
106+
// (?<=\$s): Starts with "$s" (stable mangling);
107+
// \d{1,3}: Some numbers (the class name length or the module name length);
108+
// .*: The class name, or module name + class name length + class name;
109+
// C: The class type identifier;
110+
// (?=\d{1,3}test.*yy(Ya)?K?F): Followed by the function name length, function that starts with
111+
// `test`, has no arguments (y), returns Void (y), and is a function (F), potentially async (Ya),
112+
// throwing (K), or both.
113+
private let testCaseRegex = #"(?<=\$s)\d{1,3}.*C(?=\d{1,3}test.*yy(Ya)?K?F)"#
114+
115+
func testCaseSubclass(callStackSymbols: [String]) -> Any.Type? {
116+
for frame in callStackSymbols {
117+
var startIndex = frame.startIndex
118+
while startIndex != frame.endIndex {
119+
if let range = frame.range(
120+
of: testCaseRegex,
121+
options: .regularExpression,
122+
range: startIndex..<frame.endIndex,
123+
locale: nil
124+
) {
125+
if let testCase = testCase(mangledName: String(frame[range])) {
126+
return testCase
127+
}
128+
startIndex = range.upperBound
129+
} else {
130+
break
131+
}
132+
}
133+
}
134+
return nil
135+
}
136+
137+
private func testCase(mangledName: String) -> Any.Type? {
138+
if let object = _typeByName(mangledName) as? NSObject.Type,
139+
NSClassFromString("XCTestCase").map(object.isSubclass(of:)) == true
140+
{
141+
return object
142+
}
143+
return nil
144+
}
145+
67146
#else
68147
/// This function generates a failure immediately and unconditionally.
69148
///
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import XCTest
2+
3+
@testable import XCTestDynamicOverlay
4+
5+
final class HostAppCallStackTests: XCTestCase {
6+
func testIsAbleToDetectTest() {
7+
XCTAssertEqual(
8+
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
9+
ObjectIdentifier(HostAppCallStackTests.self)
10+
)
11+
}
12+
13+
func testIsAbleToDetectAsyncTest() async {
14+
XCTAssertEqual(
15+
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
16+
ObjectIdentifier(HostAppCallStackTests.self)
17+
)
18+
}
19+
20+
func testIsAbleToDetectThrowingTest() throws {
21+
XCTAssertEqual(
22+
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
23+
ObjectIdentifier(HostAppCallStackTests.self)
24+
)
25+
}
26+
27+
func testIsAbleToDetectAsyncThrowingTest() async throws {
28+
XCTAssertEqual(
29+
testCaseSubclass(callStackSymbols: Thread.callStackSymbols).map(ObjectIdentifier.init),
30+
ObjectIdentifier(HostAppCallStackTests.self)
31+
)
32+
}
33+
34+
#if !os(Linux)
35+
func testFailDoesNotAppendHostAppWarningFromATest() {
36+
XCTExpectFailure {
37+
XCTestDynamicOverlay.XCTFail("foo")
38+
} issueMatcher: {
39+
$0.compactDescription == "foo"
40+
}
41+
}
42+
#endif
43+
}

0 commit comments

Comments
 (0)