FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

テストの知見が全くない2年目iOSエンジニアが、マイページのユニテカバレッジ率を0%から90%以上に引き上げた話

この記事はフリューAdvent Calendar 2025 25日目の記事です。

qiita.com

ピクトリンク開発部でiOSアプリの開発を担当している牛尾です。フリューAdvent Calendar 2025の最終日を担当させていただきます。

今回は、ピクトリンクiOSのマイページにおける、ユニットテストのカバレッジ向上に向けた取り組みについてお話しさせていただきます。

テスト改善着手前の状態

テスト改善に着手する前のテストピラミッドの状態から簡単にお話しします。

テストピラミッドとは

そもそもテストピラミッドとは、ソフトウェアやアプリのテスト戦略をピラミッド状に可視化したモデルのことです。テストレベルごとの適切な比率を示し、信頼性と効率性を高めるための指標とも言えます。

テストはその粒度に応じて、下記図のようにユニットテスト、結合テスト、E2Eテストの3段階に分けられます。

テストピラミッド

developer.android.com

E2Eテスト

ユーザーが実際にアプリそのものを操作している時と同様に、機能を利用する上で期待通りの振る舞いをするかをテストします。ユーザー視点でのテストであるため忠実性が高くなりますが、実行時間が長くなる、実装やメンテナンスのコストも上がります。

結合テスト

複数のモジュールやコンポーネントを結合して、それらが相互に正しく連携して動作できているかをテストします。ユニットテストよりも規模が大きくなるため、実行時間もその分長くなります。

ユニットテスト

3段階の中では最も小規模なテストで、メソッドなどをテスト対象として動作を検証します。小規模なので高速に実行することができます。

テストピラミッドの理想形とのギャップ

テストピラミッドはテストレベルごとの適切な比率を示すいわば理想形です。このバランスを最適化することにより、信頼性や効率性が担保されます。しかし、テスト改善着手前のテストピラミッドは下記の図の状態でした。大半がE2Eで担保されており、ユニットテストは雀の涙、結合テストにいたっては「そんなものはございません」の状態でした。まさに典型的なアンチパターンです。

テストピラミッド(アンチパターン)

こういった状況の問題点として、以下が挙げられます。

  • ただでさえ時間がかかるE2Eテストの量が膨大なので、大幅に時間がかかってしまう
  • 潜在的なバグの早期発見が困難で品質面でリスクを抱えてしまう
  • バグが発見された時の調査コストがかかる

このように、アンチパターンである状態は信頼性や効率性の面で問題を抱えてしまうことにつながるのです。

理想形に近づくために

理想形に近づくためには、

  1. ユニットテストの実装
  2. 結合テストの実装
  3. ユニットテストや結合テストで検証しているものを、E2Eでは担保しない

といったステップを踏む必要がありました。 今回はユニットテストの実装を第一段階として、まず着手することにしました。また、手当たり次第とりあえずユニットテストを増やすといったことはせずに、アプリ主要機能の1つであるマイページのロジック部分に絞ってユニットテストを実装することにしました。

よし!じゃあ実装していくぜ!って意気込んでいましたが、ユニットテストを実装する上でも課題がありました。

進めていく上での課題

課題1:あれもこれもstatic

まずはこちらのサンプルコードをご覧ください。テスト実装する上での問題点はどこにあるでしょう?

class SamplePresenter {
    func process(items: [Item]) -> Int {
        var total = CalculatorService.calculate(items)
        if DiscountService.hasDiscount() {
           let discount = DiscountService.getAmount()
           total -= discount
        }

        return total
    }
}

問題は各Serviceクラスのメソッドがstaticである点です。staticなメソッド(静的メソッド)はインスタンスの生成なしでクラス名から直接呼び出すことが可能です。

アプリの機能を実装する上でstaticであることは問題ではないですし、インスタンスの生成なしで呼び出せるのは便利ですが、testableな設計という観点で考えると大問題で、そもそもこのままではテスト実装が不可能です。なぜでしょうか?

それは、Mock(模擬のオブジェクト)を差し替えることができないからです。

正常系や異常系といったテストケースを確認するためのMockを準備して、テスト対象のメソッドが期待通りの動作をするかを検証するのがテストの目的です。

しかし、上記のサンプルコードでは例えばhasDiscounttrueのパターンorfalseのパターンそれぞれのMockに差し替えることができず、メソッドの動作検証ができません。

テスト改善着手前のマイページ関連のメソッドは軒並みstaticで、テスト実装なんてできる状態ではなかったのです、、、

そこでこの課題を解決するためにリファクタしようとしたところ、別の課題が見つかりました。

課題2:Mock乱立

次に、私は数少ないユニットテストたちはどう実装されているのかを確認しに行きました。すると、Mockクラスの管理が下記の状態でした。

  • ユニットテスト実装に必要となるServiceProtocolに準拠したMockクラスが各ユニットテスト内に実装されていた。

どういうこと?ってなるかもしれないのでサンプルコードを用意しました。

import Quick
import Nimble
import RxSwift
@testable import YourApp

final class CardPresenterSpec: QuickSpec {

    // MARK: - Mock Service

    /// テスト用のサービスモック基底クラス
    class MockService: CardServiceProtocol {
        let emptyCard: CardViewEntity      // スタンプ0, クーポン0
        let stampOnlyCard: CardViewEntity  // スタンプ1, クーポン0
        let couponOnlyCard: CardViewEntity // スタンプ0, クーポン1
        let fullCard: CardViewEntity       // スタンプ1, クーポン1

        init() {
            let id = "test_card_id"
            let issuedAt = Date()
            let coupon = CouponDTO(id: "coupon_001", type: .free, issuedAt: issuedAt, usedAt: nil)

            emptyCard = CardViewEntity(dto: CardDTO(id: id, issuedAt: issuedAt, stampCount: 0, coupons: []))
            stampOnlyCard = CardViewEntity(dto: CardDTO(id: id, issuedAt: issuedAt, stampCount: 1, coupons: []))
            couponOnlyCard = CardViewEntity(dto: CardDTO(id: id, issuedAt: issuedAt, stampCount: 0, coupons: [coupon]))
            fullCard = CardViewEntity(dto: CardDTO(id: id, issuedAt: issuedAt, stampCount: 1, coupons: [coupon]))
        }

        func fetchCard() -> Single<CardViewEntity> { .never() }
        func addStamp() -> Single<CardViewEntity> { .never() }
    }

    // MARK: - Spec

    override class func spec() {
        var sut: CardPresenter!

        // MARK: Helper Functions

        func verifyStampCount(_ expected: Int) {
            expect(sut.stampCount).to(equal(expected))
            expect(sut.hasStamp).to(equal(expected > 0))
        }

        func verifyCouponCount(_ expected: Int) {
            expect(sut.coupons).to(haveCount(expected))
            expect(sut.unusedCoupons).to(haveCount(expected))
        }

        // MARK: - fetchCard

        describe("fetchCard()") {
            context("正常系: スタンプ0・クーポン0") {
                beforeEach {
                    class Mock: MockService {
                        override func fetchCard() -> Single<CardViewEntity> { .just(emptyCard) }
                    }
                    sut = CardPresenter(service: Mock())
                }

                it("カード情報が正しく設定される") {
                    _ = sut.fetchCard().toBlocking().materialize()
                    verifyStampCount(0)
                    verifyCouponCount(0)
                }
            }

            context("正常系: スタンプ1・クーポン1") {
                beforeEach {
                    class Mock: MockService {
                        override func fetchCard() -> Single<CardViewEntity> { .just(fullCard) }
                    }
                    sut = CardPresenter(service: Mock())
                }

                it("カード情報が正しく設定される") {
                    _ = sut.fetchCard().toBlocking().materialize()
                    verifyStampCount(1)
                    verifyCouponCount(1)
                }
            }

            context("異常系: API失敗") {
                beforeEach {
                    class Mock: MockService {
                        override func fetchCard() -> Single<CardViewEntity> {
                            .error(NSError(domain: "Test", code: -1))
                        }
                    }
                    sut = CardPresenter(service: Mock())
                }

                it("カード情報が更新されない") {
                    _ = sut.fetchCard().toBlocking().materialize()
                    expect(sut.cardId).to(beNil())
                    verifyStampCount(0)
                    verifyCouponCount(0)
                }
            }
        }

        // MARK: - addStamp

        describe("addStamp()") {
            context("正常系: スタンプ追加成功") {
                beforeEach {
                    class Mock: MockService {
                        override func addStamp() -> Single<CardViewEntity> { .just(stampOnlyCard) }
                    }
                    sut = CardPresenter(service: Mock())
                }

                it("スタンプ数が更新される") {
                    _ = sut.addStamp().toBlocking().materialize()
                    verifyStampCount(1)
                }
            }

            context("異常系: API失敗") {
                beforeEach {
                    class Mock: MockService {
                        override func addStamp() -> Single<CardViewEntity> {
                            .error(NSError(domain: "Test", code: -1))
                        }
                    }
                    sut = CardPresenter(service: Mock())
                }

                it("スタンプ数が更新されない") {
                    _ = sut.addStamp().toBlocking().materialize()
                    verifyStampCount(0)
                }
            }
        }
    }
}

このように、ユニットテスト内に必要となるMockクラスが定義されており、かつ他のユニットテスト内にも似た役割のMockクラスが定義されていたため、あらゆるところでMockクラスが乱立してる状況でした。

この問題点は以下の2つです。

  1. 重複コード:同じProtocolに対するMockが複数箇所に存在し、コードが重複する
  2. 変更時の影響範囲が大きい:Protocolに変更が入ると、散らばったMock全てを修正する必要がある

Protocolは型がどのようなプロパティやメソッドを保持しているかを示したインターフェースを定義するものです。つまり、Mockクラスは元のProtocolの定義に準拠している必要があります。

この状況で課題1を解決しようとすると、さらに問題が発生します。

例えば、HogeService内の静的メソッドfetch()をインスタンスメソッドにリファクタし、HogeServiceProtocolfetch()を定義したとします。

そしてテスト実行!

するとテストは実行されず、元のServiceクラスがProtocolに準拠していないというエラーが大量に発生しました。

つまり、staticをやめることでProtocolへ定義を追加する必要が発生し、それと同時にProtocolに準拠していた乱立するMockクラス全てに定義を追加する必要が出てきたのです。

まぁめんどくさいですよねw

テスト実装を今後進めていく上で、Mockクラスを共通の場所に管理する方針にする必要がありそうだと実感させられました。

課題解決に向けて

これらの課題を解決するために、下記の方針で進めました。

  1. 乱立していたMockクラスについて、Mock管理ディレクトリを用意して共通化
  2. テスト実装できる状態であるかをチェック
  3. テスト実装できない場合は、リファクタ実施
  4. 既存機能に影響はないか確認して、レビュー実施
  5. レビュー完了後、テスト実装

このうち、1と3について詳しく説明します。

Mock管理ディレクトリを用意して共通化

これは非常にシンプルです。Mock専用のディレクトリを用意しました。

簡易的ですが下記のような構成です。

Project
└── Tests
    └── Sources
        ├── Presenter
        │   └── Mock(New!)
        ├── ...
        ├── ...
        └── ...

Presenterに関するユニットテストが実装されているPresenterディレクトリにMockディレクトリを新規で用意し、この配下に既存のMockクラスを移管と新規Mockクラスの実装を行いました。

その結果、Mockクラスの乱立は解消されて、あらゆるところでエラーが発生する事態は無くなりました!

テスト実装できないPresenterをリファクタ

実装済みのPresenterなどは、実装者や実装をレビューしたレビュワー依存の設計であったため、まずは、テスト実装できる状態になっているか(Serviceクラスのメソッドが静的メソッドではないかetc..)をチームで分担して確認していきました。

例えば、Presenter内で使用されているServiceクラスが静的メソッドである場合は、

  1. インスタンスメソッドに変更してServiceProtocolへ定義
  2. Presenterを初期化する際にServiceProtocolをDI(依存性注入)する設計に変更

というようにリファクタしていきました。

前述したSamplePresenterを上記に従ってリファクタすると、こんな感じです(インスタンスメソッドに変更してServiceProtocolへ定義は省略します)。

class SamplePresenter {
   private let calculatorService: CalculatorServiceProtocol
   private let discountService: DiscountServiceProtocol

   init(
       calculatorService: CalculatorServiceProtocol,
       discountService: DiscountServiceProtocol
   ) {
       self.calculatorService = calculatorService
       self.discountService = discountService
   }

   func process(items: [Item]) -> Int {
       var total = calculatorService.calculate(items)
       if discountService.hasDiscount() {
           let discount = discountService.getAmount()
           total -= discount
       }
       return total
   }
}

このようにProtocol型で依存を受け取ることで、テストコードではMockを渡すことができるようになり、ユニットテストで正常系や異常系の動作検証を行うことが可能となりました!

取り組みを続けた結果

Xcodeでカバレッジ率を出力できるため、本取り組みの結果を算出しました(一部温かい手計算を含む)。

なお、カバレッジ率の計算方法は下記のとおりです。

(テスト実行された行数 ÷ テスト実行可能な行数)× 100

※ 実行可能な行数:空行やコメント行などコードの動作に関係しないものを除いたファイル内総行数

アプリの機能開発を進めつつ、隙間時間でチームの皆さんに協力いただきながらテスト改善を半年間進めた結果、マイページのロジックカバレッジ率はほぼ0%だったところから96%にまで引き上げることができました!

テストピラミッドの改善の第一歩をようやく踏み出すことができたと実感しています!

まとめ

今回は2年目iOSエンジニアによるテストピラミッドの改善に向けた取り組みを書かせていただきました。

本取り組みを行っていく過程で、設計に関する知識もかなり身につき、普段の機能開発の業務でも可読性や保守性等を考慮するようになったと実感しています。

テストピラミッドの改善はまだ始まったばかりですが、今後も継続的に取り組むことで改善を進めていきます!

最後に

フリューAdvent Calendar 2025に最後までお付き合いいただき、ありがとうございました!

11月末時点では半数程度しか埋まっていなかったとのことですが、なんとか25日間走り切ること事ができました!

皆様が読んだ記事の中で興味深いものはありましたでしょうか?ぜひ何かの参考になれば幸いです!

2026年のフリューAdvent Calendarをお楽しみに〜!