[Xcode/Swift] Quick/NimbleではじめるSwiftの快適ユニットテスト入門

Quick/Nimbleとは

Quick

RSpec風にユニットテストを書けるSwiftのBDDテストフレームワーク。
自然な言語に近い形でテストの構造を組み立てられるのが魅力👍

describe("ログイン機能") {
    context("正しい入力のとき") {
        it("成功すべき") {
            // テスト処理をここに書く
        }
    }
}

→ これがそのまま「仕様書」になる。読みやすくて伝わるテストが書ける。

Nimble

Quickと一緒によく使われる 柔軟で読みやすいアサーションライブラリ。

  • expect(value).to(equal(x))
  • expect(error).to(beNil())
  • expect(array).toEventually(haveCount(1))

非同期処理も .toEventually でサクッと書けるのが魅力。

Quick/Nimbleを使うと何が嬉しいのか

Before(XCTest)After(Quick/Nimble)
XCTAssertEqual(result, 42)expect(result).to(equal(42))
testSearchSuccess()it("成功すべき")
手続き的で読みにくい仕様書みたいに読める
非同期処理は苦痛.toEventually で簡潔に記述

テスト=めんどくさい からテスト=書くの気持ちいい に変わる。

describe, context, it の意味と使い分け

describe("ある機能") {
    context("ある状況") {
        it("こうなるべき") {
            // 検証
        }
    }
}
  • describe → テスト対象(クラス、関数)
  • context → 状況(入力値、状態など)
  • it → 結果(どうなるべきか)

→ 「〇〇のとき、××すべき」って構造になるから、読んでもわかりやすいのが特徴。

サンプルアプリでざっくり学ぶ

多分コーディングテストとか課題とかでよく出てくる、GithubのRepository検索機能をざっくり作ってそのテストケースを作成。

Quick/Nimbleのインストール (SPM)

Quick: https://github.com/Quick/Quick

NImble: https://github.com/Quick/Nimble

ターゲットはTestの方に入れないとコンパイルエラーになるのでそこだけ気をつけましょう。

GitHubAPIClient.swiftと、MockGitHubAPIClient.swift

MockGitHubAPIClient.swiftの方は、Testsディレクトリに入れてターゲットもTestにしておくこと。

import Foundation
import Combine

class GitHubAPIClient {
    func searchRepositories(query: String) -> AnyPublisher<[Repository], Error> {
        guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
              let url = URL(string: "https://api.github.com/search/repositories?q=\(encodedQuery)") else {
            return Fail(error: URLError(.badURL)).eraseToAnyPublisher()
        }
        return URLSession.shared.dataTaskPublisher(for: url)
            .map(\.data)
            .decode(type: SearchResponse.self, decoder: JSONDecoder())
            .map(\.items)
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}
import Quick
import Nimble
import Combine
@testable import GitHubSearchApp

class MockGitHubAPIClient: GitHubAPIClient {
    var mockResult: Result<[Repository], Error> = .success([]) // テストケース用のMockデータの役割

    override func searchRepositories(query: String) -> AnyPublisher<[Repository], any Error> {
        switch mockResult {
        case .success(let repos):
            return Just(repos)
                .setFailureType(to: Error.self)
                .eraseToAnyPublisher()
        case .failure(let error):
            return Fail(error: error)
                .eraseToAnyPublisher()
        }
    }
}

SearchViewModel.swift

import SwiftUI
import Combine

final class SearchViewModel: ObservableObject {
    @Published var query: String = ""
    @Published var results: [Repository] = []
    @Published var isLoading = false
    @Published var errorMessage: String?

    private let apiClient: GitHubAPIClient
    private var cancellables = Set<AnyCancellable>()

    init(apiClient: GitHubAPIClient = GitHubAPIClient()) {
        self.apiClient = apiClient
    }

    func search() {
        guard !query.isEmpty else { return }
        isLoading = true
        errorMessage = nil

        apiClient.searchRepositories(query: query)
            .sink(receiveCompletion: { [weak self] completion in
                self?.isLoading = false
                if case .failure(let error) = completion {
                    self?.errorMessage = error.localizedDescription
                }
            }, receiveValue: { [weak self] repos in
                self?.results = repos
            })
            .store(in: &cancellables)
    }
}

SearchView.swift (もしくはContentView、アプリエントリーポイント対象のView)

import SwiftUI

struct SearchView: View {
    @StateObject private var viewModel = SearchViewModel()

    var body: some View {
        NavigationView {
            VStack {
                TextField("Search GitHub repos", text: $viewModel.query)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .padding()

                Button("Search") {
                    viewModel.search()
                }

                if viewModel.isLoading {
                    ProgressView()
                }
                List(viewModel.results) { repo in
                    VStack(alignment: .leading) {
                        Text(repo.name).font(.headline)
                        if let desc = repo.description {
                            Text(desc).font(.subheadline)
                        }
                    }
                }
            }
        }
        .navigationTitle("GitHub Search")
    }
}

#Preview {
    SearchView()
}

SearchViewModelSpec.swift (テストコード記述ファイル)

今回の肝、簡潔に

  • VMの初期化 → ヨシ
  • 成功時のデータ返還 → ヨシ
  • 失敗時のデータ返還 → ヨシ
  • QueryがEmptyの時のデータ返還 → ヨシ

これらのチェックを行いましょう。

import Quick
import Nimble
@testable import GitHubSearchApp

final class SearchViewModelSpec: QuickSpec {
    override class func spec() {
        // 各テスト前に使い回す変数(mockとViewModel)
        var mockApiClient: MockGitHubAPIClient!
        var viewModel: SearchViewModel!

        // 初期状態のテスト(テスト対象が正しく初期化されるか)
        describe("SearchViewModel") {
            it("can be initialized") {
                let viewModel = SearchViewModel()
                expect(viewModel.query).to(equal(""))
                expect(viewModel.results).to(beEmpty())
            }
        }

        describe("SearchViewModel Execute Search") {
            // 各テストごとに ViewModel と モックAPI を毎回初期化
            beforeEach {
                mockApiClient = MockGitHubAPIClient()
                viewModel = SearchViewModel(apiClient: mockApiClient)
            }

            context("When search succeeds") {
                it("Updates the results array") {
                    let expectedRepo = Repository(id: 1, name: "swift", description: "Swift language", html_url: "https://github.com/apple/swift")
                    mockApiClient.mockResult = .success([expectedRepo])
                    viewModel.query = "swift"
                    viewModel.search()
                    expect(viewModel.results).toEventually(haveCount(1))
                    expect(viewModel.results.first?.name).toEventually(equal("swift"))
                }
            }

            context("When search fails") {
                it("sets errorMessage") {
                    mockApiClient.mockResult = .failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Mock Error"]))
                    viewModel.query = "fail"
                    viewModel.search()
                    expect(viewModel.errorMessage).toEventually(equal("Mock Error"))
                }
            }

            context("When query is empty") {
                it("Does not perform search") {
                    mockApiClient.mockResult = .success([Repository(id: 1, name: "test", description: nil, html_url: "")])
                    viewModel.query = ""
                    viewModel.search()
                    expect(viewModel.results).toEventually(beEmpty())
                }
            }
        }
    }
}

完成したら、command + Uを実行するとテストビルドが回ります、そこで落ちていた場合はFailedになって、詳細を確認できます。

今回だと、成功時の”swift”を”swifts”にするとエラー詳細を見れます。

お前swiftsをexpectしてんのにswift返ってきてんじゃねーかって感じで怒ってくれます。

Quick/Nimbleが最強な理由まとめ

  • 📘 可読性が高く、仕様書みたいに書ける
  • 🔁 beforeEach/afterEach で共通処理がシンプルに
  • 🧪 柔軟なアサーションで書きたい通りに書ける
  • 🔮 非同期処理もスマートに検証できる

まだまだ基礎部分だけの紹介なので、今後も色々とテスト技術を書いていこうと思います。