はじめに
XCTestでTimerのテストコードを書く機会があったので、備忘録として記載します。
XCTestとは
iPhoneの単体テスト用のフレームワークです。
Xcodeでプロジェクトを生成した時にデフォルトで生成されます。
引用:iPhoneアプリ自動テスト入門 ~ XCTest+Swiftの単体テストを調べてみた
環境
- Xcode 11.1
- Swift5
①テストコードの書けるクラス(プロダクトコード側)のイニシャライザを作る
final class TimerManager: NSObject {
private let timerProvider: Timer.Type
private var timer: Timer?
init(timerProvider: Timer.Type = Timer.self) {
self.timerProvider = timerProvider
}
}
MockTimer(Timerのモック(テストに使うTimerの))側から使えるように、
イニシャライザの引数に.Type
(メタタイプ)を設定します。
(https://dev.classmethod.jp/smartphone/iphone/swift-3-type-of/)
Timerはクラスなので、.Type
を設定する必要があるみたいです。
Timer.self
で実際に値にアクセスしています。
メタタイプはクラスやストラクチャ、列挙型、プロトコルが何であるか判別するための特別な型です
引用:[[Swift 3] 型名を取得する]
②テストするメソッドを作る
今回は、Timerを使用した、簡単な2つの機能を実装しています。
①タイマーを削除するプライベートメソッド
②タイマーをメインスレッドで発火させるメソッド(10秒後に①を1度だけ呼ぶ)
追記:テスト時にタイマーが発火されない事象が発生したため、今回はメインスレッドで発火させています。
import Foundation
final class TimerManager: NSObject {
private let timerProvider: Timer.Type
private var timer: Timer?
init(timerProvider: Timer.Type = Timer.self) {
self.timerProvider = timerProvider
}
/// タイマー削除
@objc private func stopTimer() {
self.timer?.invalidate()
self.timer = nil
}
/// タイマー作成
/// - Parameter flg: タイマー
func scheduledTimer(flg: Bool) {
if flg {
// タイマーはメインスレッドで発火する
DispatchQueue.main.async {
self.timer = self.timerProvider.scheduledTimer(withTimeInterval: TimeInterval(10),
repeats: false,
block: { _ in self.stopTimer()
})
}
}
}
}
テストするメソッドに使用したメソッドは以下になります。
◆ scheduledTimer(withTimeInterval:repeats:block:)・・・タイマーを作成するタイプメソッド。
@interface NSTimer : NSObject
+ (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)interval repeats:(BOOL)repeats block:(void (^)(NSTimer *timer))block API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));
◆ invalidate()・・・タイマーを削除するインスタンスメソッド。
@interface NSTimer : NSObject
- (void)invalidate;
③MockTimerクラスを作る
Timerを継承しているMockTimerクラスを作ります。
import Foundation
class MockTimer: Timer {
var block: ((Timer) -> Void)!
static var timer: MockTimer?
static var callCountInvalidate = 0
static var callCountScheduledTimer = 0
override func fire() {
print("MockTimer発火")
block(self)
}
override open class func scheduledTimer(withTimeInterval interval: TimeInterval,
repeats: Bool,
block: @escaping (Timer) -> Void) -> Timer {
let mockTimer = MockTimer()
mockTimer.block = block
MockTimer.timer = mockTimer
MockTimer.callCountScheduledTimer += 1
print("ScheduledTimer呼ばれた回数:\(MockTimer.callCountScheduledTimer)")
return mockTimer
}
override open func invalidate() {
MockTimer.callCountInvalidate += 1
print("Invalidateが呼ばれた回数:\(MockTimer.callCountInvalidate)")
}
}
@escapeingを使用することで、受け取ったクロージャをメソッド内ですぐに実行せずに、
メソッドの外側で実行することができるようになるみたいです。
④テストコードを書く
実際に書いたテストコードです。(これでテスト通りました。)
import XCTest
class TimerManagerTests: XCTestCase {
override func setUp() {
super.setUp()
// Put setup code here. This method is called before the invocation of each test method in the class.
}
override func tearDown() {
super.tearDown()
// Put teardown code here. This method is called after the invocation of each test method in the class.
}
func test_stopTimer() {
// stopTimerを間接的に呼び出す
let timerManager = TimerManager(timerProvider: MockTimer.self)
timerManager.scheduledTimer(flg: true)
let beforeCallCountInvalidate = MockTimer.callCountInvalidate
let exp = expectation(description: "StopTimer")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
MockTimer.timer?.fire()
exp.fulfill()
}
wait(for: [exp], timeout: 0.1)
_ = XCTContext.runActivity(named: "MockTimer.invalidate is called.", block: { _ in
XCTAssertEqual(beforeCallCountInvalidate + 1, MockTimer.callCountInvalidate)
})
// 初期化する
MockTimer.callCountInvalidate = 0
}
func test_scheduledTimer() {
let beforeCallCountScheduledTimer = MockTimer.callCountScheduledTimer
let timerManager = TimerManager(timerProvider: MockTimer.self)
XCTContext.runActivity(named: "引数のflgがtrueの時", block: { _ in
timerManager.scheduledTimer(flg: true)
let exp = expectation(description: "ScheduledTimer Fire.")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
MockTimer.timer?.fire()
exp.fulfill()
}
wait(for: [exp], timeout: 0.1)
_ = XCTContext.runActivity(named: "WearBluetoothService.stopTimer is called.", block: { _ in
XCTAssertEqual(beforeCallCountScheduledTimer + 1, MockTimer.callCountScheduledTimer)
})
// 初期化する
MockTimer.callCountScheduledTimer = 0
})
XCTContext.runActivity(named: "引数のflgがfalseの時", block: { _ in
timerManager.scheduledTimer(flg: false)
let exp = expectation(description: "ScheduledTimer Fire.")
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) {
MockTimer.timer?.fire()
exp.fulfill()
}
wait(for: [exp], timeout: 0.1)
_ = XCTContext.runActivity(named: "WearBluetoothService.stopTimer is called.", block: { _ in
XCTAssertEqual(0, MockTimer.callCountScheduledTimer)
})
// 初期化する
MockTimer.callCountScheduledTimer = 0
})
}
}
今回は、タイマーをメインスレッドで使用していたため、
タイマーを発火するタイミングを考慮し、遅延処理周りにはwait(for:timeout:)を使用しました。
補足(テストコード基本のき)
- setUp()・・・各テストの開始前に呼ばれるメソッド。
- tearDown()・・・各テスト実行後に呼ばれるメソッド。
まとめ
Timerのテストコードは、元々自力で書けなかったのもあり、
先輩に教わったのですが、私には雰囲気を理解(?)するのも難しかったです。
今回調べて、理解し切れていない部分もあったり、調べ切れなかった部分もあるので、
もう少し学習を進めたいと思います。