LoginSignup
24
12

More than 1 year has passed since last update.

iOSのユニットテストでAppDelegateのMockを作成する

Last updated at Posted at 2021-08-15

はじめに

ユニットテストをするときに、可能な限り副作用を減らしていく必要があるかと思います。

ここでいう「副作用」とは、他の機能の結果が影響して、テスト対象の機能の結果が変わってしまうことです。また、正確なカバレッジが取れないこと・サーバーへ負荷をかけてしまうことも副作用であると考えます(`・ω・´)

今回は、iOSアプリ開発のAppDelegateの副作用をどうやって無くすか考えていきます。

前提

  • 本記事では、SceneDelegateを使用していないプロジェクトを想定しています。
  • Test Doubleでいうと、Fake Object的なものを作りますが、命名はxxxMockにしています。

テストカバレッジの確認から順を追って説明していきますので、
本題のAppDelegateのMock作成だけ確認したい方は
飛ばしてこちらから読んでいただいても大丈夫ですm(_ _)m

(2021.08.17追記)
SceneDelegateのMockについても記事を書きました!
こちらも併せて確認してみてくださいm(_ _)m

新規プロジェクトのテストカバレッジを見てみる

AppDelegateの副作用について実際に確認するために、
作成したばかりのプロジェクトに、カバレッジを見れるように設定してみます。

だいぶ前にXCTestでカバレッジを確認するという記事を書きましたが、
Xcodeのバージョンも変わって見た目が少し変わっているので、イメージを載せておきます。
(やり方は、ほぼ変わってないです。)

スクリーンショット 2021-08-15 10.59.04.png

Code Coverageにチェックをつけるだけです:point_up:
収集対象は、all targetsにしています。
Embedded Frameworkなどを導入しているプロジェクトでは、指定Frameworkのカバレッジのみ収集するよう設定することもできます。

新規作成したプロジェクトにカバレッジの設定をして、テストコードを実行してみました。
スクリーンショット 2021-08-15 10.33.58.png

SceneDelegateを取り除いて、再度テストコードを実行してみました。
スクリーンショット 2021-08-15 12.03.33.png

テストコードではまだ何も検証していないのに、
AppDelegateと最初のViewControllerのカバレッジが100%になりました。°(´•ω•̥`)°。

ユニットテスト実行時も、func application(_:didFinishLaunchingWithOptions:)が呼び出されて、最初のViewControllerが表示されていることが確認できました。

新規作成したばかりのプロジェクトなので特に処理はありませんが、
実際のプロジェクトで最初の画面のライフサイクルが実行されると
予期しないAPIリクエストが実行されてしまうといったこともあるかと思います。

ユニットテスト実行中かどうか判定してみる

AppDelegateのfunc application(_:didFinishLaunchingWithOptions:)内で
ユニットテスト実行中かどうか判定する分岐を加えてみました。

AppDelegate.swift
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { // 分岐を追加
            print("ユニットテスト実行中")
        } else {
            print("ユニットテスト実行中ではない")
        }
        return true
    }
}

ユニットテスト実行中かどうか判定できるようになったので、
ユニットテスト実行中の場合は、最初のViewControllerを差し替えるようにします。

AppDelegate.swift
import UIKit

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        if ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil { // 分岐を追加
            print("ユニットテスト実行中")

            // 追加
            window?.rootViewController = UIViewController()
            window?.makeKeyAndVisible()
        } else {
            print("ユニットテスト実行中ではない")
        }
        return true
    }
}

ここで、再度テストを実行してみます。
スクリーンショット 2021-08-15 13.54.05.png

スクリーンショット 2021-08-15 13.57.02.png

ViewControllerを差し替えたので、ViewController.swiftは、テスト実行時に呼び出されなくなりました。

AppDelegateもテスト実行時かどうか判定をできているので、必要最低限の処理だけ呼ぶようには改善できたのですが、プロダクトコードに本来は不要な分岐が入ってしまうし、AppDelegateのカバレッジは収集されてしまうので、これでは十分ではないです。

AppDelegateのMockを作成する

ここからが本題です!

AppDelegate内でユニットテスト実行中かどうか分岐するのではなく、
AppDelegate自体をMockに差し替えるようにしていきます。

以下の順に対応をしていきます。

  1. main.swiftを追加して、エントリーポイントを定義する
  2. AppDelegateからmain属性を取り除く
  3. ユニットテストのターゲットにAppDelegateMockを実装する
  4. main.swiftでAppDelegateとAppDelegateMockを出し分ける

main.swiftを追加して、エントリーポイントを定義する

「main.swift」というファイルを追加して、アプリのエントリーポイントを定義していきます。

普段、iOSアプリを開発する際には、AppDelegate.swiftの
func application(_:didFinishLaunchingWithOptions:)が起動時に呼び出されることが分かると思います。
これは、AppDelegateのクラス定義の上に@mainまたはUIApplicationMain属性が定義されているため、コンパイラがアプリのエントリーポイントを生成してくれています。
そのため、「main.swift」ファイルが不要になっています。

Objective-CでiOS開発を経験したことがある方は、「main.m」ファイルを見たことがあるかと思いますが、Swiftでも「main.swift」を作成することで、コードでエントリーポイントを定義することができます。

main.swift
import UIKit

let appDelegateClass: AnyClass = AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

参考情報:
 https://developer.apple.com/swift/blog/?id=7

AppDelegateからmain属性を取り除く

main.swiftを追加すると、AppDelegateでコンパイルエラーが発生するようになります。

スクリーンショット 2021-08-15 14.57.59.png

エントリーポイントは、main.swiftで定義されるため、
AppDelegate.swiftから@mainまたはUIApplicationMain属性を削除することで
エラーを解消することができます。

ユニットテストのターゲットにAppDelegateMockを実装する

テストターゲットにAppDelegateMock.swift新規作成します。
スクリーンショット 2021-08-15 20.58.05.png

実装は、AppDelegate.swiftをベースにします。

AppDelegateMock.swift
import UIKit

class AppDelegateMock: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        window?.rootViewController = UIViewController()
        window?.makeKeyAndVisible()
        return true
    }
}

この状態では、Applicationのターゲットにあるmain.swiftから
ユニットテストのターゲットのAppDelegateMockクラスを参照できないので、
NSClassFromString(_:)でクラス名からクラスを取得できるように @objc属性を付けます。

AppDelegateMock.swift
import UIKit

@objc(AppDelegateMock) // 追加
class AppDelegateMock: UIResponder, UIApplicationDelegate {

    // 省略
}

参考情報:
 Objective-C の NSClassFromString で Swift のクラスを生成する
 NSClassFromString(_:)

main.swiftでAppDelegateとAppDelegateMockを出し分ける

テストターゲットに実装したAppDelegateMockを呼び出せるようにmain.swiftを修正をしていきます。

ユニットテスト実行時は、AppDelegateMockがロードされているので
NSClassFromString("AppDelegateMock")でAppDelegateMockのクラスオブジェクトが取得できます。
一方、プロダクトコード実行時は、NSClassFromString("AppDelegateMock")がnilになるので、
その場合はAppDelegate.selfを使用するようにします。

main.swift
import UIKit

let appDelegateClass: AnyClass = NSClassFromString("AppDelegateMock") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))

これで、テスト実行時はAppDelegateMockを呼び出せるようになったので
あらためてテストコードを実行してみます。

スクリーンショット 2021-08-16 0.18.29.png

ユニットテスト実行時にAppDelegate.swiftが実行されなくなりました:metal:

ソースコード全体

実装したソースコードをまとめると、最終的に以下のようになります。

Applicationのターゲット

main.swift
import UIKit

let appDelegateClass: AnyClass = NSClassFromString("AppDelegateMock") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))
AppDelegate.swift
import UIKit

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.
        return true
    }
}

ユニットテストのターゲット

AppDelegateMock.swift
import UIKit

@objc(AppDelegateMock)
class AppDelegateMock: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        // Override point for customization after application launch.

        window?.rootViewController = UIViewController()
        window?.makeKeyAndVisible()
        return true
    }
}

さいごに

今回は、AppDelegateのMockを作成してみました。
本物のAppDelegateを汚さなくて済むようにできましたし、
普段iOSアプリ開発であまり意識する必要がなかったエントリーポイントについても
おさらいすることができてよかったです( ´ ▽ ` )ノ

24
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
12