Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

【Swift5】Timerのテストコードの備忘録

はじめに

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のテストコードは、元々自力で書けなかったのもあり、
先輩に教わったのですが、私には雰囲気を理解(?)するのも難しかったです。
今回調べて、理解し切れていない部分もあったり、調べ切れなかった部分もあるので、
もう少し学習を進めたいと思います。

参考

haru15komekome
Swift勉強中。初心者向けの記事を書きます😇 オンラインプログラミング教室を運営しています😇
https://ipallets.hatenablog.com/
i-enter
「効果」をつねに提供します。スマホアプリ開発No.1の実績。最新のIoTに対応した開発も行います。
https://www.i-enter.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away