LoginSignup
14
10

More than 1 year has passed since last update.

[swift]API通信を利用しているクラスをUnitTestしてみる[stubとmockの使い方]

Posted at

はじめに

外部依存のあるコンポーネントはUnitTestが書きづらいです。
今回は書きづらいUnitTestをAPI通信を利用しているクラスを用いて、stubやmockを利用してtestできる状態までを実際に触りながら紹介していきたいと思います。
またUnitTestが分からない方は是非下記の記事をご覧下さい。

環境

・Xcode : 12.5
・macOS : Big Sur

内容

今回はAPI通信を行なっているクラスを対象にUnitTestを行います。
クラス内で完結しているテストは非常に書きやすい事は前回の試して学ぶUnitTestに記述していますが、外部に依存しているコンポーネントは非常に書きづらいです。そこで今回は依存コンポーネントを差し替え(DI)を行い、stubやmockを利用したUnitTestをサンプルを交えながら解説していきます。

サンプル

Simulator Screen Recording - iPhone 11 Pro - 2021-08-03 at 21.46.21.gif
今回はGitHubのAPIを利用して、検索キーワードを入力すると一覧を表示するアプリをサンプルとして解説していきたいと思います。

依存

API通信を利用したクラスの多くはURLSessionなどを利用してサーバーから値を取得していると思います。
サーバー側が常に不変的なものであれば良いのですが、時間の経過とともに変化して行くケースが多くあります。
今回サンプルに使用させて頂いたGitHunのAPIも常にスターやウォッチ、フォークなどの数は変化していきます。
またエラーについてもキーワードや通信状況によって左右される為、エラーの原因も複雑になりがちです。
その為サーバーに依存した状態でUnitTestを行うと非常に不安定なテストとなります。

そこでサーバーに依存しない環境を疑似的に作成し、UnitTestを行います。

準備

まずModel側にプロトコルを導入して、擬似的なものと差し替えができる状態にします。

protocol RepositoryFetchInterface {
    func fetchRepository(word: String, completion: @escaping (Result<[Item], APIError>) -> Void)
}

上記のプロトコルに準拠した形でクラスを作成する事により、クラス同士の依存を避けることや、コンポーネントを差し替える事が可能になります。

今回使用するModel
protocol RepositoryFetchInterface {
    func fetchRepository(word: String, completion: @escaping (Result<[Item], APIError>) -> Void)
}

final class RepositoryFetcher: RepositoryFetchInterface {  
    func fetchRepository(word: String, completion: @escaping (Result<[Item], APIError>) -> Void) {
    code...
  }
}

これでRepositoryFetchInterfaceに準拠したクラスを作成する事ができます。
このクラスを使用してViewModel側を書いていきます。

import SwiftUI
final class RepositoryViewModel: ObservableObject {
    // 疎結合
    private let repositoryFetchInterface: RepositoryFetchInterface
    init(fetchRepository: RepositoryFetchInterface) {
        self.repositoryFetchInterface = fetchRepository
    }

    func searchRepository(word: String) {
        repositoryFetchInterface.fetchRepository(word: word) {(result) in
            switch result {
            case .success(let success):
                成功した時の処理
            case .failure(let error):
                失敗した時の処理
            }
        }
    }
}

普通にMVVMを採用して今回のようなクライアントアプリを作成する場合、ViewModelはModelに依存した形になると思いますが、今回はRepositoryFetchInterfaceと言うprotocolに準拠させる事によりクラス間の依存関係を疎結合の状態にします。これでViewModel側のテストでstubを注入する準備ができます。

UnitTest

上記のViewModelをテストしてく際
・通信エラーの場合APIError.networkErrorを返す事をテストしたい
・成功している時はレポジトリーを返す事をテストしたい
が中々しづらい状況です。そこでstubとmockの出番です。

stubを作成する

初めにstubから作ってきます。
新規でUnitTestを作成し、先ほど作成したプロトコルに準拠したクラスを作成します。
スクリーンショット 2021-08-03 22.22.57.jpg
するとstubを追加しますか?と聞いてきますのでFixをクリックして追加します。
スクリーンショット 2021-08-03 22.23.08.jpg
これでstubが追加されますのでfetchripositoryメソッド内にコードを書いていきます。

テストの都合の良い振る舞いをするように書き換える

stub
@testable import iOSEngineerCodeCheck

final class MockRepositoryFetchr: RepositoryFetchInterface {
    // メソッドの結果を操作するプロパティ
    var fetchResult: Result<[Item], APIError> = .success(mockRepositories)
    // 呼び出された引数を記録するプロパティ
    var argumentsWord: String?
    // 返り値を記録するプロパティ
    var returnRepositories: [Item]?

    func fetchRepository(word: String, completion: @escaping (Result<[Item], APIError>) -> Void) {
            completion(fetchResult)
        switch fetchResult {
        case .success:
            returnRepositories = mockRepositories
        default:
            returnRepositories = nil
        }
        self.argumentsWord = word
    }
}

続いてmockも作ります。

mock
@testable import iOSEngineerCodeCheck

// テスト用ripository
let mockRepositories: [Item]
    = [Item(nodeId: "1",
            fullName: "name1",
            stargazersCount: 1000,
            watchersCount: 1500,
            language: "Swift",
            forksCount: 9999,
            openIssuesCount: 500),
       Item(nodeId: "2",
            fullName: "name2",
            stargazersCount: 500,
            watchersCount: 2000,
            language: "Swift",
            forksCount: 1200,
            openIssuesCount: 10),
       Item(nodeId: "3",
            fullName: "name2",
            stargazersCount: 10,
            watchersCount: 150,
            language: "Swift",
            forksCount: 5,
            openIssuesCount: 25),
       Item(nodeId: "4",
            fullName: "name3",
            stargazersCount: 320,
            watchersCount: 15,
            language: "Swift",
            forksCount: 30,
            openIssuesCount: 4),
       Item(nodeId: "5",
            fullName: "name4",
            stargazersCount: 3,
            watchersCount: 3,
            language: "Swift",
            forksCount: 1,
            openIssuesCount: 1),
       Item(nodeId: "6",
            fullName: "name5",
            stargazersCount: 19500,
            watchersCount: 20000,
            language: "Swift",
            forksCount: 10000,
            openIssuesCount: 12000),
    ]
UnitTest
import XCTest
@testable import iOSEngineerCodeCheck

final class RepositoryViewModelTest: XCTestCase {

    private var repositoryViewModel: RepositoryViewModel!
    private var mockRepositoryFetcher: MockRepositoryFetchr!
    private var testWord: String!

    override func setUp() {
        mockRepositoryFetcher = MockRepositoryFetchr()
        repositoryViewModel = .init(fetchRepository: mockRepositoryFetcher)
    }
    private func testRepositoryViewModel_成功してmockリポジトリーを返す() {
        testWord = "apple"
        repositoryViewModel.getRepository(word: testWord)
        XCTAssertNotNil(repositoryViewModel.repositories)
        XCTAssertEqual("name1", repositoryViewModel.repositories[0].fullName)
        XCTAssertEqual(320, repositoryViewModel.repositories[3].stargazersCount)
    }
    private func testRepositoryViewModel_通信エラーだとnetworkErrorを返す() {
        mockRepositoryFetcher.fetchResult = .failure(APIError.networkError)
        testWord = testWords[4]
        repositoryViewModel.getRepository(word: testWord)
        XCTAssertNil(mockRepositoryFetcher.returnRepositories)
        XCTAssertEqual(APIError.networkError, repositoryViewModel.apiError)
    }
    private func testRepositoryViewModel_不明なエラーの時はunknownを返す() {
        mockRepositoryFetcher.fetchResult = .failure(APIError.unknown)
        testWord = testWords[4]
        repositoryViewModel.getRepository(word: testWord)
        XCTAssertNil(mockRepositoryFetcher.returnRepositories)
        XCTAssertEqual(APIError.unknown, repositoryViewModel.apiError)
    }
}

通信エラーの時レポジトリーがnilである事をテストする時、通信環境が悪い環境を作るのは容易ではないのでmockRepositoryFetcher.fetchResult = .failure(APIError.networkError)で予め通信エラーになる様にします。このように擬似的にテストがしやすい様に振る舞いができるようstubを注入します。更にmockを利用して、返ってくる値を不変的なものに変える事でサーバーに依存せず、安全にテストが行えます。

まとめ

stubを注入してテストがしやすい様に振る舞い、mockを利用して安定したUnitTestを試してみました。これでUnitTestもしやすくなったのでは無いでしょうか?今後は非同期のテストやCI/CD、TDDなどのテストにも挑戦していきたいと思いますのでまたできたら記事書きたいと思います。

14
10
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
14
10