Skip to content

Commit 61112cf

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 73ff15a commit 61112cf

File tree

6 files changed

+370
-0
lines changed

6 files changed

+370
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
22
xcuserdata
3+
*.xcscmblueprint
34
.build/
45
Output/

Sources/XCTest/XCTestCase.swift

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,25 @@
1111
// Base protocol (and extension with default methods) for test cases
1212
//
1313

14+
#if os(Linux) || os(FreeBSD)
15+
import Foundation
16+
#else
17+
import SwiftFoundation
18+
#endif
19+
1420
public protocol XCTestCase : XCTestCaseProvider {
1521
func setUp()
1622
func tearDown()
1723
}
1824

25+
/// A block to be invoked when a call to wait times out or has had all
26+
/// associated expectations fulfilled.
27+
///
28+
/// - Parameter error: If the wait timed out or a failure was raised while
29+
/// waiting, the error's code will specify the type of failure. Otherwise
30+
/// error will be nil.
31+
public typealias XCWaitCompletionHandler = (NSError?) -> ()
32+
1933
extension XCTestCase {
2034

2135
public var continueAfterFailure: Bool {
@@ -61,6 +75,23 @@ extension XCTestCase {
6175

6276
tearDown()
6377

78+
// It is an API violation to create expectations but not wait
79+
// for them to be completed. Notify the user of a mistake via
80+
// a test failure.
81+
if XCTAllExpectations.count > 0 {
82+
let failure = XCTFailure(
83+
message: "Failed due to unwaited expectations.",
84+
failureDescription: "",
85+
expected: true,
86+
file: XCTLatestExpectationLocation.file,
87+
line: XCTLatestExpectationLocation.line)
88+
XCTAllExpectationFailures.append(failure)
89+
}
90+
91+
failures += XCTAllExpectationFailures
92+
XCTLatestExpectationLocation = (file: "", line: 0)
93+
XCTAllExpectationFailures = []
94+
6495
totalDuration += duration
6596

6697
var result = "passed"
@@ -98,4 +129,137 @@ extension XCTestCase {
98129
public func tearDown() {
99130

100131
}
132+
133+
/// Creates and returns an expectation associated with the test case.
134+
///
135+
/// - Parameter description: This string will be displayed in the test log
136+
/// to help diagnose failures.
137+
/// - Parameter file: The file name to use in the error message if
138+
/// this expectation is not waited for. Default is the file
139+
/// containing the call to this method. It is rare to provide this
140+
/// parameter when calling this method.
141+
/// - Parameter line: The line number to use in the error message if the
142+
/// this expectation is not waited for. Default is the line
143+
/// number of the call to this method in the calling file. It is rare to
144+
/// provide this parameter when calling this method.
145+
///
146+
/// - Note: Whereas Objective-C XCTest determines the file and line
147+
/// number of expectations that are created by using symbolication, this
148+
/// implementation opts to take `file` and `line` as parameters instead.
149+
/// As a result, the interface to these methods are not exactly identical
150+
/// between these environments.
151+
public func expectationWithDescription(description: String, file: StaticString = __FILE__, line: UInt = __LINE__) -> XCTestExpectation {
152+
let expectation = XCTestExpectation(description: description)
153+
XCTAllExpectations.append(expectation)
154+
XCTLatestExpectationLocation = (file: file, line: line)
155+
return expectation
156+
}
157+
158+
/// Creates a point of synchronization in the flow of a test. Only one
159+
/// "wait" can be active at any given time, but multiple discrete sequences
160+
/// of { expectations -> wait } can be chained together.
161+
///
162+
/// - Parameter timeout: The amount of time within which all expectation
163+
/// must be fulfilled.
164+
/// - Parameter file: The file name to use in the error message if
165+
/// expectations are not met before the given timeout. Default is the file
166+
/// containing the call to this method. It is rare to provide this
167+
/// parameter when calling this method.
168+
/// - Parameter line: The line number to use in the error message if the
169+
/// expectations are not met before the given timeout. Default is the line
170+
/// number of the call to this method in the calling file. It is rare to
171+
/// provide this parameter when calling this method.
172+
/// - Parameter handler: If provided, the handler will be invoked both on
173+
/// timeout or fulfillment of all expectations. Timeout is always treated
174+
/// as a test failure.
175+
///
176+
/// - Note: Whereas Objective-C XCTest determines the file and line
177+
/// number of the "wait" call using symbolication, this implementation
178+
/// opts to take `file` and `line` as parameters instead. As a result,
179+
/// the interface to these methods are not exactly identical between
180+
/// these environments.
181+
public func waitForExpectationsWithTimeout(timeout: Double, file: StaticString = __FILE__, line: UInt = __LINE__, handler: XCWaitCompletionHandler?) {
182+
// Mirror Objective-C XCTest behavior; display a test failure when
183+
// users wait without having first set expectations.
184+
if XCTAllExpectations.count == 0 {
185+
let failure = XCTFailure(
186+
message: "call made to wait without any expectations having been set",
187+
failureDescription: "API violation",
188+
expected: false,
189+
file: file,
190+
line: line)
191+
XCTAllExpectationFailures.append(failure)
192+
return
193+
}
194+
195+
// Objective-C XCTest outputs the descriptions of every unfulfilled
196+
// expectation. We gather them into this array, which is also used
197+
// to determine failure--a non-empty array meets expectations weren't
198+
// met.
199+
var unfulfilledDescriptions = [String]()
200+
201+
// We continue checking whether expectations have been fulfilled until
202+
// the specified timeout has been reached.
203+
let runLoop = NSRunLoop.currentRunLoop()
204+
let timeoutDate = NSDate(timeIntervalSinceNow: timeout)
205+
while NSDate().compare(timeoutDate) == NSComparisonResult.OrderedAscending {
206+
unfulfilledDescriptions = []
207+
for expectation in XCTAllExpectations {
208+
if !expectation.fulfilled {
209+
unfulfilledDescriptions.append(expectation.description)
210+
}
211+
}
212+
213+
// If we've met all expectations, then break out of the specified
214+
// timeout loop early.
215+
if unfulfilledDescriptions.count == 0 {
216+
break
217+
}
218+
219+
// Otherwise, wait another fraction of a second.
220+
runLoop.runUntilDate(NSDate(timeIntervalSinceNow: 0.01))
221+
}
222+
223+
if unfulfilledDescriptions.count > 0 {
224+
// Not all expectations were fulfilled. Append a failure
225+
// to the array of expectation-based failures.
226+
let descriptions = unfulfilledDescriptions.joinWithSeparator(", ")
227+
let failure = XCTFailure(
228+
message: "Exceeded timeout of \(timeout) seconds, with unfulfilled expectations: \(descriptions)",
229+
failureDescription: "Asynchronous wait failed",
230+
expected: true,
231+
file: file,
232+
line: line)
233+
// FIXME: This should be an instance variable on the test case,
234+
// not a global.
235+
XCTAllExpectationFailures.append(failure)
236+
}
237+
238+
// We've recorded all the failures; clear the expectations that
239+
// were set for this test case, in order to prepare the next test
240+
// case to be run.
241+
// FIXME: This should be an instance variable on the test case.
242+
// Once this is the case, there is no longer any need
243+
// to reset it to an empty array here.
244+
XCTAllExpectations = []
245+
246+
// The handler is invoked regardless of whether the test passed.
247+
if let completionHandler = handler {
248+
var error: NSError? = nil
249+
if unfulfilledDescriptions.count > 0 {
250+
// If the test failed, send an error object.
251+
error = NSError(
252+
domain: "org.swift.XCTestErrorDomain",
253+
code: 0,
254+
userInfo: [:])
255+
}
256+
completionHandler(error)
257+
}
258+
}
101259
}
260+
261+
// FIXME: These should be instance variables on XCTestCase;
262+
// see: https://github.com/apple/swift-corelibs-xctest/pull/40
263+
private var XCTLatestExpectationLocation: (file: StaticString, line: UInt) = ("", 0)
264+
private var XCTAllExpectations = [XCTestExpectation]()
265+
private var XCTAllExpectationFailures = [XCTFailure]()
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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 var fulfilled = false
17+
internal let description: String
18+
19+
internal init(description: String) {
20+
self.description = description
21+
}
22+
23+
/// Marks an expectation as having been met. It's an error to call this
24+
/// method on an expectation that has already been fulfilled, or when the
25+
/// test case that vended the expectation has already completed.
26+
public func fulfill() {
27+
fulfilled = true
28+
}
29+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// RUN: %{swiftc} %s -o %{built_tests_dir}/Asynchronous
2+
// RUN: %{built_tests_dir}/Asynchronous > %t || true
3+
// RUN: %{xctest_checker} %t %s
4+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnUnfulfilledExpectation_fails' started.
5+
// CHECK: .*/Tests/Functional/Asynchronous/main.swift:51: error: AsynchronousTestCase.test_waitingForAnUnfulfilledExpectation_fails : Asynchronous wait failed - Exceeded timeout of 0.2 seconds, with unfulfilled expectations: foo
6+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnUnfulfilledExpectation_fails' failed \(\d+\.\d+ seconds\).
7+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails' started.
8+
// CHECK: .*/Tests/Functional/Asynchronous/main.swift:57: error: AsynchronousTestCase.test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails : Asynchronous wait failed - Exceeded timeout of 0.2 seconds, with unfulfilled expectations: bar, baz
9+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails' failed \(\d+\.\d+ seconds\).
10+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnImmediatelyFulfilledExpectation_passes' started.
11+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnImmediatelyFulfilledExpectation_passes' passed \(\d+\.\d+ seconds\).
12+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnEventuallyFulfilledExpectation_passes' started.
13+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnEventuallyFulfilledExpectation_passes' passed \(\d+\.\d+ seconds\).
14+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnExpectationFulfilledAfterTheTimeout_fails' started.
15+
// CHECK: .*/Tests/Functional/Asynchronous/main.swift:81: error: AsynchronousTestCase.test_waitingForAnExpectationFulfilledAfterTheTimeout_fails : Asynchronous wait failed - Exceeded timeout of 0.1 seconds, with unfulfilled expectations: hog
16+
// CHECK: Test Case 'AsynchronousTestCase.test_waitingForAnExpectationFulfilledAfterTheTimeout_fails' failed \(\d+\.\d+ seconds\).
17+
// CHECK: Executed 5 tests, with 3 failures \(0 unexpected\) in \d+\.\d+ \(\d+\.\d+\) seconds
18+
// CHECK: Test Case 'HandlerTestCase.test_whenExpectationsAreNotFulfilled_handlerCalled_andFails' started.
19+
// CHECK: .*/Tests/Functional/Asynchronous/main.swift:97: error: HandlerTestCase.test_whenExpectationsAreNotFulfilled_handlerCalled_andFails : Asynchronous wait failed - Exceeded timeout of 0.2 seconds, with unfulfilled expectations: fog
20+
// CHECK: Test Case 'HandlerTestCase.test_whenExpectationsAreNotFulfilled_handlerCalled_andFails' failed \(\d+\.\d+ seconds\).
21+
// CHECK: Test Case 'HandlerTestCase.test_whenExpectationsAreFulfilled_handlerCalled_andPasses' started.
22+
// CHECK: Test Case 'HandlerTestCase.test_whenExpectationsAreFulfilled_handlerCalled_andPasses' passed \(\d+\.\d+ seconds\).
23+
// CHECK: Executed 2 tests, with 1 failure \(0 unexpected\) in \d+\.\d+ \(\d+\.\d+\) seconds
24+
// CHECK: Test Case 'MisuseTestCase.test_whenExpectationsAreMade_butNotWaitedFor_fails' started.
25+
// CHECK: .*/Tests/Functional/Asynchronous/main.swift:128: error: MisuseTestCase.test_whenExpectationsAreMade_butNotWaitedFor_fails : - Failed due to unwaited expectations.
26+
// CHECK: Test Case 'MisuseTestCase.test_whenExpectationsAreMade_butNotWaitedFor_fails' failed \(\d+\.\d+ seconds\).
27+
// CHECK: Executed 1 test, with 1 failure \(0 unexpected\) in \d+\.\d+\ \(\d+\.\d+\) seconds
28+
// CHECK: Total executed 8 tests, with 5 failures \(0 unexpected\) in \d+\.\d+\ \(\d+\.\d+\) seconds
29+
30+
#if os(Linux) || os(FreeBSD)
31+
import XCTest
32+
import Foundation
33+
#else
34+
import SwiftXCTest
35+
import SwiftFoundation
36+
#endif
37+
38+
class AsynchronousTestCase: XCTestCase {
39+
var allTests: [(String, () throws -> ())] {
40+
return [
41+
("test_waitingForAnUnfulfilledExpectation_fails", test_waitingForAnUnfulfilledExpectation_fails),
42+
("test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails", test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails),
43+
("test_waitingForAnImmediatelyFulfilledExpectation_passes", test_waitingForAnImmediatelyFulfilledExpectation_passes),
44+
("test_waitingForAnEventuallyFulfilledExpectation_passes", test_waitingForAnEventuallyFulfilledExpectation_passes),
45+
("test_waitingForAnExpectationFulfilledAfterTheTimeout_fails", test_waitingForAnExpectationFulfilledAfterTheTimeout_fails),
46+
]
47+
}
48+
49+
func test_waitingForAnUnfulfilledExpectation_fails() {
50+
expectationWithDescription("foo")
51+
waitForExpectationsWithTimeout(0.2, handler: nil)
52+
}
53+
54+
func test_waitingForUnfulfilledExpectations_outputsAllExpectations_andFails() {
55+
expectationWithDescription("bar")
56+
expectationWithDescription("baz")
57+
waitForExpectationsWithTimeout(0.2, handler: nil)
58+
}
59+
60+
func test_waitingForAnImmediatelyFulfilledExpectation_passes() {
61+
let expectation = expectationWithDescription("flim")
62+
expectation.fulfill()
63+
waitForExpectationsWithTimeout(0.2, handler: nil)
64+
}
65+
66+
func test_waitingForAnEventuallyFulfilledExpectation_passes() {
67+
let expectation = expectationWithDescription("flam")
68+
let timer = NSTimer.scheduledTimer(0.1, repeats: false) { _ in
69+
expectation.fulfill()
70+
}
71+
NSRunLoop.currentRunLoop().addTimer(timer, forMode: NSDefaultRunLoopMode)
72+
waitForExpectationsWithTimeout(1.0, handler: nil)
73+
}
74+
75+
func test_waitingForAnExpectationFulfilledAfterTheTimeout_fails() {
76+
let expectation = expectationWithDescription("hog")
77+
let timer = NSTimer.scheduledTimer(1.0, repeats: false) { _ in
78+
expectation.fulfill()
79+
}
80+
NSRunLoop.currentRunLoop().addTimer(timer, forMode: NSDefaultRunLoopMode)
81+
waitForExpectationsWithTimeout(0.1, handler: nil)
82+
}
83+
}
84+
85+
class HandlerTestCase: XCTestCase {
86+
var allTests: [(String, () throws -> ())] {
87+
return [
88+
("test_whenExpectationsAreNotFulfilled_handlerCalled_andFails", test_whenExpectationsAreNotFulfilled_handlerCalled_andFails),
89+
("test_whenExpectationsAreFulfilled_handlerCalled_andPasses", test_whenExpectationsAreFulfilled_handlerCalled_andPasses),
90+
]
91+
}
92+
93+
func test_whenExpectationsAreNotFulfilled_handlerCalled_andFails() {
94+
self.expectationWithDescription("fog")
95+
96+
var handlerWasCalled = false
97+
self.waitForExpectationsWithTimeout(0.2) { error in
98+
XCTAssertNotNil(error, "Expectation handlers for unfulfilled expectations should not be nil.")
99+
XCTAssertTrue(error!.domain.hasSuffix("XCTestErrorDomain"), "The last component of the error domain should match Objective-C XCTest.")
100+
XCTAssertEqual(error!.code, 0, "The error code should match Objective-C XCTest.")
101+
handlerWasCalled = true
102+
}
103+
XCTAssertTrue(handlerWasCalled)
104+
}
105+
106+
func test_whenExpectationsAreFulfilled_handlerCalled_andPasses() {
107+
let expectation = self.expectationWithDescription("bog")
108+
expectation.fulfill()
109+
110+
var handlerWasCalled = false
111+
self.waitForExpectationsWithTimeout(0.2) { error in
112+
XCTAssertNil(error, "Expectation handlers for fulfilled expectations should be nil.")
113+
handlerWasCalled = true
114+
}
115+
XCTAssertTrue(handlerWasCalled)
116+
}
117+
}
118+
119+
class MisuseTestCase: XCTestCase {
120+
var allTests: [(String, () throws -> ())] {
121+
return [
122+
("test_whenExpectationsAreMade_butNotWaitedFor_fails", test_whenExpectationsAreMade_butNotWaitedFor_fails),
123+
]
124+
}
125+
126+
func test_whenExpectationsAreMade_butNotWaitedFor_fails() {
127+
self.expectationWithDescription("the first expectation")
128+
self.expectationWithDescription("the second expectation (the file and line number for this one are included in the failure message")
129+
}
130+
}
131+
132+
XCTMain([
133+
AsynchronousTestCase(),
134+
HandlerTestCase(),
135+
MisuseTestCase(),
136+
])

0 commit comments

Comments
 (0)