🎉

実践!iOSでTDD開発

2021/08/03に公開

TDDとは

TDDとは、テスト駆動開発Test Driven Developmentです。
最初に失敗するテストコードを書き、それを駆動源にしてプロダクトコードを書いていくスタイルです。

https://peaks.cc/books/iOS_testing

流れとしては、

  1. 失敗するテストコードを書く(RED)
  2. テストを成功させるコードを書く(GREEN)
  3. コードの可読性をあげる(Refactor)

実践

早速実践してみます。Github Search APIを使い、MVVMで開発します。
プロジェクトの作成については割愛します。

機能としては

  • 文字列からリポジトリを検索
  • 検索結果をリスト表示

Modelのテストコード/プロダクトコードを書く

Github Search APIのレスポンスがModelに当たります。
https://docs.github.com/ja/rest/reference/search#search-repositories

{
  "total_count": 40,
  "incomplete_results": false,
  "items": [
    {
      "id": 3081286,
      "node_id": "MDEwOlJlcG9zaXRvcnkzMDgxMjg2",
      "name": "Tetris",
      "full_name": "dtrupenn/Tetris",
      "owner": {
        "login": "dtrupenn",
        "id": 872147,
  .....

テストコードとしては、レスポンスをCodableでデコードし、内容を読み取ります。
最初に失敗するテストコードを書きます。

class GithubApiTestSampleTests: XCTestCase {
  ...

  func testModel() throws {
    XCTAssertTrue(false, "can not decode") //デコードできるかどうか
    XCTAssertTrue(false, "invalid total count") //total_countの数値が正しいか
    XCTAssertTrue(false, "invalid License") //LICENSEの値が読み取れるか
  }

次に、プロダクトコードを書きます。

struct SearchRepositoryResponse: Codable {
    let totalCount: Int
    let items: [SearchRepositoryItem]
}

struct SearchRepositoryItem: Codable {
    let id: Int
    let name: String
    let fullName: String
    let owner: GithubUser
    let `private`: Bool
    let description: String?
    let fork: Bool
    let url: String
    let createdAt: Date
    let updatedAt: Date
    let homepage: String?
    let stargazersCount: Int
    let watchersCount: Int
    let language: String?
    let forksCount: Int
    let license: GithubLicense?
}
...

テスト用にJSONファイルを用意します。上記のGithub Search APIのドキュメントにレスポンスのサンプルがあるので、コピーしJSONファイルとして保存、Unit Testターゲットに追加します。

最後に、成功するテストコードを書きます。

    func testModel() throws {
        
        var data = try getData(fileName: "search_repositories") //JSONからDataに読み取り
        
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        
        //XCTAssertTrue -> XCTAssertNoThrowに変更
        XCTAssertNoThrow(try decoder.decode(SearchRepositoryResponse.self, from: data), "can not decode")
        var response = try decoder.decode(SearchRepositoryResponse.self, from: data)
        
        XCTAssertTrue(response.totalCount == 40, "invalid total count")
        XCTAssertTrue(response.items.first?.license?.key == "mit", "invalid License")
                
    }

テストを実行すると、エラーなし(GREEN)となりました。

API client

詳しい実装はサンプルで確認できます。
テストケースのみ書きます。

    /// HTTPStubsを利用し、API通信のテスト
    func testStubApi() throws {
        //HTTP status: 200
        XCTContext.runActivity(named: "status 200") { activity in
          if successed {
            XCTAssertTrue(false, "must be finished response") //レスポンスが正しく返ってくるかどうか
          } else {
            XCTFail(error.localizedDescription) //エラーが返ってきてしまった場合
          }
        }
        //HTTP status: 40x
        XCTContext.runActivity(named: "400 status") { activity in
          if successed {
            XCTFail("must be error") //成功してしまった場合
          } else {
            XCTAssertTrue(false, "must be error") //レスポンスがエラーの場合
          }
        }
    }

パターンごとにXCTContextでテストケースをまとめています。

ViewModel

ViewModelとしては3つの状態があります。

  • 読み込み中
  • レスポンスが返ってきた場合
  • エラーが返ってきた場合

1つ目の「読み込み中」以外のテストコードを書いていきます。

テストコードは

    func testSearchViewModel() throws {

      XCTContext.runActivity(named: "success") { activity in
        //レスポンスデータがある
        XCTAssertTrue(false, "must have data")
      }        

      XCTContext.runActivity(named: "failure") { activity in
        //エラーがある
        XCTAssertTrue(false, "must have error")
      }
    }

今回、API clientについてはStubと差し替えできるようにするため、protocolを定義しています。

protocol ApiClientProtocol: AnyObject {
    func searchRepositories(query: String) -> AnyPublisher<SearchRepositoryResponse, Error>
}

Unit Testターゲット側に以下のクラスを追加します。

class StubApiClient {
    
    private let response: SearchRepositoryResponse
    private let failure: Bool
    
    init(response: SearchRepositoryResponse, failure: Bool) {
        self.response = response
        self.failure = failure
    }
    
}

extension StubApiClient: ApiClientProtocol {
    
    func searchRepositories(query: String) -> AnyPublisher<SearchRepositoryResponse, Error> {
        if failure {
            return Result<SearchRepositoryResponse, Error>.failure(URLError.init(.badServerResponse)).publisher.eraseToAnyPublisher()
        } else {
            return Result.Publisher.init(response).eraseToAnyPublisher()
        }
    }
    
}

StubApiClientを初期化する際に、レスポンス、エラーかどうかの情報を渡します。
searchRepositoriesを呼び出すと、failureの値によってレスポンスが変わります。
このクラスをテストコードで使っていきます。

ViewModelのテストコードです。successの場合のみ。

    func testSearchViewModel() throws {
        
        //Stub用のデータ
        let data = try getData(fileName: "search_repositories")
        let response = try decoder.decode(SearchRepositoryResponse.self, from: data)
                
        XCTContext.runActivity(named: "success") { activity in
            let expectation = expectation(description: activity.name)

            //Stub用のApiClientを使い、ViewModelを初期化        
            let viewModel = ViewModel.init(apiClient: StubApiClient(response: response, failure: false))
            viewModel.text = "apple"
            viewModel.search(debounce: 0.5) //0.5秒後に検索を開始
            
            DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
                //データがあるかどうか
                XCTAssertTrue(!viewModel.items.isEmpty, "must have data")
                expectation.fulfill()
            }            
            
            //fulfillが呼び出されるまで待つ。
            wait(for: [expectation], timeout: 5.0)
        }
    ...

Viewのテスト

Viewのテストも行います。確認することとしては、UIが正しく表示されているかです。スクリーンショットも保存するようにします。

パターンとしては

  • 読み込み中
  • レスポンスが返ってきた場合
  • エラーが返ってきた場合
    の3つです。

今回、SwiftUI.Viewのテスト用にViewInspectorを使います。
https://github.com/nalexn/ViewInspector

テストコード

    func testView() throws {

      XCTContext.runActivity(named: "loading") { activity in        
        //loading viewが表示されているかどうか
        XCTAssertTrue(false, "must have loading view")
      }

      XCTContext.runActivity(named: "has result") { activity in
        //結果が表示されているかどうか
        XCTAssertTrue(false, "must have list view")
      }

      XCTContext.runActivity(named: "has error") { activity in
        //結果が表示されているかどうか
        XCTAssertTrue(false, "must have error view")
      }

    }

View

struct ContentView: View {
    
    @ObservedObject var viewModel: ViewModel
    
    var body: some View {
        VStack {
          ...
          Group {
                //読み込み中                
                if viewModel.loading {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle())
                } else if !viewModel.hasError {
                    
                    //結果が返ってきた場合
                    List {
                        ForEach.init(viewModel.items) { item in
                            RepositoryRow(item: item)
                        }
                    }
                    
                } else {
                    //エラーが返ってきた場合                    
                    VStack {
                        Image(systemName: "xmark.octagon.fill")
                            .font(.system(size: 80))
                        Text("Error").font(.title)
                    }
                }
            }
        }
    }

3パターンによって表示を変えています。

最後に、テストコードを仕上げます。読み込み中の場合のみ。

    func testView() throws {
        
        try XCTContext.runActivity(named: "loading") { activity in

            //ViewModelで状態を変更しContentViewを初期化
            let viewModel = ViewModel()
            viewModel.loading = true
            let view = ContentView(viewModel: viewModel)

            //スクリーンショットを保存                                
            add(XCTAttachment(image: view.snapshot()).setLifetime(.keepAlways))
            
            //ViewInspectorで存在するかどうかを確認
            //XCTAssertTrue -> XCTAssertNoThrowに変更
            XCTAssertNoThrow(try view.inspect().vStack().group(2).progressView(0), "must have loading view")
            
        }
        ...

これでテストコードが通るようになりました。

最後に

テストコードを書くのは結構大変でしたが、ソースが整理されたり、不具合をすぐ見つけやすく感じました。
逆に、テストコードを書きすぎると、テスト実装コスト・メンテナンスコストが高くなることも感じました。
どこまでテストコードを書くかは、ある程度事前に決めたいですね。

サンプル

https://github.com/usk-sample/GithubApiTestSample

Discussion