Skip to content

Commit 651fd40

Browse files
committed
Use XCTestRun to execute tests and report results
A major goal for swift-corelibs-xctest is API parity with Apple XCTest. This adds the largest missing API in swift-corelibs-xctest: `XCTestRun`. In Apple XCTest, `XCTestRun` is responsible for keeping track of the result of a test run. It's an integral part of how Apple XCTest works. swift-corelibs-xctest, on the other hand, used a global array of `XCTRun` structs to keep track of how many tests passed/failed. While it may have been possible to tack on `XCTestRun` to the swift-corelibs-xctest mechanism for failure reporting, this commit instead fully integrates it. As a result, the changes are widespread: gone is `XCTFailureHandler`, `XCTRun`, and other internal structures. In their place, welcome the Apple XCTest public APIs: the `XCTest` abstract class, `XCTestRun`, and its subclasses `XCTestCaseRun` and `XCTestSuiteRun`. In conjunction with the new `XCTestSuite`-related observation methods from #84, test reporting is now done exclusively through `XCTestObservation`. As a result, test output is now nearly identical to Apple XCTest.
1 parent 5274cdd commit 651fd40

File tree

26 files changed

+819
-388
lines changed

26 files changed

+819
-388
lines changed

Sources/XCTest/PrintObserver.swift

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2016 Apple Inc. and the Swift project authors
4+
// Licensed under Apache License v2.0 with Runtime Library Exception
5+
//
6+
// See http://swift.org/LICENSE.txt for license information
7+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
8+
//
9+
//
10+
// PrintObserver.swift
11+
// Prints test progress to stdout.
12+
//
13+
14+
#if os(Linux) || os(FreeBSD)
15+
import Foundation
16+
#else
17+
import SwiftFoundation
18+
#endif
19+
20+
/// Prints textual representations of each XCTestObservation event to stdout.
21+
/// Mirrors the Apple XCTest output exactly.
22+
internal class PrintObserver: XCTestObservation {
23+
func testBundleWillStart(testBundle: NSBundle) {}
24+
25+
func testSuiteWillStart(testSuite: XCTestSuite) {
26+
printAndFlush("Test Suite '\(testSuite.name)' started at \(dateFormatter.stringFromDate(testSuite.testRun!.startDate!))")
27+
}
28+
29+
func testCaseWillStart(testCase: XCTestCase) {
30+
printAndFlush("Test Case '\(testCase.name)' started at \(dateFormatter.stringFromDate(testCase.testRun!.startDate!))")
31+
}
32+
33+
func testCase(testCase: XCTestCase, didFailWithDescription description: String, inFile filePath: String?, atLine lineNumber: UInt) {
34+
let file = filePath ?? "<unknown>"
35+
printAndFlush("\(file):\(lineNumber): error: \(description)")
36+
}
37+
38+
func testCaseDidFinish(testCase: XCTestCase) {
39+
let testRun = testCase.testRun!
40+
let verb = testRun.hasSucceeded ? "passed" : "failed"
41+
// FIXME: Apple XCTest does not print a period after "(N seconds)".
42+
// The trailing period here should be removed and the functional
43+
// test suite should be updated.
44+
printAndFlush("Test Case '\(testCase.name)' \(verb) (\(formatTimeInterval(testRun.totalDuration)) seconds).")
45+
}
46+
47+
func testSuiteDidFinish(testSuite: XCTestSuite) {
48+
let testRun = testSuite.testRun!
49+
let verb = testRun.hasSucceeded ? "passed" : "failed"
50+
printAndFlush("Test Suite '\(testSuite.name)' \(verb) at \(dateFormatter.stringFromDate(testRun.stopDate!))")
51+
52+
let tests = testRun.executionCount == 1 ? "test" : "tests"
53+
let failures = testRun.totalFailureCount == 1 ? "failure" : "failures"
54+
printAndFlush(
55+
"\t Executed \(testRun.executionCount) \(tests), " +
56+
"with \(testRun.totalFailureCount) \(failures) (\(testRun.unexpectedExceptionCount) unexpected) " +
57+
"in \(formatTimeInterval(testRun.testDuration)) (\(formatTimeInterval(testRun.totalDuration))) seconds"
58+
)
59+
}
60+
61+
func testBundleDidFinish(testBundle: NSBundle) {}
62+
63+
private lazy var dateFormatter: NSDateFormatter = {
64+
let formatter = NSDateFormatter()
65+
formatter.dateFormat = "HH:mm:ss.SSS"
66+
return formatter
67+
}()
68+
69+
private func printAndFlush(message: String) {
70+
print(message)
71+
fflush(stdout)
72+
}
73+
74+
private func formatTimeInterval(timeInterval: NSTimeInterval) -> String {
75+
return String(round(timeInterval * 1000.0) / 1000.0)
76+
}
77+
}

Sources/XCTest/XCAbstractTest.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,46 @@ public class XCTest {
2626
fatalError("Must be overridden by subclasses.")
2727
}
2828

29+
/// The `XCTestRun` subclass that will be instantiated when the test is run
30+
/// to hold the test's results. Must be overridden by subclasses.
31+
public var testRunClass: AnyClass {
32+
fatalError("Must be overridden by subclasses.")
33+
}
34+
35+
/// The test run object that executed the test, an instance of
36+
/// testRunClass. If the test has not yet been run, this will be nil.
37+
/// - Note: FIXME: This property is meant to be `internal(set)`. It is
38+
/// publicly settable for now due to a Swift compiler bug on Linux. To
39+
/// ensure compatibility of tests between swift-corelibs-xctest and Apple
40+
/// XCTest, you should not set this property. See
41+
/// https://bugs.swift.org/browse/SR-1129 for details.
42+
public public(set) var testRun: XCTestRun? = nil
43+
44+
/// The method through which tests are executed. Must be overridden by
45+
/// subclasses.
46+
public func performTest(run: XCTestRun) {
47+
fatalError("Must be overridden by subclasses.")
48+
}
49+
50+
/// Creates an instance of the `testRunClass` and passes it as a parameter
51+
/// to `performTest()`.
52+
public func runTest() {
53+
guard let testRunType = testRunClass as? XCTestRun.Type else {
54+
fatalError("XCTest.testRunClass must be a kind of XCTestRun.")
55+
}
56+
testRun = testRunType.init(test: self)
57+
performTest(testRun!)
58+
}
59+
2960
/// Setup method called before the invocation of each test method in the
3061
/// class.
3162
public func setUp() {}
3263

3364
/// Teardown method called after the invocation of each test method in the
3465
/// class.
3566
public func tearDown() {}
67+
68+
// FIXME: This initializer is required due to a Swift compiler bug on Linux.
69+
// It should be removed once the bug is fixed.
70+
public init() {}
3671
}

Sources/XCTest/XCTAssert.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,12 @@ private func _XCTEvaluateAssertion(assertion: _XCTAssertion, @autoclosure messag
8787
case .Success:
8888
return
8989
default:
90-
if let handler = XCTFailureHandler {
91-
handler(XCTFailure(message: message(), failureDescription: result.failureDescription(assertion), expected: result.expected, file: file, line: line))
90+
if let currentTestCase = XCTCurrentTestCase {
91+
currentTestCase.recordFailureWithDescription(
92+
"\(result.failureDescription(assertion)) - \(message())",
93+
inFile: String(file),
94+
atLine: line,
95+
expected: result.expected)
9296
}
9397
}
9498
}

Sources/XCTest/XCTestCase.swift

Lines changed: 89 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@
2424
/// - seealso: `XCTMain`
2525
public typealias XCTestCaseEntry = (testCaseClass: XCTestCase.Type, allTests: [(String, XCTestCase throws -> Void)])
2626

27+
// A global pointer to the currently running test case. This is required in
28+
// order for XCTAssert functions to report failures.
29+
internal var XCTCurrentTestCase: XCTestCase?
30+
2731
public class XCTestCase: XCTest {
32+
private let testClosure: XCTestCase throws -> Void
2833

2934
/// The name of the test case, consisting of its class name and the method
3035
/// name it will run.
@@ -39,8 +44,75 @@ public class XCTestCase: XCTest {
3944
/// https://bugs.swift.org/browse/SR-1129 for details.
4045
public var _name: String
4146

42-
public required override init() {
43-
_name = "\(self.dynamicType).<unknown>"
47+
public override var testRunClass: AnyClass {
48+
return XCTestCaseRun.self
49+
}
50+
51+
public override func performTest(run: XCTestRun) {
52+
guard let testRun = run as? XCTestCaseRun else {
53+
fatalError("Wrong XCTestRun class.")
54+
}
55+
56+
XCTCurrentTestCase = self
57+
testRun.start()
58+
invokeTest()
59+
failIfExpectationsNotWaitedFor(XCTAllExpectations)
60+
XCTAllExpectations = []
61+
testRun.stop()
62+
XCTCurrentTestCase = nil
63+
}
64+
65+
/// The designated initializer for SwiftXCTest's XCTestCase.
66+
/// - Note: Like the designated initializer for Apple XCTest's XCTestCase,
67+
/// `-[XCTestCase initWithInvocation:]`, it's rare for anyone outside of
68+
/// XCTest itself to call this initializer.
69+
public required init(name: String, testClosure: XCTestCase throws -> Void) {
70+
_name = "\(self.dynamicType).\(name)"
71+
self.testClosure = testClosure
72+
}
73+
74+
/// Invoking a test performs its setUp, invocation, and tearDown. In
75+
/// general this should not be called directly.
76+
public func invokeTest() {
77+
setUp()
78+
do {
79+
try testClosure(self)
80+
} catch {
81+
recordFailureWithDescription(
82+
"threw error \"\(error)\"",
83+
inFile: "<EXPR>",
84+
atLine: 0,
85+
expected: false)
86+
}
87+
tearDown()
88+
}
89+
90+
/// Records a failure in the execution of the test and is used by all test
91+
/// assertions.
92+
/// - Parameter description: The description of the failure being reported.
93+
/// - Parameter filePath: The file path to the source file where the failure
94+
/// being reported was encountered.
95+
/// - Parameter lineNumber: The line number in the source file at filePath
96+
/// where the failure being reported was encountered.
97+
/// - Parameter expected: `true` if the failure being reported was the
98+
/// result of a failed assertion, `false` if it was the result of an
99+
/// uncaught exception.
100+
public func recordFailureWithDescription(description: String, inFile filePath: String, atLine lineNumber: UInt, expected: Bool) {
101+
testRun?.recordFailureWithDescription(
102+
"\(name) : \(description)",
103+
inFile: filePath,
104+
atLine: lineNumber,
105+
expected: expected)
106+
107+
// FIXME: Apple XCTest does not throw a fatal error and crash the test
108+
// process, it merely prevents the remainder of a testClosure
109+
// from execting after it's been determined that it has already
110+
// failed. The following behavior is incorrect.
111+
// FIXME: No regression tests exist for this feature. We may break it
112+
// without ever realizing.
113+
if !continueAfterFailure {
114+
fatalError("Terminating execution due to test failure")
115+
}
44116
}
45117
}
46118

@@ -82,90 +154,15 @@ extension XCTestCase {
82154
}
83155
}
84156

85-
internal static func invokeTests(tests: [(String, XCTestCase throws -> Void)]) {
86-
let observationCenter = XCTestObservationCenter.shared()
87-
88-
var totalDuration = 0.0
89-
var totalFailures = 0
90-
var unexpectedFailures = 0
91-
let overallDuration = measureTimeExecutingBlock {
92-
for (name, test) in tests {
93-
let testCase = self.init()
94-
testCase._name = "\(testCase.dynamicType).\(name)"
95-
96-
var failures = [XCTFailure]()
97-
XCTFailureHandler = { failure in
98-
observationCenter.testCase(testCase,
99-
didFailWithDescription: failure.failureMessage,
100-
inFile: String(failure.file),
101-
atLine: failure.line)
102-
103-
if !testCase.continueAfterFailure {
104-
failure.emit(testCase.name)
105-
fatalError("Terminating execution due to test failure", file: failure.file, line: failure.line)
106-
} else {
107-
failures.append(failure)
108-
}
109-
}
110-
111-
XCTPrint("Test Case '\(testCase.name)' started.")
112-
113-
observationCenter.testCaseWillStart(testCase)
114-
115-
testCase.setUp()
116-
117-
let duration = measureTimeExecutingBlock {
118-
do {
119-
try test(testCase)
120-
} catch {
121-
let unexpectedFailure = XCTFailure(message: "", failureDescription: "threw error \"\(error)\"", expected: false, file: "<EXPR>", line: 0)
122-
XCTFailureHandler!(unexpectedFailure)
123-
}
124-
}
125-
126-
testCase.tearDown()
127-
testCase.failIfExpectationsNotWaitedFor(XCTAllExpectations)
128-
XCTAllExpectations = []
129-
130-
observationCenter.testCaseDidFinish(testCase)
131-
132-
totalDuration += duration
133-
134-
var result = "passed"
135-
for failure in failures {
136-
failure.emit(testCase.name)
137-
totalFailures += 1
138-
if !failure.expected {
139-
unexpectedFailures += 1
140-
}
141-
result = failures.count > 0 ? "failed" : "passed"
142-
}
143-
144-
XCTPrint("Test Case '\(testCase.name)' \(result) (\(printableStringForTimeInterval(duration)) seconds).")
145-
XCTAllRuns.append(XCTRun(duration: duration, method: testCase.name, passed: failures.count == 0, failures: failures))
146-
XCTFailureHandler = nil
147-
}
148-
}
149-
150-
let testCountSuffix = (tests.count == 1) ? "" : "s"
151-
let failureSuffix = (totalFailures == 1) ? "" : "s"
152-
153-
XCTPrint("Executed \(tests.count) test\(testCountSuffix), with \(totalFailures) failure\(failureSuffix) (\(unexpectedFailures) unexpected) in \(printableStringForTimeInterval(totalDuration)) (\(printableStringForTimeInterval(overallDuration))) seconds")
154-
}
155-
156157
/// It is an API violation to create expectations but not wait for them to
157158
/// be completed. Notify the user of a mistake via a test failure.
158159
private func failIfExpectationsNotWaitedFor(expectations: [XCTestExpectation]) {
159160
if expectations.count > 0 {
160-
let failure = XCTFailure(
161-
message: "Failed due to unwaited expectations.",
162-
failureDescription: "",
163-
expected: false,
164-
file: expectations.last!.file,
165-
line: expectations.last!.line)
166-
if let failureHandler = XCTFailureHandler {
167-
failureHandler(failure)
168-
}
161+
recordFailureWithDescription(
162+
"Failed due to unwaited expectations.",
163+
inFile: String(expectations.last!.file),
164+
atLine: expectations.last!.line,
165+
expected: false)
169166
}
170167
}
171168

@@ -233,15 +230,11 @@ extension XCTestCase {
233230
// and executes the rest of the test. This discrepancy should be
234231
// fixed.
235232
if XCTAllExpectations.count == 0 {
236-
let failure = XCTFailure(
237-
message: "call made to wait without any expectations having been set.",
238-
failureDescription: "API violation",
239-
expected: false,
240-
file: file,
241-
line: line)
242-
if let failureHandler = XCTFailureHandler {
243-
failureHandler(failure)
244-
}
233+
recordFailureWithDescription(
234+
"API violation - call made to wait without any expectations having been set.",
235+
inFile: String(file),
236+
atLine: line,
237+
expected: false)
245238
return
246239
}
247240

@@ -281,15 +274,11 @@ extension XCTestCase {
281274
// Not all expectations were fulfilled. Append a failure
282275
// to the array of expectation-based failures.
283276
let descriptions = unfulfilledDescriptions.joined(separator: ", ")
284-
let failure = XCTFailure(
285-
message: "Exceeded timeout of \(timeout) seconds, with unfulfilled expectations: \(descriptions)",
286-
failureDescription: "Asynchronous wait failed",
287-
expected: true,
288-
file: file,
289-
line: line)
290-
if let failureHandler = XCTFailureHandler {
291-
failureHandler(failure)
292-
}
277+
recordFailureWithDescription(
278+
"Asynchronous wait failed - Exceeded timeout of \(timeout) seconds, with unfulfilled expectations: \(descriptions)",
279+
inFile: String(file),
280+
atLine: line,
281+
expected: true)
293282
}
294283

295284
// We've recorded all the failures; clear the expectations that

0 commit comments

Comments
 (0)