Skip to content

Commit b7bc279

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 swiftlang#84, test reporting is now done exclusively through `XCTestObservation`. As a result, test output is now nearly identical to Apple XCTest.
1 parent 15f9272 commit b7bc279

File tree

27 files changed

+815
-387
lines changed

27 files changed

+815
-387
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: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,45 @@ 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.
41+
public public(set) var testRun: XCTestRun? = nil
42+
43+
/// The method through which tests are executed. Must be overridden by
44+
/// subclasses.
45+
public func performTest(run: XCTestRun) {
46+
fatalError("Must be overridden by subclasses.")
47+
}
48+
49+
/// Creates an instance of the `testRunClass` and passes it as a parameter
50+
/// to `performTest()`.
51+
public func runTest() {
52+
guard let testRunType = testRunClass as? XCTestRun.Type else {
53+
fatalError("XCTest.testRunClass must be a kind of XCTestRun.")
54+
}
55+
testRun = testRunType.init(test: self)
56+
performTest(testRun!)
57+
}
58+
2959
/// Setup method called before the invocation of each test method in the
3060
/// class.
3161
public func setUp() {}
3262

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

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.
@@ -38,8 +43,75 @@ public class XCTestCase: XCTest {
3843
/// this property should not be modified.
3944
public var _name: String
4045

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

@@ -81,90 +153,15 @@ extension XCTestCase {
81153
}
82154
}
83155

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

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

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

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

0 commit comments

Comments
 (0)