CA MOBILE TECH BLOG

株式会社シーエー・モバイルのエンジニア・デザイナーの活動を綴るブログです

株式会社シーエー・モバイルの技術広報による、
技術に特化したブログです。
エンジニアとデザイナーの活動や思想を綴ってゆきます。

UnitTestを書いてCIでテストを自動化する

f:id:cam-engineer:20180531114556p:plain

こんにちは。マッチングサービスアプリ「 mimi 」のiOSエンジニアを担当している、Tです。

前回の記事では、fastlane と bitrise を使って、CI 環境の構築について書きました。

今回は、それに UnitTest を組み込んで、ViewModel 周りの単体テストを自動化したいと思います。 前回紹介した fastlane を使えば、簡単に組み込めます。


では、「 mimi 」のiOSプロジェクトで実際に使用している UnitTest の一部をご紹介します。

環境

・XCTest
・OHHTTPStubs
・Quick
・Nimble
・ObjectMapper

 「 mimi 」では、Quick を使用しています。これはお好みですが、RSpec / Specta / Ginkgo などを使ったことがある方は、使いやすいかと思います。

ちなみに Nimble というのは Quick とセットで、matcher の役割を果たします。

テストを書く

【 テストケース例 】

「 mimi 」では、様々な画面で下の画像のような振り分け機能が出てきます。

ここで " いいね " をすると、ActionAPI が叩かれます。その際、画面によってリクエストパラメータが変わるので、 画面とリクエストパラメータが一致しているかをテストします。

f:id:cam-engineer:20180531153958j:plain

import XCTest
import OHHTTPStubs
import Quick
import Nimble
import ObjectMapper
@testable import mimi

class AllocationTypeTests: QuickSpec {
    
    // 振り分け画面viewModel
    let viewModel = AllocationViewModel()
    
    var feelingPostParameters = [String: Any]()
    
    override func spec() {
        describe("ActionAPIを呼ぶ") {
            context("いいねをした場合") {
                // ユーザ一覧データ取得(スタブ)
                guard let url = Bundle(for: type(of: self)).url(forResource: "lover", withExtension: "json"),
                    let json = try? String(contentsOf: url),
                    let data = Mapper<AllocationData>().map(JSONString: json),
                    let lovers = data.allocateLovers else {
                    XCTFail("loversが取得できない")
                    return
                }
                // ユーザ一覧データをviewModelに格納
                self.viewModel.allocationLovers = lovers
                
                it("リクエストパラメータの値が正しいか") {
                    for index in 0..<AllocationType.count {
                        guard let allocationType = AllocationType(rawValue: index) else {
                            XCTFail("AllocationTypeが取得できない")
                            return
                        }
                        self.viewModel.allocationType = allocationType
                        
                        // ユーザ一覧データ配列位置
                        self.viewModel.displayIndex = 0
                        self.viewModel.categoryId = 1
                        
                        // リクエストパラメータ形成
                        self.feelingPostParameters = self.viewModel.setFeelingPostParams(.yes)
                        
                        expect(self.feelingPostParameters["lovers_user_id"] as? String).to(equal("LOVERSUSERID"))
                        
                        switch allocationType {
                        case .custom:             // 理想条件から探す, 各カテゴリ
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(3))
                            expect(self.feelingPostParameters["item_id" ] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        case .category:           // 理想条件から探す, 各カテゴリ
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(3))
                            expect(self.feelingPostParameters["item_id" ] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(equal(1))
                        case .newFace:            // 今日の新メンバー
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(1))
                            expect(self.feelingPostParameters["item_id"] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        case .recommend:          // 脈アリから探す
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(5))
                            expect(self.feelingPostParameters["item_id"] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        case .faceSearch:         // 写真から探す
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(6))
                            expect(self.feelingPostParameters["item_id"] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        case .pickup:             // 今日のピックアップ
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(2))
                            expect(self.feelingPostParameters["item_id"] as? Int).to(beNil())
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        case .appealItem:         // アピりんご
                            self.viewModel.displayIndex = 1
                            self.feelingPostParameters = self.viewModel.setFeelingPostParams(.yes)
                            expect(self.feelingPostParameters["lovers_user_id"] as? String).to(equal("LOVERSUSERID2"))
                            expect(self.feelingPostParameters["type"] as? Int).to(equal(21))
                            expect(self.feelingPostParameters["yes_type"] as? Int).to(equal(4))
                            expect(self.feelingPostParameters["item_id"] as? Int).to(equal(1))
                            expect(self.feelingPostParameters["category_id"] as? Int).to(beNil())
                        }
                    }
                }
                afterEach {
                    OHHTTPStubs.removeAllStubs()
                }
            }
        }
    }
    
}

複数の振り分けの画面を Enum で管理しているので、ループでまわして値のチェックを行います。

Quick を使ったテストの書き方についてはこちらをご覧ください。

また、この他のテストメソッドを知りたい場合はこちらで確認できます。

fastlane に組み込む

XCTest を fastlane に組み込むのは、めちゃくちゃ簡単です。

# ./fastlane/Fastfile
desc "XCTest実行"
lane :test do
    xctest(
        scheme: "mimi",
        destination: "platform=iOS Simulator,name=iPhone 8,OS=11.3",
    )
end

これだけです。

ちなみに、下記の記述も置いておくと、テストやビルドで失敗した時に slack に通知してくれます。

# ./fastlane/Fastfile
error do |lane, exception|
    slack(
        message: exception.message,
        success: false
    )
end

f:id:cam-engineer:20180531134732p:plain

これは slack にきたエラー通知ですが、どのテストのどの位置で失敗したかを見ることができます。

まとめ

アプリをアップデートする毎に全部の機能をデバッグするのは現実的ではないので、 こういった自動テストを拡充させることで、品質を担保することができるかと思います。

完全な安心感を得ることはできないですが、予期しない大事故はいくらか避けられるかと思うので、引き続きテストの拡充を行なっていきます。

次回は、UITestに挑戦したいです😇

::::::::: 関連記事 :::::::::

tech.camobile.com