はじめに
外部依存のあるコンポーネントはUnitTestが書きづらいです。
今回は書きづらいUnitTestをAPI通信を利用しているクラスを用いて、stubやmockを利用してtestできる状態までを実際に触りながら紹介していきたいと思います。
またUnitTestが分からない方は是非下記の記事をご覧下さい。
環境
・Xcode : 12.5
・macOS : Big Sur
内容
今回はAPI通信を行なっているクラスを対象にUnitTestを行います。
クラス内で完結しているテストは非常に書きやすい事は前回の試して学ぶUnitTestに記述していますが、外部に依存しているコンポーネントは非常に書きづらいです。そこで今回は依存コンポーネントを差し替え(DI)を行い、stubやmockを利用したUnitTestをサンプルを交えながら解説していきます。
サンプル
今回はGitHubのAPIを利用して、検索キーワードを入力すると一覧を表示するアプリをサンプルとして解説していきたいと思います。
依存
API通信を利用したクラスの多くはURLSessionなどを利用してサーバーから値を取得していると思います。
サーバー側が常に不変的なものであれば良いのですが、時間の経過とともに変化して行くケースが多くあります。
今回サンプルに使用させて頂いたGitHunのAPIも常にスターやウォッチ、フォークなどの数は変化していきます。
またエラーについてもキーワードや通信状況によって左右される為、エラーの原因も複雑になりがちです。
その為サーバーに依存した状態でUnitTestを行うと非常に不安定なテストとなります。
そこでサーバーに依存しない環境を疑似的に作成し、UnitTestを行います。
準備
まずModel側にプロトコルを導入して、擬似的なものと差し替えができる状態にします。
protocol RepositoryFetchInterface {
func fetchRepository(word: String, completion: @escaping (Result<[Item], APIError>) -> Void)
}
上記のプロトコルに準拠した形でクラスを作成する事により、クラス同士の依存を避けることや、コンポーネントを差し替える事が可能になります。
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を作成し、先ほど作成したプロトコルに準拠したクラスを作成します。
するとstubを追加しますか?と聞いてきますのでFixをクリックして追加します。
これでstubが追加されますのでfetchripositoryメソッド内にコードを書いていきます。
テストの都合の良い振る舞いをするように書き換える
@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も作ります。
@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),
]
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などのテストにも挑戦していきたいと思いますのでまたできたら記事書きたいと思います。