🦔

【Swift】ViewModelをテスト駆動(TDD)で実装する。[async await][Stub,Mock]を用いて

2023/05/12に公開

今回はポケモンAPIを用いて、ViewModelを実装していきます。API通信は、async awaitを用いて、TDD(テスト駆動)で実装していきます。

TDDに関してわからない場合は、前回の記事をご覧ください。

https://qiita.com/muranakar/items/223a5905d0c3c8fc99ae

また、async await TDD(テスト駆動)を中心に話を進めているので、Errorをどのように定義しているのか、構造体をどのように定義しているのかが気になる場合は、今回のコードをGitに挙げていますので、参考にしていただければと思います。

https://github.com/muranakar/PokemonSample

今回の流れ

  • PokemonAPIProtocolの作成
  • Dummyデータとして、PokemonDetailSampleData作成
  • MockPokemonAPI作成
  • PokemonListViewModel作成 ←(形だけを作成する)
  • テスト作成+実行
  • PokemonListViewModelを実装
  • テスト再度実行

の流れで行います。

PokemonAPIProtocolを作成

PokemonAPIProtocol
protocol PokemonAPIProtocol {
    func fetchPokemonList() async throws -> PokemonList
}
  • 実際のAPIと、差し替え可能にするために、プロトコルを定義する。
  • asyncメソッドで実装しておく。

MockPokemonAPIを作成

MockPokemonAPI
struct MockPokemonAPI : PokemonAPIProtocol {
    var returnHTTPError: HTTPError?

    var returnPokemonAPIError: PokemonAPIError?

    let returnMockPokemonList = PokemonListSampleData().pokemonList

    let returnMockPokemonDetail = PokemonDetailSampleData().pokemon

    func fetchPokemonList() async throws -> PokemonList {
        if let returnHTTPError {  throw returnHTTPError }
        if let returnPokemonAPIError { throw returnPokemonAPIError }
        return returnMockPokemonList
    }
}
  • MockPokemonAPIfetchPokemonList関数で、HTTPErrorPokemonAPIErrorSampleデータを意図的に変更して、返す値を変更できるように実装している。returnHTTPErrorreturnPokemonAPIErrorに値を代入することにより、Errorを返すことが可能。

PokemonListSampleDataを作成

PokemonListSampleData
struct PokemonListSampleData {
    let pokemonList =
    PokemonList(
        count: 1118,
        next: "https://pokeapi.co/api/v2/pokemon?offset=20&limit=20",
        previous: nil,
        results: [
            Pokemon(name: "bulbasaur", url: "https://pokeapi.co/api/v2/pokemon/1/"),
            Pokemon(name: "ivysaur", url: "https://pokeapi.co/api/v2/pokemon/2/"),
            Pokemon(name: "venusaur", url: "https://pokeapi.co/api/v2/pokemon/3/"),
            Pokemon(name: "charmander", url: "https://pokeapi.co/api/v2/pokemon/4/"),
            Pokemon(name: "charmeleon", url: "https://pokeapi.co/api/v2/pokemon/5/"),
            Pokemon(name: "charizard", url: "https://pokeapi.co/api/v2/pokemon/6/"),
            Pokemon(name: "squirtle", url: "https://pokeapi.co/api/v2/pokemon/7/"),
            Pokemon(name: "wartortle", url: "https://pokeapi.co/api/v2/pokemon/8/"),
            Pokemon(name: "blastoise", url: "https://pokeapi.co/api/v2/pokemon/9/"),
            Pokemon(name: "caterpie", url: "https://pokeapi.co/api/v2/pokemon/10/"),
            Pokemon(name: "metapod", url: "https://pokeapi.co/api/v2/pokemon/11/"),
            Pokemon(name: "butterfree", url: "https://pokeapi.co/api/v2/pokemon/12/"),
            Pokemon(name: "weedle", url: "https://pokeapi.co/api/v2/pokemon/13/"),
            Pokemon(name: "kakuna", url: "https://pokeapi.co/api/v2/pokemon/14/"),
            Pokemon(name: "beedrill", url: "https://pokeapi.co/api/v2/pokemon/15/"),
            Pokemon(name: "pidgey", url: "https://pokeapi.co/api/v2/pokemon/16/"),
            Pokemon(name: "pidgeotto", url: "https://pokeapi.co/api/v2/pokemon/17/"),
            Pokemon(name: "pidgeot", url: "https://pokeapi.co/api/v2/pokemon/18/"),
            Pokemon(name: "rattata", url: "https://pokeapi.co/api/v2/pokemon/19/"),
            Pokemon(name: "raticate", url: "https://pokeapi.co/api/v2/pokemon/20/")
        ]
    )
}
  • PokemonListのダミーデータを作成して、実装する。
  • ChatGPTを用いて、ダミーデータを作成すると、楽でした。

PokemonListViewModelを作成

PokemonListViewModel.swift
class PokemonListViewModel: ObservableObject {
    @Published var pokemonList: PokemonList?
    @Published var errorMessage: String = ""

    private let pokemonAPI: PokemonAPIProtocol

    init(pokemonAPI: PokemonAPIProtocol = MockPokemonAPI()) {
        self.pokemonAPI = pokemonAPI
    }

    func fetchPokemonList() async {
    }
}
  • fetchPokemonList()の中身は実装しておらず、関数だけ作成しています。

テストコードの作成

PokemonListViewModelを用いて、
返って来た値(結果)

返って来て欲しい値(期待値)
をテストで実装する。

PokemonListViewModelTestsを作成

PokemonListViewModelTests
final class PokemonListViewModelTests: XCTestCase {
   
    func test_PokemonListSampleDataを用いて出力の確認() async throws {
        let mockPokemonAPI = MockPokemonAPI()
        let viewModel: PokemonListViewModel = PokemonListViewModel(pokemonAPI: mockPokemonAPI)
        await viewModel.fetchPokemonList()
        XCTAssertEqual(viewModel.pokemonList?.results[0].name, "bulbasaur")
        XCTAssertEqual(viewModel.pokemonList?.results[4].name, "charmeleon")
        XCTAssertEqual(viewModel.pokemonList?.results[10].url, "https://pokeapi.co/api/v2/pokemon/11/")
    }

    @MainActor
    func test_HTTPエラー後エラーメッセージの出力の確認() async throws {
        let viewModel: PokemonListViewModel = PokemonListViewModel(pokemonAPI: MockPokemonAPI(returnHTTPError: .invalidRequest))
        await viewModel.fetchPokemonList()
        XCTContext.runActivity(named: "HTTPErrorに関して") { _ in
            XCTContext.runActivity(named: ".invalidRequestが生じた場合") { _ in
                XCTAssertEqual(viewModel.errorMessage, "The request could not be made. Please change and try again.")
            }
        }
    }

    @MainActor
    func test_PokemonAPIError後エラーメッセージの出力の確認() async  throws {
        let viewModel: PokemonListViewModel = PokemonListViewModel(pokemonAPI: MockPokemonAPI(returnPokemonAPIError: .decodingFailed))
        await viewModel.fetchPokemonList()
        XCTContext.runActivity(named: "PokemonAPIErrorに関して") { _ in
            XCTContext.runActivity(named: ".decodingFailedが生じた場合") { _ in
                XCTAssertEqual(viewModel.errorMessage, "デコードに失敗しました")
            }
        }
    }
}

上記のように、ViewModelを用いて、XCTAssertで、結果期待値 を設定します。

@MainActorをメソッドの上に必要である理由は、記載がなければXCTContext.runActivityがテスト実行時にエラーになってしまうためです(下URL参考)。私も調べて見ましたが、なぜエラーになってしまうのかは、わかりませんでした。

https://zenn.dev/kymmt/articles/e811f0d18568eb

テストの実装

テストを実行すると、
スクリーンショット 2023-05-10 11.03.51.png
全てテストが失敗します。そこから、テストが成功するように実装して行きます。

PokemonListViewModel の実装する

PokemonListViewModel.swift
class PokemonListViewModel: ObservableObject {
    @Published var pokemonList: PokemonList?
    @Published var errorMessage: String = ""

    private let pokemonAPI: PokemonAPIProtocol

    init(pokemonAPI: PokemonAPIProtocol = MockPokemonAPI()) {
        self.pokemonAPI = pokemonAPI
    }

    func fetchPokemonList() async {
        do {
            let list = try await pokemonAPI.fetchPokemonList()
            await setPokemonList(pokemonList: list)
        } catch let error as HTTPError {
            await setErrorMessage(errorMessage: error.localizedDescription)
        } catch let error as PokemonAPIError {
            await setErrorMessage(errorMessage: error.localizedDescription)
        } catch {
            await setErrorMessage(errorMessage: "An unknown error occurred.")
        }
    }
    @MainActor private func setPokemonList(pokemonList: PokemonList) {
        self.pokemonList = pokemonList
    }

    @MainActor private func setErrorMessage(errorMessage: String) {
        self.errorMessage = errorMessage
    }
}

  • @Published var pokemonList: PokemonList? や@Published var errorMessage: String = ""の値の変更はメインスレッドで変更する必要があります。@Publishedのプロパティは、変化があった際に、Viewに通知を送るからです。メインスレッドで値を変更するために、@MainActorを、値を変更するメソッドの上に記載します。

  • fetchPokemonList() の中で、
    let list = try await pokemonAPI.fetchPokemonList()の処理の終わりを待ってから、await setPokemonList(pokemonList: list)が実行されるように実装。

  • let list = try await pokemonAPI.fetchPokemonList()の実行時にエラーを投げられた場合は、HTTPErrorか、PokemonAPIErrorが飛んでくる。
    そのエラーを、catchの部分で受け取り、エラーメッセージを取得して、setErrorMessage(errorMessage: error.localizedDescription)の引数に代入している。

再度、テストを実行する

スクリーンショット 2023-05-10 11.12.13.png

失敗なく、テストが通った。他にも、エラーの検証をしたい場合は、メソッドを追加していくと良いと思います。

(余談)
テストメソッドを多くなるとテストのチェックがしづらくなると思います。そんなときは、XCTContext.runActivity を用いて実装すると

スクリーンショット 2023-05-10 11.15.08.png

上画像のように、どの部分でエラーが生じているかを、階層でチェックすることが可能です。ぜひ参考までに。


以上になります!

他にも良い方法があれば、コメントいただけると大変うれしいです。
良かったと思ったら、いいねやTwitterのフォローよろしくお願いいたします!

https://sites.google.com/view/muranakar
個人でアプリを作成しているので、良かったら覗いてみてください!

参考URL

https://www.fuwamaki.com/article/253

https://qiita.com/yujioka/items/260902b5fc046185257a

https://qiita.com/peka2/items/ee14ad27edfd07c843c5

Discussion