【Swift】ViewModelをテスト駆動(TDD)で実装する。[async await][Stub,Mock]を用いて
今回はポケモンAPIを用いて、ViewModel
を実装していきます。API通信は、async await
を用いて、TDD(テスト駆動)で実装していきます。
TDDに関してわからない場合は、前回の記事をご覧ください。
また、async await
TDD(テスト駆動)
を中心に話を進めているので、Errorをどのように定義しているのか、構造体をどのように定義しているのかが気になる場合は、今回のコードをGitに挙げていますので、参考にしていただければと思います。
今回の流れ
-
PokemonAPIProtocol
の作成 -
Dummy
データとして、PokemonDetailSampleData
作成 -
MockPokemonAPI
作成 -
PokemonListViewModel
作成 ←(形だけを作成する) - テスト作成+実行
-
PokemonListViewModel
を実装 - テスト再度実行
の流れで行います。
PokemonAPIProtocolを作成
protocol PokemonAPIProtocol {
func fetchPokemonList() async throws -> PokemonList
}
- 実際のAPIと、差し替え可能にするために、プロトコルを定義する。
-
async
メソッドで実装しておく。
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
}
}
-
MockPokemonAPI
のfetchPokemonList
関数で、HTTPError
かPokemonAPIError
かSampleデータ
を意図的に変更して、返す値を変更できるように実装している。returnHTTPError
かreturnPokemonAPIError
に値を代入することにより、Errorを返すことが可能。
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を作成
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を作成
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参考)。私も調べて見ましたが、なぜエラーになってしまうのかは、わかりませんでした。
テストの実装
テストを実行すると、
全てテストが失敗します。そこから、テストが成功するように実装して行きます。
PokemonListViewModel の実装する
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)
の引数に代入している。
再度、テストを実行する
失敗なく、テストが通った。他にも、エラーの検証をしたい場合は、メソッドを追加していくと良いと思います。
(余談)
テストメソッドを多くなるとテストのチェックがしづらくなると思います。そんなときは、XCTContext.runActivity を用いて実装すると
上画像のように、どの部分でエラーが生じているかを、階層でチェックすることが可能です。ぜひ参考までに。
以上になります!
他にも良い方法があれば、コメントいただけると大変うれしいです。
良かったと思ったら、いいねやTwitterのフォローよろしくお願いいたします!
個人でアプリを作成しているので、良かったら覗いてみてください!
参考URL
Discussion