Skip to content

Commit 9466dc1

Browse files
committed
[XCTestCase] Add asynchronous testing API
Mirror the Objective-C XCTest API to add asynchronous testing support, primarily via two methods: - `XCTestCase.expectationWithDescription()` - `XCTestCase.waitForExpectationsWithTimeout()` These methods' APIs are identical to their Objective-C SDK overlay counterparts, with the following exceptions: 1. If expectations are created but not waited for, Objective-C XCTest generates a test failure. In order to display a failure on the exact line of the last expectation that was created, Objective-C XCTest symbolicates the call stack. It does so using a private framework called `CoreSymbolication.framework`. We don't have this at our disposal for swift-corelibs-xctest, so instead we take `file` and `line` as parameters, defaulting to `__FILE__` and `__LINE__`. By doing so, we provide the same failure highlighting functionality while also maintaining a (nearly) identical API. 2. If `waitForExpectationsWithTimeout()` fails, Objective-C XCTest generates a test failure on the exact line that method was called, using the same technique from (1). For the same reason, this method also takes `file` and `line` parameters in swift-corelibs-xctest. 3. `waitForExpectationsWithTimeout()` takes a handler callback, which in Objective-C XCTest is passed an instance of `NSError`. swift-corelibs-xctest doesn't depend upon swift-corelibs-foundation, and so does not have access to `NSError`. Instead, it defines a shim, `XCTError`. In order to implement the asynchronous APIs, swift-corelibs-xctest now has a dependency upon swift-corelibs-foundation. Bump minimum deployment targets for XCTest to the same required by Foundation.
1 parent 2b0046d commit 9466dc1

File tree

9 files changed

+592
-22
lines changed

9 files changed

+592
-22
lines changed

README.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,37 +24,58 @@ The rest of this document will focus on how this version of XCTest differs from
2424

2525
## Working on XCTest
2626

27+
### On Linux
28+
2729
XCTest can be built as part of the overall Swift package. When following [the instructions for building Swift](http://www.github.com/apple/swift), pass the `--xctest` option to the build script:
2830

2931
```sh
3032
swift/utils/build-script --xctest
3133
```
3234

33-
If you want to build just XCTest, use the `build_script.py` script at the root of the project. The `master` version of XCTest must be built with the `master` version of Swift.
35+
If you want to build just XCTest, use the `build_script.py` script at the root of the project. The `master` version of XCTest must be built with the `master` version of Swift. XCTest has a dependency upon Foundation, so you must have built the `master` version of that as well.
3436

3537
If your install of Swift is located at `/swift` and you wish to install XCTest into that same location, here is a sample invocation of the build script:
3638

3739
```sh
3840
./build_script.py \
3941
--swiftc="/swift/usr/bin/swiftc" \
4042
--build-dir="/tmp/XCTest_build" \
43+
--foundation-build-dir "/swift//usr/lib/swift/linux" \
4144
--library-install-path="/swift/usr/lib/swift/linux" \
4245
--module-install-path="/swift/usr/lib/swift/linux/x86_64"
4346
```
4447

4548
To run the tests on Linux, use the `--test` option:
4649

4750
```sh
48-
./build_script.py --swiftc="/swift/usr/bin/swiftc" --test
51+
./build_script.py \
52+
--swiftc="/swift/usr/bin/swiftc" \
53+
--foundation-build-dir "/swift/usr/lib/swift/linux" \
54+
--test
55+
```
56+
57+
You may add tests for XCTest by including them in the `Tests/Functional/` directory. For an example, see `Tests/Functional/SingleFailingTestCase`.
58+
59+
### On OS X
60+
61+
You may build XCTest via the "SwiftXCTest" scheme in `XCTest.xcworkspace`. The workspace assumes that Foundation and XCTest are checked out from GitHub in sibling directories. For example:
62+
4963
```
64+
% cd Development
65+
% ls
66+
swift-corelibs-foundation swift-corelibs-xctest
67+
%
68+
```
69+
70+
Unlike on Linux, you do not need to build Foundation prior to building XCTest. The "SwiftXCTest" Xcode scheme takes care of that for you.
5071

5172
To run the tests on OS X, build and run the `SwiftXCTestFunctionalTests` target in the Xcode workspace. You may also run them via the command line:
5273

5374
```
5475
xcodebuild -workspace XCTest.xcworkspace -scheme SwiftXCTestFunctionalTests
5576
```
5677

57-
You may add tests for XCTest by including them in the `Tests/Functional/` directory. For an example, see `Tests/Functional/SingleFailingTestCase`.
78+
When adding tests to the `Tests/Functional` directory, make sure they can be opened in the `XCTest.xcworkspace` by adding references to them, but do not add them to any of the targets.
5879

5980
### Additional Considerations for Swift on Linux
6081

Sources/XCTest/XCTestCase.swift

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
// Base class for test cases
1212
//
1313

14+
#if os(Linux) || os(FreeBSD)
15+
import Foundation
16+
#else
17+
import SwiftFoundation
18+
#endif
19+
1420
/// This is a compound type used by `XCTMain` to represent tests to run. It combines an
1521
/// `XCTestCase` subclass type with the list of test methods to invoke on the test case.
1622
/// This type is intended to be produced by the `testCase` helper function.
@@ -48,6 +54,14 @@ private func test<T: XCTestCase>(testFunc: T -> () throws -> Void) -> XCTestCase
4854
}
4955
}
5056

57+
// FIXME: These should be instance variables defined on XCTestCase, but when so
58+
// defined Linux tests fail with "hidden symbol isn't defined". Use
59+
// globals for the time being, as these seem to appease the Linux
60+
// compiler.
61+
private var XCTLatestExpectationLocation: (file: StaticString, line: UInt) = ("", 0)
62+
private var XCTAllExpectations = [XCTestExpectation]()
63+
private var XCTAllExpectationFailures = [XCTFailure]()
64+
5165
extension XCTestCase {
5266

5367
public var continueAfterFailure: Bool {
@@ -93,6 +107,24 @@ extension XCTestCase {
93107

94108
testCase.tearDown()
95109

110+
// It is an API violation to create expectations but not wait
111+
// for them to be completed. Notify the user of a mistake via
112+
// a test failure.
113+
if XCTAllExpectations.count > 0 {
114+
let failure = XCTFailure(
115+
message: "Failed due to unwaited expectations.",
116+
failureDescription: "",
117+
expected: false,
118+
file: XCTLatestExpectationLocation.file,
119+
line: XCTLatestExpectationLocation.line)
120+
XCTAllExpectationFailures.append(failure)
121+
}
122+
123+
failures += XCTAllExpectationFailures
124+
XCTLatestExpectationLocation = (file: "", line: 0)
125+
XCTAllExpectations = []
126+
XCTAllExpectationFailures = []
127+
96128
totalDuration += duration
97129

98130
var result = "passed"
@@ -122,4 +154,155 @@ extension XCTestCase {
122154

123155
XCTPrint("Executed \(tests.count) test\(testCountSuffix), with \(totalFailures) failure\(failureSuffix) (\(unexpectedFailures) unexpected) in \(printableStringForTimeInterval(totalDuration)) (\(printableStringForTimeInterval(overallDuration))) seconds")
124156
}
157+
158+
internal func expectationWasFulfilledInDuplicate(expectation: XCTestExpectation, file: StaticString, line: UInt) {
159+
// Mirror Objective-C XCTest behavior: treat multiple calls to
160+
// fulfill() as an unexpected failure.
161+
// FIXME: Objective-C XCTest raises an exception for most "API
162+
// violation" failures, including this one. Normally this causes
163+
// the test to stop cold. swift-corelibs-xctest does not stop,
164+
// and executes the rest of the test. This discrepancy should be
165+
// fixed.
166+
let failure = XCTFailure(
167+
message: "multiple calls made to XCTestExpectation.fulfill() for \(expectation.description).",
168+
failureDescription: "API violation",
169+
expected: false,
170+
file: file,
171+
line: line)
172+
XCTAllExpectationFailures.append(failure)
173+
}
174+
175+
/// Creates and returns an expectation associated with the test case.
176+
///
177+
/// - Parameter description: This string will be displayed in the test log
178+
/// to help diagnose failures.
179+
/// - Parameter file: The file name to use in the error message if
180+
/// this expectation is not waited for. Default is the file
181+
/// containing the call to this method. It is rare to provide this
182+
/// parameter when calling this method.
183+
/// - Parameter line: The line number to use in the error message if the
184+
/// this expectation is not waited for. Default is the line
185+
/// number of the call to this method in the calling file. It is rare to
186+
/// provide this parameter when calling this method.
187+
///
188+
/// - Note: Whereas Objective-C XCTest determines the file and line
189+
/// number of expectations that are created by using symbolication, this
190+
/// implementation opts to take `file` and `line` as parameters instead.
191+
/// As a result, the interface to these methods are not exactly identical
192+
/// between these environments. To ensure compatibility of tests between
193+
/// swift-corelibs-xctest and Apple XCTest, it is not recommended to pass
194+
/// explicit values for `file` and `line`.
195+
public func expectationWithDescription(description: String, file: StaticString = #file, line: UInt = #line) -> XCTestExpectation {
196+
let expectation = XCTestExpectation(description: description, testCase: self)
197+
XCTAllExpectations.append(expectation)
198+
XCTLatestExpectationLocation = (file: file, line: line)
199+
return expectation
200+
}
201+
202+
/// Creates a point of synchronization in the flow of a test. Only one
203+
/// "wait" can be active at any given time, but multiple discrete sequences
204+
/// of { expectations -> wait } can be chained together.
205+
///
206+
/// - Parameter timeout: The amount of time within which all expectation
207+
/// must be fulfilled.
208+
/// - Parameter file: The file name to use in the error message if
209+
/// expectations are not met before the given timeout. Default is the file
210+
/// containing the call to this method. It is rare to provide this
211+
/// parameter when calling this method.
212+
/// - Parameter line: The line number to use in the error message if the
213+
/// expectations are not met before the given timeout. Default is the line
214+
/// number of the call to this method in the calling file. It is rare to
215+
/// provide this parameter when calling this method.
216+
/// - Parameter handler: If provided, the handler will be invoked both on
217+
/// timeout or fulfillment of all expectations. Timeout is always treated
218+
/// as a test failure.
219+
///
220+
/// - Note: Whereas Objective-C XCTest determines the file and line
221+
/// number of the "wait" call using symbolication, this implementation
222+
/// opts to take `file` and `line` as parameters instead. As a result,
223+
/// the interface to these methods are not exactly identical between
224+
/// these environments. To ensure compatibility of tests between
225+
/// swift-corelibs-xctest and Apple XCTest, it is not recommended to pass
226+
/// explicit values for `file` and `line`.
227+
public func waitForExpectationsWithTimeout(timeout: Double, file: StaticString = #file, line: UInt = #line, handler: XCWaitCompletionHandler?) {
228+
// Mirror Objective-C XCTest behavior; display an unexpected test
229+
// failure when users wait without having first set expectations.
230+
// FIXME: Objective-C XCTest raises an exception for most "API
231+
// violation" failures, including this one. Normally this causes
232+
// the test to stop cold. swift-corelibs-xctest does not stop,
233+
// and executes the rest of the test. This discrepancy should be
234+
// fixed.
235+
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+
XCTAllExpectationFailures.append(failure)
243+
return
244+
}
245+
246+
// Objective-C XCTest outputs the descriptions of every unfulfilled
247+
// expectation. We gather them into this array, which is also used
248+
// to determine failure--a non-empty array meets expectations weren't
249+
// met.
250+
var unfulfilledDescriptions = [String]()
251+
252+
// We continue checking whether expectations have been fulfilled until
253+
// the specified timeout has been reached.
254+
// FIXME: Instead of polling the expectations to check whether they've
255+
// been fulfilled, it would be more efficient to use a runloop
256+
// source that can be signaled to wake up when an expectation is
257+
// fulfilled.
258+
let runLoop = NSRunLoop.currentRunLoop()
259+
let timeoutDate = NSDate(timeIntervalSinceNow: timeout)
260+
repeat {
261+
unfulfilledDescriptions = []
262+
for expectation in XCTAllExpectations {
263+
if !expectation.fulfilled {
264+
unfulfilledDescriptions.append(expectation.description)
265+
}
266+
}
267+
268+
// If we've met all expectations, then break out of the specified
269+
// timeout loop early.
270+
if unfulfilledDescriptions.count == 0 {
271+
break
272+
}
273+
274+
// Otherwise, wait another fraction of a second.
275+
runLoop.runUntilDate(NSDate(timeIntervalSinceNow: 0.01))
276+
} while NSDate().compare(timeoutDate) == NSComparisonResult.OrderedAscending
277+
278+
if unfulfilledDescriptions.count > 0 {
279+
// Not all expectations were fulfilled. Append a failure
280+
// to the array of expectation-based failures.
281+
let descriptions = unfulfilledDescriptions.joinWithSeparator(", ")
282+
let failure = XCTFailure(
283+
message: "Exceeded timeout of \(timeout) seconds, with unfulfilled expectations: \(descriptions)",
284+
failureDescription: "Asynchronous wait failed",
285+
expected: true,
286+
file: file,
287+
line: line)
288+
XCTAllExpectationFailures.append(failure)
289+
}
290+
291+
// We've recorded all the failures; clear the expectations that
292+
// were set for this test case.
293+
XCTAllExpectations = []
294+
295+
// The handler is invoked regardless of whether the test passed.
296+
if let completionHandler = handler {
297+
var error: NSError? = nil
298+
if unfulfilledDescriptions.count > 0 {
299+
// If the test failed, send an error object.
300+
error = NSError(
301+
domain: "org.swift.XCTestErrorDomain",
302+
code: 0,
303+
userInfo: [:])
304+
}
305+
completionHandler(error)
306+
}
307+
}
125308
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2014 - 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+
// XCTestExpectation.swift
11+
// Expectations represent specific conditions in asynchronous testing.
12+
//
13+
14+
/// Expectations represent specific conditions in asynchronous testing.
15+
public class XCTestExpectation {
16+
internal let description: String
17+
internal weak var testCase: XCTestCase?
18+
19+
internal var fulfilled = false
20+
21+
internal init(description: String, testCase: XCTestCase) {
22+
self.description = description
23+
self.testCase = testCase
24+
}
25+
26+
/// Marks an expectation as having been met. It's an error to call this
27+
/// method on an expectation that has already been fulfilled, or when the
28+
/// test case that vended the expectation has already completed.
29+
///
30+
/// - Parameter file: The file name to use in the error message if
31+
/// expectations are not met before the given timeout. Default is the file
32+
/// containing the call to this method. It is rare to provide this
33+
/// parameter when calling this method.
34+
/// - Parameter line: The line number to use in the error message if the
35+
/// expectations are not met before the given timeout. Default is the line
36+
/// number of the call to this method in the calling file. It is rare to
37+
/// provide this parameter when calling this method.
38+
///
39+
/// - Note: Whereas Objective-C XCTest determines the file and line
40+
/// number the expectation was fulfilled using symbolication, this
41+
/// implementation opts to take `file` and `line` as parameters instead.
42+
/// As a result, the interface to these methods are not exactly identical
43+
/// between these environments. To ensure compatibility of tests between
44+
/// swift-corelibs-xctest and Apple XCTest, it is not recommended to pass
45+
/// explicit values for `file` and `line`.
46+
public func fulfill(file: StaticString = #file, line: UInt = #line) {
47+
if fulfilled {
48+
if testCase == nil {
49+
// FIXME: Objective-C XCTest emits failures when expectations
50+
// are fulfilled after the test cases that generated
51+
// those expectations have completed.
52+
} else {
53+
testCase!.expectationWasFulfilledInDuplicate(self, file: file, line: line)
54+
}
55+
} else {
56+
fulfilled = true
57+
}
58+
}
59+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// This source file is part of the Swift.org open source project
2+
//
3+
// Copyright (c) 2014 - 2015 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+
// XCWaitCompletionHandler.swift
11+
// A closure invoked by XCTestCase when a wait for expectations to be
12+
// fulfilled times out.
13+
//
14+
15+
#if os(Linux) || os(FreeBSD)
16+
import Foundation
17+
#else
18+
import SwiftFoundation
19+
#endif
20+
21+
/// A block to be invoked when a call to wait times out or has had all
22+
/// associated expectations fulfilled.
23+
///
24+
/// - Parameter error: If the wait timed out or a failure was raised while
25+
/// waiting, the error's code will specify the type of failure. Otherwise
26+
/// error will be nil.
27+
public typealias XCWaitCompletionHandler = (NSError?) -> ()

0 commit comments

Comments
 (0)