Protocol を利用したライブラリに依存しないテストを書く方法
はじめに
どうも tattsun です 🙌
テスト可能なコードを書くことは単なるベストプラクティスではなく、品質の高いプロダクトをユーザーへ提供するために必須です。アプリケーションの複雑さが増すにつれて、自動化されたテストを通じて機能を検証する能力は、コード品質の維持、リグレッションの防止、そして自信を持ったリファクタリングを行ううえで非常に重要になります。
本記事では クラシルリワード における Protocol ベースのテスト の実践例を紹介します!
テスト可能性のための Protocol ベースの設計パターン
Protocol は Swift においてテスト可能なコードを書く中心的な要素です。プロトコルを使うことで、実際のコンポーネントとテストダブルの両方が実装できる明確なインターフェースを定義できます。
Protocol を使用した依存性注入
依存性注入(Dependency Injection)は、オブジェクト自身が依存関係を生成するのではなく、外部から提供してもらうテクニックです。Protocol と組み合わせることで強力なテスト支援になります。
protocol HogeServiceProtocol {
func fetch() async -> String
}
final class HogeService: HogeServiceProtocol {
private let hogeRepository: HogeRepository
init(hogeRepository: HogeRepository) {
self.hogeRepository = hogeRepository
}
func fetch() async -> String {
hogeRepository.fetchString()
}
}
// MARK: - 利用側
@MainActor
final class HogeViewModel: ObservableObject {
private let hogeService: any HogeServiceProtocol
init(hogeService: any HogeServiceProtocol) {
self.hogeService = hogeService
}
func fetch() async -> String {
hogeService.fetch()
}
}
Mock と Preview
// MARK: - Mock
final class HogeServiceProtocolMock: HogeServiceProtocol {
private let _fetch: () async -> String
init(fetch: @escaping () async -> String) {
self._fetch = fetch
}
func fetch() async -> String {
_fetch()
}
}
// MARK: - Preview
#Preview {
HogeView(
viewModel: HogeViewModel(
hogeService: HogeServiceProtocolMock()
)
)
}
Test
// MARK: - Test
@Suite
struct HogeViewModelTests {
@Test
func fetch() async {
let service = HogeServiceProtocolMock(
fetch: { "This is Test" }
)
let viewModel = HomeViewModel(hogeService: service)
let result = await viewModel.fetch()
#expect(result == "This is Test")
}
}
Apple 標準 API を Protocol に抽出する
Protocol ベーステストの最も強力な応用の 1 つは、UIKit のシングルトンやシステムクラスをモック化可能な Protocol に抽出することです。
ここでは UIApplication を例にします。
@MainActor
public protocol UIApplicationProtocol {
func registerForRemoteNotifications()
func canOpenURL(_ url: URL) -> Bool
}
extension UIApplication: UIApplicationProtocol {}
// MARK: - 利用側
@MainActor
final class HogeViewModel: ObservableObject {
private let uiApplication: any UIApplicationProtocol
init(uiAppication: any UIApplicationProtocol = UIApplication.shared) {
self.uiApplication = uiApplication
}
func canOpenURL(_ url: URL) -> Bool {
uiApplication.canOpenURL(url)
}
}
Mock と Preview
// MARK: - Mock
final class UIApplicationProtocolMock: UIApplicationProtocol {
private let registerForRemoteNotificationsHandler: () -> Void
private let canOpenURLHandler: (URL) -> Bool
init(
registerForRemoteNotificationsHandler: @escaping () -> Void = {},
canOpenURLHandler: @escaping (URL) -> Bool = { _ in false }
) {
self.registerForRemoteNotificationsHandler = registerForRemoteNotificationsHandler
self.canOpenURLHandler = canOpenURLHandler
}
func registerForRemoteNotifications() {
registerForRemoteNotificationsHandler()
}
func canOpenURL(_ url: URL) -> Bool {
canOpenURLHandler(url)
}
}
// MARK: - Preview
#Preview {
HogeView(
viewModel: HogeViewModel(
uiApplication: UIApplicationProtocolMock()
)
)
}
Test
// MARK: - Test
@Suite
struct HogeViewModelTests {
@Test
func canOpenURL() {
let url = URL(string: "https://example.com")!
let uiApplication = UIApplicationProtocolMock(
canOpenURLHandler: { false }
)
let viewModel = HomeViewModel(uiApplication: uiApplication)
let result = viewModel.canOpenURL(url)
#expect(result == false)
}
}
Mockolo を使用した Mock 生成
クラシルリワードでは Mock 生成ツールとして Mockolo を採用しています。複雑な Protocol を手動で Mock 化するのは時間がかかりエラーも起こりがちです。Mockolo は Uber 製の Mock 生成ツールで、Protocol から型安全なモックを自動生成します。
Mockolo の特徴
- 高速な Mock 生成
- 型安全な Mock
- カスタマイズ可能な生成オプション
- Xcode Build Phase との統合
- SPM と CocoaPods のサポート
Mockolo の利用方法
先ほど実装した UIApplicationProtocol
で Mock を自動生成してみます。
- Mock 化したいプロトコルに @mockable アノテーションを付与します:
// @mockable アノテーションを追加
/// @mockable
@MainActor
public protocol UIApplicationProtocol {
func registerForRemoteNotifications()
func canOpenURL(_ url: URL) -> Bool
}
- CLI で生成を実行します:
mockolo -s path/to/source -d path/to/output/MockResults.swift
自動生成された Mock ファイルがこちらです。
public class UIApplicationProtocolMock: UIApplicationProtocol, @unchecked Sendable {
public init() { }
public private(set) var registerForRemoteNotificationsCallCount = 0
public var registerForRemoteNotificationsHandler: (() -> ())?
public func registerForRemoteNotifications() {
registerForRemoteNotificationsCallCount += 1
if let registerForRemoteNotificationsHandler = registerForRemoteNotificationsHandler {
registerForRemoteNotificationsHandler()
}
}
public private(set) var canOpenURLCallCount = 0
public var canOpenURLHandler: ((URL) -> Bool)?
public func canOpenURL(_ url: URL) -> Bool {
canOpenURLCallCount += 1
if let canOpenURLHandler = canOpenURLHandler {
return canOpenURLHandler(url)
}
return false
}
}
- 生成された UIApplicationProtocolMock をテストから利用します:
@Suite
struct HogeViewModelTests {
@Test
func canOpenURL() {
let url = URL(string: "https://example.com")!
let uiApplication = UIApplicationProtocolMock()
uiApplication.canOpenURLHandler = { false }
let viewModel = HomeViewModel(uiApplication: uiApplication)
let result = viewModel.canOpenURL(url)
#expect(result == false)
#expect(uiApplication.canOpenURLCallCount == 1)
}
}
Mockolo の利用によって、先ほどまで独自で実装していたコードが自動で生成されるようになりました。Mock を実装するのは地味に 手間がかかるので自動生成できるのはとてもありがたいですね 🎉
まとめ
- Protocol と依存性注入 を組み合わせることで、実装とテストダブル間の契約を明確化し、疎結合かつテスト容易な設計を実現できる。
- システム API(UIApplication など)を Protocol に抽出 すると、シングルトン依存を排除し、ユニットテストでの制御性が格段に向上する。
-
Mockolo を使えば、手書きが面倒な Mock を自動生成できるため、
- 大規模プロジェクトでも Mock 実装コストを最小化
- 型安全・ビルドフェーズ統合によりランタイムエラーを防止
- テストコードを書くハードル が下がり、結果として 開発サイクルが高速化
- 本記事のサンプルは クラシルリワード の実運用コードを簡略化したもの。
- 実際のプロジェクトでは 複数 Protocol の合成なども行っているので、ぜひ自分のコードベースに合わせて拡張してみてください 💪
参考文献
- Swift公式ドキュメント - プロトコル
- Swift公式ドキュメント - Swift Testing
- Mockolo - Uberが開発したSwiftモック生成ツール
Discussion