はじめに
ユニットテストをするときに、可能な限り副作用を減らしていく必要があるかと思います。
ここでいう「副作用」とは、他の機能の結果が影響して、テスト対象の機能の結果が変わってしまうことです。また、正確なカバレッジが取れないこと・サーバーへ負荷をかけてしまうことも副作用であると考えます(`・ω・´)
今回は、iOSアプリ開発のAppDelegateの副作用をどうやって無くすか考えていきます。
前提
- 本記事では、SceneDelegateを使用していないプロジェクトを想定しています。
- Test Doubleでいうと、Fake Object的なものを作りますが、命名はxxxMockにしています。
テストカバレッジの確認から順を追って説明していきますので、
本題のAppDelegateのMock作成だけ確認したい方は
飛ばしてこちらから読んでいただいても大丈夫ですm(_ _)m
(2021.08.17追記)
SceneDelegateのMockについても記事を書きました!
こちらも併せて確認してみてくださいm(_ _)m
新規プロジェクトのテストカバレッジを見てみる
AppDelegateの副作用について実際に確認するために、
作成したばかりのプロジェクトに、カバレッジを見れるように設定してみます。
だいぶ前にXCTestでカバレッジを確認するという記事を書きましたが、
Xcodeのバージョンも変わって見た目が少し変わっているので、イメージを載せておきます。
(やり方は、ほぼ変わってないです。)
Code Coverageにチェックをつけるだけです
収集対象は、all targetsにしています。
Embedded Frameworkなどを導入しているプロジェクトでは、指定Frameworkのカバレッジのみ収集するよう設定することもできます。
新規作成したプロジェクトにカバレッジの設定をして、テストコードを実行してみました。
SceneDelegateを取り除いて、再度テストコードを実行してみました。
テストコードではまだ何も検証していないのに、
AppDelegateと最初のViewControllerのカバレッジが100%になりました。°(´•ω•̥`)°。
ユニットテスト実行時も、func application(_:didFinishLaunchingWithOptions:)
が呼び出されて、最初のViewControllerが表示されていることが確認できました。
新規作成したばかりのプロジェクトなので特に処理はありませんが、
実際のプロジェクトで最初の画面のライフサイクルが実行されると
予期しないAPIリクエストが実行されてしまうといったこともあるかと思います。
ユニットテスト実行中かどうか判定してみる
AppDelegateのfunc application(_:didFinishLaunchingWithOptions:)
内で
ユニットテスト実行中かどうか判定する分岐を加えてみました。
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を差し替えるようにします。
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
}
}
ViewControllerを差し替えたので、ViewController.swiftは、テスト実行時に呼び出されなくなりました。
AppDelegateもテスト実行時かどうか判定をできているので、必要最低限の処理だけ呼ぶようには改善できたのですが、プロダクトコードに本来は不要な分岐が入ってしまうし、AppDelegateのカバレッジは収集されてしまうので、これでは十分ではないです。
AppDelegateのMockを作成する
ここからが本題です!
AppDelegate内でユニットテスト実行中かどうか分岐するのではなく、
AppDelegate自体をMockに差し替えるようにしていきます。
以下の順に対応をしていきます。
- main.swiftを追加して、エントリーポイントを定義する
- AppDelegateからmain属性を取り除く
- ユニットテストのターゲットにAppDelegateMockを実装する
- 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」を作成することで、コードでエントリーポイントを定義することができます。
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でコンパイルエラーが発生するようになります。
エントリーポイントは、main.swiftで定義されるため、
AppDelegate.swiftから@main
またはUIApplicationMain
属性を削除することで
エラーを解消することができます。
ユニットテストのターゲットにAppDelegateMockを実装する
テストターゲットにAppDelegateMock.swift新規作成します。
実装は、AppDelegate.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
属性を付けます。
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
を使用するようにします。
import UIKit
let appDelegateClass: AnyClass = NSClassFromString("AppDelegateMock") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))
これで、テスト実行時はAppDelegateMockを呼び出せるようになったので
あらためてテストコードを実行してみます。
ユニットテスト実行時にAppDelegate.swiftが実行されなくなりました
ソースコード全体
実装したソースコードをまとめると、最終的に以下のようになります。
Applicationのターゲット
import UIKit
let appDelegateClass: AnyClass = NSClassFromString("AppDelegateMock") ?? AppDelegate.self
UIApplicationMain(CommandLine.argc, CommandLine.unsafeArgv, nil, NSStringFromClass(appDelegateClass))
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
}
}
ユニットテストのターゲット
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アプリ開発であまり意識する必要がなかったエントリーポイントについても
おさらいすることができてよかったです( ´ ▽ ` )ノ