結論
XCTestExpectationのisInvertedをtrueに設定する
非同期テスト
下記のようなサブスレッドを利用して結果を非同期に返すクラスがあるとします。
class AsyncTask {
func execute(completion: @escaping (Bool) -> Void) {
DispatchQueue.global().async {
completion(false)
}
}
}
このクラスのテストをXCTestで書く際、以下のような実装をしてしまうと、該当のテストケースは非同期処理の完了を待たずにテストを終了させてしまいます。
class SampleTests: XCTestCase {
var asyncTask: AsyncTask!
override func setUpWithError() throws {
asyncTask = AsyncTask()
}
func test() throws {
asyncTask.execute { result in
// このクロージャが呼び出される前にテストが終了してしまう
XCTAssertTrue(result)
}
}
}
そのため、テストケースとしてはtrueを期待しており、AsyncTaskはfalseを返しているので、本来テストは失敗して欲しいところですが、このテストは意図とは異なり成功で終了してしまいます。
XCTestExpectation
この問題を解決するには、XCTestExpectation
を利用する必要があります。
これはXCTestCase内で expectation
メソッドを呼び出すことで生成でき、 XCTestCaseのwait
メソッドに受け渡すことで非同期処理が完了するまでテストの終了を待機させることができます。
XCTestCaseに非同期処理が完了したことを伝えるには XCTestExpectationの fulfill
を呼び出す必要があります。
また、waitメソッドのtimeoutまでにfullfillが呼び出されなかった場合はテストが失敗することになります。
先ほどのサンプルコードをXCTestExpectationを利用した形に修正すると下記のようになります。
func test() throws {
let expectation = self.expectation(description: "wait for async task")
asyncTask.execute { result in
XCTAssertTrue(result)
// 非同期処理が完了したことを知らせる
expectation.fulfill()
}
// fullfillメソッドが呼び出されるまでテストを終了させずに最大0.1秒間待機する
self.wait(for: [expectation], timeout: 0.1)
}
これによりfullfillメソッドが呼び出されるまではテストが終了されないことを保証できるので、テストがちゃんと失敗してくれるようになります。
呼び出されないことの検証
非同期テストは基本的には上記の書き方で検証をすることができますが、たまに非同期で実行され得る処理が「呼び出されない」ことを検証しておきたいケースがあります。
このテストを実装するにはどうすればよいでしょうか。
サンプルコードを少し修正して、引数によっては完了ハンドラを呼び出さずに関数を終了させるような実装に変更してみます。
class AsyncTask {
func execute(shouldCallCompletion: Bool, completion: @escaping () -> Void) {
if !shouldCallCompletion {
return
}
DispatchQueue.global().async(execute: completion)
}
}
テストコードは下記のようになります。
func testShouldNotCalled() throws {
let expectation = self.expectation(description: "wait for async task")
asyncTask.execute(shouldCallCompletion: false) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 0.1)
}
非同期関数の引数にfalseを受け渡しているので、この関数は完了ハンドラを呼び出さずに終了します。
そのためfulfillメソッドが呼び出されないことでwaitメソッドのタイムアウトを超過し、テストとしては失敗します。
「テストが失敗している=呼び出されていない」という判断はできるので、期待している挙動になってはいます。
しかし、自動テストとしてこの状態をOKとすることはできません。
こういった場合に利用できるのが、XCTestExpectationの isInverted
プロパティです。
このプロパティにtrueを設定しておくと、テストケースは「fullfillが呼び出されない」という挙動を期待するようになります。
func testShouldNotCalled() throws {
let expectation = self.expectation(description: "wait for async task")
+ expectation.isInverted = true
asyncTask.execute(shouldCallCompletion: false) {
expectation.fulfill()
}
wait(for: [expectation], timeout: 0.1)
}
1行設定を追加して実行すると、テストが成功するようになります。
これによって期待している「ハンドラが呼び出されなければテストを成功させる」という挙動にすることができました
注意点
この isInverted プロパティは「timeoutを超過するまでにfullfillが呼ばれなければ成功」という形でテストケースを扱うようになるため、timeoutに大きな値を設定していた場合、テストの実行時間が増加してしまう可能性があります。
そのため、必要最小限の値を設定しておくことをお勧めします。