45
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

DeNA IPプラットフォーム事業部Advent Calendar 2017

Day 9

マンガボックスiOSアプリ開発におけるユニットテストの設計と実装

Last updated at Posted at 2017-12-08

この記事はDeNA IPプラットフォーム事業部 Advent Calendar 2017のエントリです。

こんにちは。@kenmazです。
DeNAでマンガボックスというiOSアプリを開発しています。
マンガボックスは最近4周年を迎えました。めでたいですねー。

さて、今回はそんなマンガボックスのiOSアプリにおける、ユニットテスト実装時の方針みたいなものをご紹介します。
とはいえ割とテスト関連の書籍などで一般的に言われていることをなぞっている程度の内容を思いつくままに書いていく感じなので、サラサラーッと読んでいただければと思います。

前提となるアプリの設計

マンガボックスはつい先日4周年を迎えましたが、古き良きApple-MVCから、FatViewControllerを経て、DDD、iOSクリーンアーキテクチャ等をチラ見しつつ、MVVMに影響されつつ、ちょっとずつ設計変更しながら生き永らえている感じのアプリになっております。だいたい、どの画面、どの機能も以下のような構造で実装されています。

View - ViewController - ViewModel - Model - Repository

各レイヤの役割についてはだいたい想像がつくとは思います。場合によっては、ViewControllerが直接Repositoryレイヤを触っていたり、ViewModelModelの間に謎のServiceみたいなレイヤが存在していたり、Routerレイヤなるものが登場して画面遷移の制御を行ったりと、そこまでアプリ全体を通じて厳密に特定のアーキテクチャに従っている、って感じでもないですが、大体こんな感じの設計になってます。

テストフレームワークは標準のXCTestを使っています。
それでは、まずは各レイヤをどのようにテストしているか、順を追って見ていきましょう。

Modelレイヤのテスト

Modelレイヤは、そのアプリの中心となるデータや、固有のビジネスロジックが実装されているレイヤです。実装としてはNSManagedObjectのサブクラスや、アプリ固有の概念を表すオブジェクトなどから構成されています。マンガボックスであれば例えば以下のようなものです。

  • ある漫画のエピソードの公開条件の制御
    • 各種フラグ、ユーザ状態、日付情報がどういう条件を満たしていればその作品が閲読可能か、といったルールの実装など。
  • 作品リストの表示順、表示内容の制御
    • 各作品の並び順、広告挿入位置の決定、A/Bテストフラグによる出し分け、などなど
  • 検索機能のロジック
    • 検索キーワードとして与えられた文字列を前処理し、DataSotreレイヤに検索処理を要求し、結果を受け取り、なんらかの整形を行い、出力として返す、という一連の実装
  • 広告の表示内容・タイミングの制御
    • 広告系SDKとのやりとり、表示条件の制御
  • 有料コミックス購入状態の管理
  • ログイン状態の管理

一例として、作品リストの表示順、表示内容を制御するsortedEpisodesForGridView()メソッドを考えてみます。テストコードは以下のようになります。

func testSortedEpisode_with_index() {
    ...
    let ep1 = MJEpisode.createActiveMangaEpisode(withEpisodeId: 1)
    ep1.publishDate = Date.dateFromString(yyyymmdd: "2013-10-09")
    ep1.episodeIndex = 2
    let ep2 = MJEpisode.createActiveMangaEpisode(withEpisodeId: 2)
    ep2.publishDate = Date.dateFromString(yyyymmdd: "2013-10-07")
    ep2.episodeIndex = 1
    let ep3 = MJEpisode.createActiveMangaEpisode(withEpisodeId: 3)
    ep3.publishDate = Date.dateFromString(yyyymmdd: "2013-10-11")
    magazine.addEpisodes(Set([ep1, ep2, ep3]))
        
    let sorted = magazine.sortedEpisodesForGridView()
    XCTAssertNotNil(sorted)
    XCTAssertEqual(sorted[0].episodeId, ep3.episodeId)
    XCTAssertEqual(sorted[1].episodeId, ep2.episodeId)
    XCTAssertEqual(sorted[2].episodeId, ep1.episodeId)
}

コードの前半部分が前提条件、後半部分が検証のコードになります。この例では以下のようなことをテストしています。

  • 前提条件
    • エピソードが3件存在し、それぞれに公開日時と表示順制御のフラグが与えられている
  • 検証内容
    • 公開日時と表示順フラグに応じて、正しい順序に並んでいるか

このように、Modelレイヤでは様々なフラグやプロパティの値を前提条件として与え、それらを元に動作するメソッドが正しい結果を返すか、といったことをテストします。

Modelレイヤは、条件分岐もある程度複雑かつ、ビジネス要件上重要なロジックが多い部分です。改修のたびに目視でチェックだとさすがに厳しくなってくることは容易に想像できます。

UIやAPI通信とは切り離されておりテストも書きやすいことが多い部分なので、対費用効果の面でもぜひ書いておきたいレイヤです。

Reposityレイヤのテスト

API通信、DB(CoreData)の読み書きなど、アプリで扱うデータの大元を管理するレイヤです。StoreKitなどiOS固有のフレームワークなど、外部環境とのやりとりもここのレイヤで取り扱っています。

WebAPIにリクエストを投げて、レスポンスをCoreDataモデルにマッピングしてsaveする、といったものはこのレイヤでの典型的な処理の一つです。

private func update(comicInfo: JSON, context: NSManagedObjectContext) -> MJComic? {
    let comic = self
    comic.availableDate = availableDate as NSDate?
    comic.publishDate = NSDate.parse(comicInfo["publishDate"]) as NSDate?
    comic.updatedDate = NSDate.parse(comicInfo["updatedDate"]) as NSDate?
    comic.purchasable = comicInfo["purchasable"] as? NSNumber ?? 0
    ...
} 

このレイヤでの典型的なテストは以下のようなものです。

  • 前提条件
    • APIレスポンスのJSONをテキストデータとしてプロジェクト内に配置
    • APIClientをstubして、↑のテキストデータを返すようにする
  • 検証
    • リクエストの発行からCoreDataへのレスポンスのマッピング処理を実行
    • 想定通りにCoreDataモデルにレスポンスデータがマッピングされているかを検証
func testComicCreateOrUpdate() {
    let jsonFileName = "comics_response.json"
    let json = MJTestHelper.loadJSONFromTestBundle(jsonFileName)
    let context = NSManagedObjectContext.mr_default()
    let b = MJComic.createOrUpdateComicWithJson(comicJson, in: context)!
    ...
    XCTAssertEqual(1434525463, b.imageUpdatedDate!.timeIntervalSince1970)
    XCTAssertEqual(1428764400, b.availableDate!.timeIntervalSince1970)
    XCTAssertEqual(6502, b.comicId!)
    XCTAssertFalse(b.hidden!.boolValue)
    XCTAssertEqual(1428764400, b.publishDate!.timeIntervalSince1970)
    XCTAssertEqual("講談社", b.publisherName)
    XCTAssertTrue(b.purchasable!.boolValue)
    XCTAssertNil(b.unreadableDate)
    XCTAssertEqual(1460980846, b.updatedDate!.timeIntervalSince1970)
    XCTAssertEqual(1, b.volume)
    XCTAssertEqual("<<作品タイトル>>", b.title)
    ...

ちなみにこのへんの処理は、最近はCodableを使って自動でマッピングさせるように移行をすすめています。その際も、テストコードがあればすぐにバグを検知できるので、自信をもって移行作業をすすめていけますね。

ViewModelレイヤのテスト

viewModelレイヤでは、画面表示やタップ操作に対するテストを書きます。

マンガボックスでは、ビューに関する状態はすべてviewModelが管理しています。viewControllerviewviewModelの状態を画面に反映させるだけです。またviewControllerviewで発生したタップイベントなども、最終的にはviewModelが受け取り、処理し、必要に応じて自身の状態を変化させviewModeldelegateを経由して、view/viewControllerに通知します。viewModelUIKitに依存しない作りになっており、テストコードの実装は比較的容易です。

viewModelのテストでは、画面の状態をチェックするだけの単純なテストから、シナリオテストのような実際のアプリの一連の操作とその結果を確認するようなテストを書くこともできます。

ここでは、購入したコミックスを管理・検索する「本棚」画面について考えてみます。「本棚」画面のviewModelであるMJBookshelfViewModelクラスのテストコードは以下のようになります。

    /**
     ログイン中、購入済みコミックスが3件ある状態で検索して、さらに1件目をタップすると詳細画面に遷移する
     */
    func testLoggedInHasPaidComicsAndSearch() {
        
        //前提条件:3件のコミックスが購入済み
        createComic(1, title: "aa11", comicId: 1, paid: true)
        createComic(2, title: "aa22", comicId: 2, paid: true)
        createComic(3, title: "bb33", comicId: 3, paid: true)
        
        //テスト対象のビューモデルを初期化(通信周りはスタブを使用)
        let bookshelf = MJBookshelfViewModel()
        bookshelf.bookshelfService.comicRepository = ComicRepositoryStub()
        bookshelf.bookshelfService.userRepository = UserRepositoryStub(loggedIn: true)
        
        //delegateで各種検証+追加アクションを実行
        let delegateMock = BookshelfDelegateMock(expectation: expectation(description: "searched!!!"))
        delegateMock.onStateDidUpdateGenerator = [
            { (expectation, viewModel) in
                //(1)画面が表示され..
                print("loaded and updateUI")
            },
            { (expectation, viewModel) in
                //(2)キャッシュをもとに画面を表示しつつサーバーから最新情報を取得し...
                print("start reload")
            },
            { (expectation, viewModel) in
                //(3)サーバーから取得した情報を取得完了し画面に反映されたタイミングで...
                print("reloaded")
                XCTAssertEqual(3, viewModel.entries.count)
                
                //(4)検索ボタンをタップし...
                viewModel.searchButtonDidTap()
            },
            { (expectation, viewModel) in
                print("search button tapped")
                
                //(5) 検索ボックスに"aa"と入力し...
                viewModel.search("aa")
            },
            { (expectation, viewModel) in
                print("searching")
                
                //(6)検索開始直後は検索結果は0件だが...
                XCTAssertEqual(0, viewModel.entries.count)
            },
            { (expectation, viewModel) in
                print("searched")
                
                //(7)検索処理が終わるとすぐに2件ヒットし...
                XCTAssertEqual(2, viewModel.entries.count)
                
                //(8)さらに検索結果の0番目をタップし...
                viewModel.selectEntry(viewModel.entries[0])
            },
            { (expectation, viewModel) in
                print("search result selected")
            }
            ].makeIterator()
        delegateMock.onShowComicSeriesDetailGenerator = [
            { (expectation, mangaId, title, includeHidden) in

                //(9)詳細画面が表示され、mangaId=1が表示されている (テスト終わり)
                print("onShowComicSeriesDetail")
                XCTAssertEqual(1, mangaId)
                XCTAssertEqual("aa11", title)
                expectation.fulfill()
            }
            ].makeIterator()
        
        //本棚表示開始
        bookshelf.delegate = delegateMock
        bookshelf.viewWillAppear()
        
        //5秒以内に完了しなければ失敗扱い
        waitForExpectations(timeout: 5) { (error) -> Void in XCTAssert(true) }
    }

特別にコメントを多めに追加したのでわかるかと思いますが、

//(1)画面が表示され..
//(2)キャッシュをもとに画面を表示しつつサーバーから最新情報を取得し...
//(3)サーバーから取得した情報を取得完了し画面に反映されたタイミングで...
//(4)検索ボタンをタップし...
//(5) 検索ボックスに"aa"と入力し...
//(6)検索開始直後は検索結果は0件だが...
//(7)検索処理が終わるとすぐに2件ヒットし...
//(8)さらに検索結果の0番目をタップし...
//(9)詳細画面が表示され、mangaId=1が表示されている (テスト終わり)

というシナリオのテストになっています。

viewModelの状態が変わるたびにdelegateのデリゲートメソッドが呼ばれるので、このテストでは、モックとして振る舞うデリゲートオブジェクトを実装しています。その内部でXCTAssertXXXを使って検証を行い、さらにユーザ操作を模倣したような追加アクションをviewModelに対してさらに実行することで、シナリオテストを進めています。テスト用のデリゲートオブジェクトの実装は以下のようになっています(抜粋)。

    class BookshelfDelegateMock: MJBookshelfViewModelDelegate {
        let expectation:XCTestExpectation
        var onStateDidUpdateGenerator:IndexingIterator<[(_ expectation:XCTestExpectation, _ viewModel:MJBookshelfViewModel)->Void]>?
        
        init(expectation:XCTestExpectation) {
            self.expectation = expectation
        }
        
        func stateDidUpdate(_ viewModel:MJBookshelfViewModel){
            if let block = onStateDidUpdateGenerator?.next() {
                block(expectation, viewModel)
            }
        }

デリゲートメソッドが呼ばれるたびに、異なる検証+追加アクションを行う必要があるので、テストコードではそれらの処理をそれぞれblockとして回数分用意しておき、onStateDidUpdateGeneratorに配列として保持させておきます。そして実際のコールバックメソッドstateDidUpdate(viewModel)が呼ばれるたびに、onStateDidUpdateGenerator.next()を呼び、先頭から順番に検証blockを実行して行く、という流れになっています。

シナリオテストやE2Eテストは一般的に実装ハードルが高くなりがちですが、viewModelのテストという形式であれば、UIKitなど固有のUIフレームワークにとらわれることなく、アニメーションの完了待ち合わせなどといったややこしい問題に直面することもなく、Swiftの標準ライブラリの範囲内でテストを書けるので、テストの記述が容易になります。

View/ViewControllerのテスト

Appiumや標準のUITestに何度もチャレンジしてきましたが、ちょっとでも込み入ったテストコードを書こうとすると、すぐに暗黒面に落ちてしまうので私は諦めました。来世に期待

その他

さて、ここまで各レイヤのテストについて説明してきました。ここからはテストに関する雑多な話題やプチテクニック的なものを紹介していきます。

モックを使ったテスト1(OCMockによるMethod Swizzling)

現在時刻に応じて挙動が変わるような処理のテストを行う際は、Objective-CのライブラリOCMockを使用しています。

func testIsPublished() {
    let mock = MJMockHelper.mock(forNSDateNow: "2017-03-10")
    defer {
        mock?.stopMocking()
    }

    //現在日時が2017-03-10 00:00:00であることを想定したテスト
    ...
}

MJMockHelperは以下のような実装になっています。

@implementation MJMockHelper

+ (OCMockObject*)mockForNSDate:(NSDate*)date {
    OCMockObject* mock = [OCMockObject mockForClass:[NSDate class]];
    [[[mock stub] andReturn:date] date];
    return mock;
}

+ (OCMockObject*)mockForNSDateNow:(NSString*)dateString {
    return [self mockForNSDate:[NSDate dateFromString:dateString]];
}

+ (OCMockObject*)mockForNSDateNowWithYYYYMMDDHHMMSS:(NSString*)dateString {
    return [self mockForNSDate:[NSDate dateFromStringWithYYYYMMDDHHMMSS:dateString]];
}

モックを使ったテスト2(protocolによる実装の分離と依存性注入)

Objective-Cの時代は言語の動的な性質を活かして、前述のようにOCMockなどを使ってメソッドの結果を強引に差し替えてテストを書くようなことが多かったですが、Swift単体ではそのようなことは不可能です。そのため、最近は差し替えたいコードはprotocolを定義してインターフェースと実装を分離し、テスト時にはテスト用の実装に差し替える、といったテストをシコシコ書くようになりました。

protocol MJComicAPI {
    func updateComics(comicIds: [MJComic.ComicId], ...)
}

class MJComicAPIImpl: MJComicAPI {
    func updateComics(comicIds: [MJComic.ComicId], ...) {
        //API通信処理
    }
}

class MJComicRepository {
    init(comicAPI: MJComicAPI = MJComicAPIImpl()) {
        self.comicAPI = comicAPI
    }
    ...
}

//プロダクトコード
let repo = MJComicRepository()
...

//テストコード
class MJComicAPIImpl: MJComicAPI {
    func updateComics(comicIds: [MJComic.ComicId], ...) {
        //ダミー処理
    }
}
let stub = MJComicAPIStub()
let repo = MJComicRepository(comicAPI: stub)
...

Objective-C ➔ Swift移行を安全に進めるためのテスト

リファクタリングを安全に行うにはテストコードによる保護が非常に有用です。iOSアプリ開発においてはロジックのリファクタリングなどももちろん頻繁に行いますが、それと同じくらいに発生するのが Objective-CからSwiftへの移行作業 であったり Swift2からSwift3への移行 であったりします。それらの移行作業も立派なリファクタリングの一種と言えましょう。

マンガボックスでは、サーバーから取得したマンガのメタデータ等をCoreDataに保持し、随時更新しています。データ更新周りはアプリの中でも非常に重要で複雑な部分ですが、そこはすべてObjective-Cで書かれていました。しかし、データ量も増え、データ構造も複雑になっており、さらに性能面でも問題が出てきたのでリファクタリングの必要が出てきました。そこでデータ更新周りのコードをすべてSwift化することで、型安全性/null安全性を活かして複雑なデータ構造に立ち向かうことにしました。さらにCPU負荷を下げて性能を向上させるためにロジック自体の最適化もあわせて行うことにしました。

このような大規模な改修をエイヤで一気に書き換えてしまうのは非常にリスクが高いです。そこで、Objective-Cのコードは維持したまま、Swift化したコードも実装し、フューチャーフラグを使って一部のユーザから徐々にSwift版のコードに切り替えていく、という戦略を取ることにしました。

このような場面でもユニットテストは非常に役立ちました。Objective-C版とSwift版は内部実装はかなり異なりますが、input/outputはその性質上まったく同じです。input=サーバーから降ってくるJSONそのもの、output=最終的なCoreDataの状態、です。

ということで、以下のようなテストを書きながら移行作業用を進めました。

    /**
     CoreDataモデル更新メソッドについて、新・旧メソッドの実行により同じ結果を出力するかどうかのテスト
     */
    func testCreateOrUpdate_NewAndLegacyConvertiblity() {

        //旧実装(objc)
        let contextLegacy = NSManagedObjectContext.mr_()
        let episodesLegacy = MJEpisode._createOrUpdate(withJson: json, in: contextLegacy)

        //新実装(swift)
        let contextNew = NSManagedObjectContext.mr_()
        let episodesNew = MJEpisode.createOrUpdate(jsonModels, in: contextNew)!

        XCTAssertEqual(episodesNew.count, episodesLegacy.count)
        for (a, b) in zip(episodesNew, episodesLegacy) {
            XCTAssertEqual(a.adAppId, b.adAppId)
            XCTAssertEqual(a.adType, b.adType)
            XCTAssertEqual(a.adUrl, b.adUrl)
            XCTAssertEqual(a.anchorPosition, b.anchorPosition)
            XCTAssertEqual(a.availableDate, b.availableDate)
            XCTAssertEqual(a.contentType, b.contentType)

            //新・旧実装の結果比較が永遠と続く...

このように、実装の移行作業を安全にすすめるためにもテストコードは役立ちます。

ちなみに、更新周りの完全移行はようやく最近完了し、上記テストコードも不要となったので、いまはその役目を終えてめでたく削除しました。

リファクタのお供にテスト

上でも書きましたが、リファクタリングにテストコードは書かせません(若かりし頃、前職のベテランエンジニアが、「テストコードが無いリファクタリングはリファクタリングとは言えない」と言ってたことを妙に覚えている)。

例えばコードレビューにおいて、「動作は正しそうですが、ロジックが複雑なのでもっと簡潔に書けないですかねー」などと突っ込まれた場合は、

  • まずテストコードを書いて、自分のゴミコードを保護する
  • 脳みそを絞ってきれいに書き直す
  • コードが簡潔になり、しかも壊れてないことが保証されている、完全無欠コードが完成

という流れを踏みましょう。

バグった箇所から書くテスト

テストを書くのは場合によっては非常に面倒であったり、工数に余裕がない場合は書くのが大変になることがあります。そんな場合は割り切ってテストなしで突っ切る(TODO:あとでテスト書くコメントを添えて・・)こともありますが、そんな状況であっても、「バグった箇所のテストはなるべく書く」という方針を守るようにしています。

例えば、実装が完了しQAフェーズになって、QA担当者からバグを指摘された場合は

  • 指摘されたバグ内容を確認し、ざっくりあたりをつける
  • バグを再現させるテストコードを書いてみる
  • 想定どおりテストがfailすることが確認できたら、コードの修正に取り掛かる
  • 修正が完了したら再度テストコードを動かしてみる
  • 無事テストがパスしたら、自信をもってバグ修正完了、PRを投げてマージして再QAにかける
    という流れになります。

この方針の良いところは、もちろんコードの修正に自信を持つことができる、という点もありますが、さらにそこで書いたテストコードは「存在意義の高いテストコード」に将来的になる可能性が高い、という点が挙げられます。つまり、

  • バグを出した、ということは何かしらそのコードに複雑な部分や見落としやすい要素が含まれているはず
  • なので将来、同じようなバグを再度埋め込んでしまう可能性は高い
  • そのとき、このテストコードが自分を救ってくれる可能性が高い

という考え方です。この方針に最低限従っておけば、テストのカバレッジも自然と高まりますし、おすすめです(特に私はバグをいっぱい出すことには定評がありますから)。

カバレッジを見ながらテスト

最近のXcodeではエディタの右側にテストカバレッジが表示されるようになったので、テスト抜け漏れに気づきやすいのでおすすめです(ただし、カバレッジ100%厨への入り口にもなりやすいのでご注意)

_86734bda-d373-11e7-92a3-d61ff7511ca4.png

CIに見守ってもらいながらのテスト

JenkinsでもCircleCIでもなんでもいいので、コミットやマージを行うたびにテストを走らせて、テストがコケたらslackに通知するようにしておきましょう。
この仕組がなければ、いくらテストをしっかり書いていても、そのうちテストコードを書く文化自体が廃れていきます。

まとめ

以上、マンガボックスiOSアプリでテストを書くときの方針や、普段テストについて考えていることをつらつらとご紹介しました。
この記事を読んでいるあなた、今スグに $open /Application/Xcode.app & [File] > [New] > [File] > [Unit Test Case Class] !!!

次回の担当は同じチームのイケてるサーバーエンジニア!kaneUさんです!

マンガボックスの開発に関するその他資料

宣伝

  • マンガボックスチームでは、テストもしっかり書きたいソフトウェアエンジニアを募集しています。
  • 現在はサーバーサイドエンジニアを積極募集中!
  • iOS/Androidエンジニアも大歓迎です
  • 気になった方はお気軽に連絡ください
45
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
45
28

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?