モックオブジェクトをより便利にする (Try! Swift2017)

  • 23
    いいね
  • 0
    コメント

はじめに

Try! Swift2017に参加しました。
とても勉強になったテーマの1つである「モックオブジェクトをより便利にする」をご紹介します。

モックオブジェクトを使ってユニットテストするコツをJon Reidさんが発表されました。

なぜモックオブジェクトを利用するか?

・実オブジェクトを利用すると時間がかかるため
・実オブジェクトが存在しない場合もあるため

今回ご説明する例

ウェーターがお客様からの注文を受け、コックに注文を正しく伝えられるかをテストします。
メニューはラーメンです。

登場人物

登場人物 説明
Waiter ウェーター 
CookProtocol ラーメンを作るプロトコル
RealCook 実オブジェクトのコック
MockCook モックオブジェクトのコック 

テスト対象は、Waiterクラスです。
プロトコルを利用することで、
実オブジェクトでも、モックオブジェクトでも利用することができます。

1.準備

1.1.ラーメンの種類

ラーメンの種類は、塩、醤油、味噌、豚骨の4種類とします。

RamenSoup.swift
import Foundation

enum RamenSoup {
    case shio       //塩
    case shoyu      //醤油
    case miso       //味噌
    case tonkotu    //とんこつ
}

1.2. プロトコルの定義

ラーメンを作るプロトコルを定義します。

プロトコルでは、何杯か、スープの種類は、トッピングは何かを定義します。
トッピングは複数個選べるものとします。

CookProtocol.swift
import Foundation

protocol CookProtocol {

    func cookRamen(
        bowls: Int,                 // 何杯
        soup: RamenSoup,            // スープの種類
        extras: [String]) -> Void   // トッピング
}

1.3. ウェータークラスの定義

ウェーターのクラスを定義します。
また、メソッドとして注文するメソッドを定義します。

今回は、味噌ラーメンを2杯、わかめと卵をトッピングとしてのせる例で説明します。
(ハードコーディングします。)

Waiter.swift
import Foundation

struct Waiter {
    let cook: CookProtocol

    /// ウエーターがラーメンを注文する (味噌ラーメンを2杯、わかめと卵をトッピング)
    func order() {        
        cook.cookRamen(bowls: 2, soup: .miso, extras: ["wakame","tamago"])
    }
}

2. ユニットテスト

それでは、本題のユニットテストをしていきます。

2.1. 【パターン1】 Booleanを利用して対象メソッドが呼ばれたかをテストする

Waiterのorderメソッドをテストします。
今回は、orderメソッドが呼ばれたか否かを確認します。

2.1.1. モック

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenWasCalled = false

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenWasCalled = true
    }
}

2.1.2. ユニットテスト

WaiterTests.swift
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        XCTAssertTrue(mocCook.cookRamenWasCalled)
    }
}

よくあるユニットテストのパターンだと思います。
このコードは、orderメソッドが呼ばれたかの確認はできますが、
MockCookクラスに情報が保存されないため、何回呼ばれたかの確認はできません。

2.2. 【パターン2】 何回呼ばれたかをテストする

ここでは、呼ばれた回数を保存し、何回呼ばれたかをテストできるように改良します。
1回しか呼ばれる予定がないはずが、複数回呼ばれてしまうケースを防げます。

2.2.1. モック

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenCallCount = 0

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenCallCount += 1
    }
}

2.2.2. ユニットテスト

WaiterTests.swift
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        XCTAssertEqual(mocCook.cookRamenCallCount, 1)
    }
}

2.3. 【パターン3】 直近入力された引数の情報もテストする

ここでは、入力された引数の情報を記録し、テストします。
例えば、オーダーミス(注文するラーメンの種類やトッピングなど)を確認します。

2.3.1. モック

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenCallCount = 0
    var cookRamenLastBowls = 0
    var cookRamenLastSoup: RamenSoup?
    var cookRamenLastExtras: [String] = []

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenCallCount += 1
        cookRamenLastBowls = bowls
        cookRamenLastSoup = soup
        cookRamenLastExtras = extras
    }
}

2.3.2. ユニットテスト

WaiterTests.swift
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        XCTAssertEqual(mocCook.cookRamenCallCount, 1)
        XCTAssertEqual(mocCook.cookRamenLastBowls, 2)
        XCTAssertEqual(mocCook.cookRamenLastSoup, .miso)
        XCTAssertEqual(mocCook.cookRamenLastExtras, ["wakame","tamago"])        
    }
}

(1) ヘルパーメソッドを用意する

ここで、様々のケースに対して、何度も同じようなアサートを書かぬように、
ヘルパー用のメソッド(verifyCookRamen)を定義し、テストでそのヘルパーメソッドを利用します。

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenCallCount = 0
    var cookRamenLastBowls = 0
    var cookRamenLastSoup: RamenSoup?
    var cookRamenLastExtras: [String] = []

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenCallCount += 1
        cookRamenLastBowls = bowls
        cookRamenLastSoup = soup
        cookRamenLastExtras = extras
    }

    func verifyCookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {

        XCTAssertEqual(cookRamenCallCount, 1)
        XCTAssertEqual(cookRamenLastBowls, bowls)
        XCTAssertEqual(cookRamenLastSoup, soup)
        XCTAssertEqual(cookRamenLastExtras, extras)
    }
}
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        mocCook.verifyCookRamen(bowls: 2, soup: .miso, extras: ["wakame","tamago"])
    }    
}

(2) どこから呼ばれたかもテストする

ここでは、予期せぬ箇所から呼ばれることも考えられるため、
期待した箇所から呼ばれているかテストするために、ファイル名と行数もテストします。

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenCallCount = 0
    var cookRamenLastBowls = 0
    var cookRamenLastSoup: RamenSoup?
    var cookRamenLastExtras: [String] = []

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenCallCount += 1
        cookRamenLastBowls = bowls
        cookRamenLastSoup = soup
        cookRamenLastExtras = extras
    }

    func verifyCookRamen(bowls: Int, soup: RamenSoup, extras: [String],
                         file: StaticString = #file,
                         line: UInt = #line
                         ) {

        XCTAssertEqual(cookRamenCallCount, 1, file: file, line: line)
        XCTAssertEqual(cookRamenLastBowls, bowls, file: file, line: line)
        XCTAssertEqual(cookRamenLastSoup, soup, file: file, line: line)
        XCTAssertEqual(cookRamenLastExtras, extras, file: file, line: line)
    }
}
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        mocCook.verifyCookRamen(bowls: 2,
                                soup: .miso,
                                extras: ["wakame","tamago"])
    }    
}

(3) 順番が任意の入力もテストする

現状のユニットテストでは、本来トッピングの順番は関係ないのですが、
特定の順番以外はNGとなってしまいます。
そこで、配列の順序を意識しないように、ユニットテストに改良します。

MockCook.swift
import XCTest
@testable import ios_swift_moc

class MockCook: CookProtocol {

    var cookRamenCallCount = 0
    var cookRamenLastBowls = 0
    var cookRamenLastSoup: RamenSoup?
    var cookRamenLastExtras: [String] = []

    func cookRamen(bowls: Int, soup: RamenSoup, extras: [String]) {        
        cookRamenCallCount += 1
        cookRamenLastBowls = bowls
        cookRamenLastSoup = soup
        cookRamenLastExtras = extras
    }

    func verifyCookRamen(bowls: Int, soup: RamenSoup,
                         extrasMatcher: (([String]) -> Bool),
                         file: StaticString = #file,
                         line: UInt = #line
                         ) {

        XCTAssertEqual(cookRamenCallCount, 1, file: file, line: line)
        XCTAssertEqual(cookRamenLastBowls, bowls, file: file, line: line)
        XCTAssertEqual(cookRamenLastSoup, soup, file: file, line: line)
        XCTAssertTrue(extrasMatcher(cookRamenLastExtras),
                       "extras was \(cookRamenLastExtras)",
                        file: file,line: line)
    }
}
WaiterTests.swift
import XCTest
@testable import ios_swift_moc

class WaiterTests: XCTestCase {

    override func setUp() {
        super.setUp()
    }

    override func tearDown() {
        super.tearDown()
    }

    func testOrder_ShouldCookRamen() {        

        let mocCook = MockCook()
        let waiter = Waiter(cook: mocCook)

        waiter.order()        
        mocCook.verifyCookRamen(bowls: 2,
                                soup: .miso,
                                extrasMatcher:  { extras in
                                    extras.count == 2 &&
                                    extras.contains("wakame") &&
                                    extras.contains("tamago")

        })
    }    
}

このようにすると、トッピングのわかめと卵の順番を変えてもテストが通ります。
正確にはトッピングが2個、わかめと卵であればOKです。

Closureを利用して、トッピング件数と配列内に特定の文字列が含まれるかをチェックしています。

まとめ

モックオブジェクトを利用したユニットテストのコツは下記の4点です。

  1. テスト対象メソッドは、何回呼ばれたかをテストする。
  2. テストコードは冗長にならないように、ヘルパーメソッドを利用する。
  3. 呼ばれた場所もテストする。(ファイル名、行数)
  4. フラジャイルテストにならないように工夫する。

なお、ユニットテストは、「大事なものに集中し、そうでないものは無視しましょう」ということでした。
今回は、とても勉強になり、早速取り入れていければと思います。

ソースコードは、こちらにあげてあります。

他にも「Hamcrest Matchers」を利用した例もご紹介されました。
これについては割愛します。

参照元

http://qualitycoding.org/tryswift2017