実践!iOSでTDD開発
TDDとは
TDD
とは、テスト駆動開発Test Driven Development
です。
最初に失敗するテストコードを書き、それを駆動源にしてプロダクトコードを書いていくスタイルです。
流れとしては、
- 失敗するテストコードを書く(RED)
- テストを成功させるコードを書く(GREEN)
- コードの可読性をあげる(Refactor)
実践
早速実践してみます。Github Search APIを使い、MVVMで開発します。
プロジェクトの作成については割愛します。
機能としては
- 文字列からリポジトリを検索
- 検索結果をリスト表示
Modelのテストコード/プロダクトコードを書く
Github Search APIのレスポンスがModel
に当たります。
{
"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
を使います。
テストコード
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")
}
...
これでテストコードが通るようになりました。
最後に
テストコードを書くのは結構大変でしたが、ソースが整理されたり、不具合をすぐ見つけやすく感じました。
逆に、テストコードを書きすぎると、テスト実装コスト・メンテナンスコストが高くなることも感じました。
どこまでテストコードを書くかは、ある程度事前に決めたいですね。
サンプル
Discussion