Retty Tech Blog

実名口コミグルメサービスRettyのエンジニアによるTech Blogです。プロダクト開発にまつわるナレッジをアウトプットして、世の中がHappyになっていくようなコンテンツを発信します。

Combine製ロジックへのSwiftTesting導入とBDDからの移行

本記事はRettyアドベントカレンダー2024の8日目になります

Rettyアプリチームの今泉 @imaizume です。 最近はプロダクトマネージャー(PdM)としての業務が増え、コードを書く時間が以前よりも減ってきています。

そんな中先日久しぶりに参加したiOSの勉強会でSwift Testingの存在を知りました。 Xcodeとの親和性や拡張性の高さに感激したので、さっそくRetty iOSにも導入してみることにしました! また下調べをする中で、Combineを使ったテストの例を見つけられなかったのですが、実際に試してみたところいい感じにテストコードを書くことができました。

この記事では、Retty iOSアプリのViewModelを例に、Combineを組み合わせたSwift Testingによるテストコードの例、導入後の所感を紹介したいと思います。

対象読者

本記事は、以下のような方に向けた内容としています。

  • Swift Testingについて詳しくないが導入を検討している方
  • Quick/NimbleなどのBDDフレームワークとの比較が知りたい方
  • Combineやasync/awaitを組み合わせたテストの実例が見たい方
  • Swift Testingをプロダクトに導入した所感が知りたい方

Swift Testing とは

developer.apple.com

Swift TestingはAppleが公式に提供しているテストフレームワークです。 同フレームワーク自体の詳細な説明は割愛しますが、特徴を簡単にまとめると以下のような点が挙げられます。

  1. Swift Macros による簡潔な記法、descriptionやパラメータテストの標準サポート
  2. async/awaitを使った非同期処理とのシームレスな統合
  3. Xcode 16以降での見やすいアサーション
  4. 既存テストと並行運用可能

具体的なコードは後述の実例をご覧いただくとわかりやすいかと思います!

Retty iOSの設計・テスト事情

Swift Testingによるテストコードの紹介の前に、前提としてRetty iOSでの設計・テストに関する状況に軽く触れておきます。

Apple標準APIを利用した設計への移行

直近のRetty iOSアプリでは、新規開発や改修箇所を中心にApple標準のモダンなAPIを利用した設計の導入と移行を進めています。

  • UI : Storyboard, UITable/CollectionViewController, SwiftUI等様々な構築方法 → コードでの記述 &UICollectionViewCompositionalLayoutUICollectionViewDiffableDataSource をベースで構築
  • フロントエンドアーキテクチャ : DelegateパターンによるMVP → Combineを利用したMVVM
  • 非同期処理 : PromiseKitを使ったPromiseベースの処理 → Swift Concurrency (async/await)

これによりViewModelやDIされるクラスへのテストパターンも統一され、チームにノウハウを蓄積しやすくなっています。

既存のテストにおける課題

他方テストフレームワークでは、BDDフレームワークであるQuickNimbleを長年利用していました。 採用当時は以下のメリットを重視していました。

  • XCTestと比べテストが構造化されたり、説明がコードに直接書けて可読性が高い
  • BDDは他言語でも共通の記法であり、インターネット上に知見も多いため非iOSエンジニアでも触りやすい

一方で、最近では以下のような課題も感じつつありました。

  1. 可読性の低下 : テストケースが増えるにつれて記述量とネストが増え、かえって読みづらくなることがありました。
  2. 直感的でない記法 : Nimbleの expectメソッドが比較対象の型によって使い分けが必要で、初心者にはこれが少し扱いづらく感じることがありました。
  3. その他 : シミュレーターの起動などが伴うため、UIテストを伴わない単体テストを行うには重量級。また些細な点ですが、サードパーティ製のため依存が増えることも気にしていました。

Swift Testing を使ったテストの実例

そこでXcode 16の導入を機として、Retty iOSでもSwift Testingを試してみることにしました。 今回は以下3つのロジックに対して導入したテストコードの例を紹介します。

  1. String Extensionのテスト(シンプルなユニットテスト
  2. Combine製ViewModelの表示ロジックのテストPassthroughSubject を利用)
  3. Combine製ViewModelのログ送信のテストSpyオブジェクトを利用)

1. String Extensionのテスト

URL文字列をパースし、クエリパラメータの抽出や特定の値への置き換えを行う処理をテストします。 このテストはシンプルで、Quick/Nimbleからの移行を試すのに適していました。

Quick/Nimbleでの記述例

Quick/Nimbleの場合、内容的は単純でも expect で評価するまでネストが深くなる傾向があります。 各テストケースを配列にして与えパラメータテストにすることもできますが、自前で forEach する必要があったりFailしたときのアサーションが弱いのであえて1行ずつにしています。

import Foundation
import Nimble
import Quick

class StringSpec: QuickSpec {
    override class func spec() {
        describe("String") {
            describe("#toQueryParamDictionary") {
                context("クエリパラメータを含む文字列") {
                    it("クエリパラメータ名と値をKey-Valueペアにした辞書を返す") {
                        expect("=".toQueryParamDictionary()).to(equal(["": ""]))
                        expect("param=".toQueryParamDictionary()).to(equal(["param": ""]))
...
            describe("#pregMatch") {
                let pattern = "^((https?)://)?retty.me/?(\\?.*)?$"
                context("マッチなし") {
                    var matches: [String] = []
                    let url = "https://reserve.retty.me/"
                    let result = url.pregMatch(regex: pattern, matches: &matches)
                    it("resultはfalse") {
                        expect(result).to(beFalse())
                    }
                    it("結果の長さは0") {
                        expect(matches.isEmpty).to(beTrue())
                    }
                }
...

Swift Testingでの記述例

一方Swift Testingでは、パラメータテストが標準でサポートされており、手動でループを回す必要がないため記述が簡潔になります。 describe / context / it と異なり、ブロックによるネストもなく、本質的な部分のみが残って可読性も向上しています。

import Foundation
import Testing

struct StringTests {
    static let queryParamTestCases: [(String, [String: String])] = [
        ("=", ["": ""]),
        ("param=", ["param": ""]),
        ...
    ]

    @Test("クエリパラメータを含む文字列からパラメータを取り出す", arguments: queryParamTestCases)
    func testToQueryParamDictionary(input: String, expected: [String: String]) {
        #expect(input.toQueryParamDictionary() == expected)
    }

    @Test("パターンマッチでマッチしないことを確認")
    func testPregMatchNoMatch() {
        let pattern = "^((https?)://)?retty.me/?(\\?.*)?$"
        var matches: [String] = []
        let url = "https://reserve.retty.me/"
        let result = url.pregMatch(regex: pattern, matches: &matches)

        #expect(result == false)
        #expect(matches.isEmpty)
    }

2. Combine製ViewModelの表示ロジックのテスト

続いてCombineを使ったMVVMアーキテクチャのViewModelでのロジックにSwift Testingを導入してみます。 今回は店舗の座席を表示する画面のViewModelに対してテストを書いてみます。

イベントの入出力Subjectを多用しており、ViewModel側の実装はどの画面もこういった形になることが多いです。

import Combine
import Foundation

final class RestaurantSeatViewModel {
    private let restaurantId: Int64 // お店のID
    private let environment: RestaurantSeatFeatureEnvironment // DIされるオブジェクトを管理するクラス
    private var cancellables: Set<AnyCancellable> = []

    private let seatItemsSubject: CurrentValueSubject<[SeatElement], Never> = .init([]) // Viewへイベントを流すためのSubject

    init(
        restaurantId: Int64,
        environment: RestaurantSeatFeatureEnvironment
    ) {
        self.restaurantId = restaurantId
        self.environment = environment
    }

    // Viewとの入出力イベントのストリームを設定する
    func transform(input: Input) -> Output {
        input
            .viewDidLoad
            .sink { [weak self] in
                self?.viewDidLoad()
            }
            .store(in: &cancellables)
           ...
        
        return Output(
            onShowSeatImage: showSeatImageSubject.eraseToAnyPublisher(),
            ...
        )
    }

    // 各入力イベント後の処理はprivate関数で定義
    private func viewDidLoad() { ... }
    private func onTapSeat(index: Int) {
        showSeatImageSubject.send(element.seat) // Viewへイベントを送信
    }

    // ViewModelへの入力イベントストリームを扱うSubjectをまとめた構造体
    struct Input {
        let viewDidLoad: AnyPublisher<Void, Never>
        let onTapSeat: AnyPublisher<Int, Never>
        ...
    }

    // ViewModelからの出力イベントストリームを扱うSubjectをまとめた構造体
    struct Output {
        let onShowSeatImage: AnyPublisher<RestaurantSeatEntity, Never>
        ...
    }
}

これをベースに、Viewの方はこのような実装を行っています。

import Combine
import Foundation

final class RestaurantSeatViewController: UIViewController {
    private let viewModel: RestaurantSeatViewModel

    // ViewModelへイベントを流すためのSubject
    private let viewDidLoadSubject: PassthroughSubject<Void, Never> = .init()
    private let onTapSeatSubject: PassthroughSubject<Int, Never> = .init()
    ...

    init(
        dependency: Dependency // DIオブジェクト
    ) {
        viewModel = dependency.viewModel
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

   override func viewDidLoad() {
        super.viewDidLoad()
        
        // viewDidLoadでViewModelとの入出力イベントのストリームを設定する
        let output = viewModel.transform(
            input: .init(
                viewDidLoad: viewDidLoadSubject.eraseToAnyPublisher(),
                onTapSeat: onTapSeatSubject.eraseToAnyPublisher(),
                ...
            )
        )

        // ViewModelからの出力イベントをハンドリング
        output.onShowSeatImage
            .receive(on: RunLoop.main)
            .sink { [weak self] in
                self?.showSeatImage(item: $0)
            }
            .store(in: &cancellables)
        ...

        // ViewModelへイベントを送信
        viewDidLoadSubject.send()
   }
}

前提となるコードの説明が長くなってしまいましたが、今回は「座席選択イベントの発火(onTapSeatSubject への send)により、タップした座席IDが返ってくる(onShowSeatImage1 が返る)こと 」 を確認するテストを書いてみます。

Quick/Nimbleでの記述例

ViewModel等の初期化には beforeEach を用いています。 また非同期処理に対応するため AsyncSpec 継承のテストクラスを使っていますが、非同期処理は it の中で呼び出す必要があることに注意が必要です。 outputの値を各テストメソッドで使うため、一つ外の階層に保持しておきます。

import Combine
import Foundation
import Quick
import Nimble

final class RestaurantSeatViewModelSpec: AsyncSpec {
    typealias ViewModel = RestaurantSeatViewModel
    private static let restaurantId: Int64 = 100000000001

    override class func spec() {
        var viewModel: ViewModel!
        let viewDidLoadSubject: PassthroughSubject<Void, Never> = .init()
        let onTapSeatSubject: PassthroughSubject<Int, Never> = .init()
        ...
        var cancellables: Set<AnyCancellable> = .init()

        describe("RestaurantSeatViewModel") {
            var output: ViewModel.Output!
            beforeEach {
                analysisRepositorySpy.reset()
                viewModel = .init(
                    restaurantId: Self.restaurantId,
                    environment: .init(
                        reduxProvider: StubReduxProvider(
                            restaurantState: createRestaurantState(restaurantId: Self.restaurantId) // Seat ID 1, 2, ... のような座席のモックデータをRedux Storeに設定
                        ),
                        analysisRepository: analysisRepositorySpy
                    )
                )
                output = viewModel.transform(input: .init(
                    viewDidLoad: viewDidLoadSubject.eraseToAnyPublisher(),
                    onTapSeat: onTapSeatSubject.eraseToAnyPublisher(),
                    ...
                ))
            }

            context("0番目のアイテムをタップ") {
                it("タップされた席のSeatIDが1である") {
                    let tappedSeatId = await withCheckedContinuation { continuation in
                        output.onShowSeatImage
                            .receive(on: RunLoop.main)
                            .sink {
                                continuation.resume(returning: $0.seatId)
                            }
                            .store(in: &cancellables)
                        viewDidLoadSubject.send()
                        onTapSeatSubject.send(0) // 0番目のアイテムをタップ
                    }
                    expect(tappedSeatId).to(equal(1)) // タップされた座席IDが1である
                }
            }
        }
    }
}

補足ですが、Nimbleの describeit はstaticメソッドとして定義されているため、独自メソッドを定義する場合もstaticなメソッドしか呼び出すことはできません。

Swift Testingでの記述例

Swift Testingでは変数の初期化に initを利用するため、かなり直感的な書き方でテストを記述できます。 またテストはインスタンスメソッドなので、独自メソッドの呼び出しも比較的簡単です。

import Combine
import Foundation
import Testing

struct RestaurantSeatViewModelTests {
    typealias ViewModel = RestaurantSeatViewModel
    private let viewModel: ViewModel
    private static let restaurantId: Int64 = 100000000001

    private let viewDidLoadSubject: PassthroughSubject<Void, Never> = .init()
    private let onTapSeatSubject: PassthroughSubject<Int, Never> = .init()

    init() {
        viewModel = .init(
            restaurantId: Self.restaurantId,
            environment: .init(
                reduxProvider: StubReduxProvider(
                    restaurantState: createRestaurantState(restaurantId: Self.restaurantId)
                ),
                ...
            )
        )
    }

    @Test("0番目のアイテムをタップ")
    func onTapSeat() async {
        var cancellables: Set<AnyCancellable> = .init()
        let output = getOutput() // 独自定義のインスタンスメソッドを呼び出すことができる
        let tappedSeatId = await withCheckedContinuation { continuation in
            output.onShowSeatImage
                .receive(on: RunLoop.main)
                .sink {
                    continuation.resume(returning: $0.seatId)
                }
                .store(in: &cancellables)
            viewDidLoadSubject.send()
            seatWillDisplaySubject.send(0)
        }
        #expect(tappedSeatId == 1, "タップされた席のSeatIDが1である")
    }

    private func getOutput() -> ViewModel.Output {
        viewModel.transform(input: .init(
            viewDidLoad: viewDidLoadSubject.eraseToAnyPublisher(),
            onTapSeat: onTapSeatSubject.eraseToAnyPublisher(),
            ...
        ))
    }

いかがでしょうか? ネストが減ったもののdescriptionでテストケースの説明自体のわかりやすさは維持できていることがおわかりいただけると思います。 インスタンス変数や定数をベースにしており、スコープも通常の開発と同じ感覚で記述できます。

3. Combine製ViewModelのログ送信のテスト

最後に2と同じViewModelで、Firebaseを経由したログ送信の処理が正しく動作するかをテストします。 Retty iOSではログ送信処理をprotocolでラップし、テスト時にはSpyを経由して送信内容を検証しています。

Quick/Nimbleでの記述例

final class RestaurantSeatViewModelSpec: AsyncSpec {
    override class func spec() {
        var viewModel: ViewModel!
        let analysisRepositorySpy: AnalysisRepositorySpy = .init()

        describe("RestaurantSeatViewModel") {
            beforeEach {
                analysisRepositorySpy.reset() // Spyを初期化
                viewModel = .init(...)
            }

            context("画面を表示") {
                it("PVログが送られる") {
                    let _ = getOutput(viewModel)
                    // PVログのデータ
                    let trackingData: AnalysisData = .init(
                        trackingKey: .viewDidLoad,
                        parameters: ...
                    )
                    viewDidLoadSubject.send()
                    expect(analysisRepositorySpy.trackingData).to(equal(trackingData))
                }
            }
         ...
}

Swift Testingでの記述例

Swift Testingを使用することで、Spyのリセット処理が不要になり、記述量が減少しました。 非同期処理についても、直感的な記述が可能です。

import Combine
import Foundation
import Testing

struct RestaurantSeatViewModelTests {
    typealias ViewModel = RestaurantSeatViewModel
    private let viewModel: ViewModel
    private let analysisRepositorySpy: AnalysisRepositorySpy = .init()

    private let viewDidLoadSubject: PassthroughSubject<Void, Never> = .init()

    init() {
        viewModel = .init(
            restaurantId: Self.restaurantId,
            environment: .init(
                analysisRepository: analysisRepositorySpy
                ...
            )
        )
    }

    @Test("画面を表示")
    func viewDidLoad() {
        _ = getOutput()
        let trackingData: AnalysisData = .init(
            trackingKey: .viewDidLoad,
            parameters: ...
        )
        viewDidLoadSubject.send()
        #expect(analysisRepositorySpy.trackingData == trackingData, "PVログが送られる")
    }
}

上記の例では簡略化していますが、ログのデータは複数のパラメータを持っており、アサーションでの情報の見やすさが大切です。 Xcode 16ではこのアサーションが大変読みやすくなっており、各フィールドの値を構造で表してくれるためデバッグや原因の特定がスムーズになった印象があります。

Swift Testingによるアサーションの例

以上が実際に導入したテスト例でした。

導入・BDDから移行しての所感

続いて今回の導入・移行を踏まえての個人的な所感について述べたいと思います。 BDDとの比較もしたうえで、個人的なメリット・デメリットをまとめてみました。

フレームワーク メリット デメリット
BDD - 構造化されて説明が見やすい
- 記述方法が他言語とも共通で学習コストが低い
- インターネット上に知見が多い
- 比較的記述量が多くネストが増えがち
- expectbeforeEachなどの固有メソッドが直感的でない
- UIテストを伴わない場合は機能過剰
- サードパーティへの依存
Swift Testing - 記述量・ネストが抑えられて軽量
- Macroによるパラメータテスト及び構造化テストのサポート
- 純Swiftベースの直感的記法で、Swift Concurrencyとの相性の良さ
- 一定の学習コスト(Macroの仕様など)
- 実行時間が長い
- 比較的新しいため情報が少なく、今後仕様変更が起こる可能性

コードが軽くなって見通しやすく

BDDフレームワークにも良さはありますが、Unit TestについてはSwift Testingの方が見通しやすくなったと感じています。 構造化もstructにまとめることで達成できますし、descriptionも @Test マクロにてサポートされているので引き続き利用できます。

Swift Likeな記法

専用のclassやprotocolを継承する仕組みではなく、Pure Swiftな記法で書けるため、書きやすいという点も大きなメリットだと思います。 初期化/終了のときには init deinit を使い、共通変数はフィールドで定義といった、通常の開発と同じような書き方ができるため、この点ではフレームワークの学習コストが下がりコードが扱いやすくなったように思います。

導入が簡単でスムーズ

Swift Testingはパッケージの追加等の準備が不要で、既存のテストコードとも共存が可能です。 導入障壁が低く、新規に作るテストからすぐに利用することができました。

実行時間が長い

StringTest(SwiftTesting)が他のテスト(Quick/Nimble)よりも遅い

一方目立った唯一のデメリットとして、SwiftTestingのテストは実行時間が長いことが判明しました。 数自体が多くないため問題とはなっていませんが、CIの実行時間や開発速度にも影響することは間違いないため、今後の拡充に向けて解消すべき課題です。 弊社で利用しているXcodeCloud固有か、プロジェクト設定やコードの書き方等による可能性もあり、ここについては現在原因を特定しているところです。

まだ知見が少なく使いこなせていない

最後に、まだ導入の初期段階でありチーム内でもSwiftTesting固有のマクロの扱い方を十分に勉強できているとは言えない状況です。 より便利で目的に合った使い方やAPIがある可能性もあり、今後チームでこのフレームワークに対しての学習は引き続き行っていきたい所存です。

Tips: 移行時にはLLMを活用する

Chat GPTにSwift Testingの記法を覚えさせる

Swift Testingの記法はこれまでのフレームワーク、特にBDDであるQuick/Nimbleとは大きく異なります。 そのため既存のテストを移行する場合、手動での書き換えには時間がかかる場合があります。

そこでChatGPTのようなLLMを活用すると効率的に進めることができます。 現時点ではデフォルトで知識を持っていないようですが、新規作成したテストを具体例として食わせたり直接ルールを教えると、楽に変換したコードを得られるのでおすすめです!

まとめ

長くなってしまいましたが、Swift Testingの実例いかがでしたでしょうか? これからSwift Testingを書いていこうと思っている方、Combine製のロジックにテストを入れようと思っている方、BDDから乗り換えようとしている方などに参考となれば幸いです。 私自身もまだまだSwift Testingを完璧に使いこなせているわけではなく、他のMacroやAPIについてもこれから調べながら取り入れていきたいと思います。

Rettyアプリチームでは、引き続きこういった新しい技術を取り入れながらしっかりユーザーさんを向いたアプリ開発を継続してきたいと思っています。

参考URL