7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Posted at

はじめに

前回投稿した記事でユニットテスト実行時の副作用を減らしていくためにテストターゲットではAppDelegateのMockを作成してみました。

今回は、SceneDelegateのMockを作成して、ユニットテスト実行時はそれを使用するようにしてみたいと思います。

前回の内容

前回の記事を前提に説明を記載しますので、そちらも併せて確認してみてくださいm(_ _)m

前提

今回は、シングルウィンドウのアプリを想定しています。
マルチウィンドウの場合も同様で問題ないかどうかは確認ができていないですm(_ _)m

また、古いOSバージョンをサポートしているとSceneDelegateを使用できるかどうかOSバージョンによる分岐が発生すると思いますが、今回は複雑化させないために、iOS13以降をターゲットにする前提にしますm(_ _)m

SceneDelegateのMockを作成する

前回の記事では、SceneDelegateを使用しないプロジェクトを想定していたので、SceneDelegateを削除していましたが、
今回はSceneDelegateするので、プロダクトコードからも削除しないで進めていきます。

※ 最終的なソースコードは、最後にまとめて載せています。

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

  1. ユニットテストのターゲットにSceneDelegateMockを実装する
  2. AppDelegateMockを修正する

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

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

実装は、AppDelegateMockを作成したときと同様に本物のSceneDelegate.swiftをベースにします。

SceneDelegateMock.swift
import UIKit

class SceneDelegateMock: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
        // If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
        // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).

        guard let windowScene = (scene as? UIWindowScene) else { return }

        // 最初のViewControllerを差し替える
        window = .init(windowScene: windowScene)
        window?.rootViewController = UIViewController()
        window?.makeKeyAndVisible()
    }
}

テスト用のViewControllerを設定したところ以外は、本物のSceneDelegateと変わりありません。

AppDelegateMockを修正する

AppDelegateMockには2つの修正が必要になります。

  1. application(_:configurationForConnecting:options:)のUISceneConfigurationの設定を変更
  2. キャッシュされたシーンセッションを削除する処理を追加

application(_:configurationForConnecting:options:)のUISceneConfigurationの設定を変更

AppDelegateには、以下のような処理があるかと思います。

AppDelegate.swift
class AppDelegate: UIResponder, UIApplicationDelegate {

    // 省略

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }

UISceneConfigurationは、Info.plistでUISceneConfigurationNameの値として定義されている"Default Configuration"を指定していますが、個別にUISceneDelegateClassNameやUISceneStoryboardFileをソースコードで定義することもできます。

AppDelegateMockクラスでは、UISceneConfigurationNameを指定せず、ソースコードでSceneDelegateMockクラスを指定していきます。

AppDelegateMock.swift
class AppDelegateMock: UIResponder, UIApplicationDelegate {

    // 省略

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // Called when a new scene session is being created.
        // Use this method to select a configuration to create the new scene with.

        let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = SceneDelegateMock.self
        sceneConfiguration.storyboard = nil
        return sceneConfiguration
    }

これで、新しくシーンセッションが作成されたら、delegateClassにSceneDelegateMockが指定されるようになります:metal:

ただ、これだけだと十分ではありません。。

これまでに作成したAppDelegateMockとSceneDelegateMock、それから、本物のAppDelegateとSceneDelegateのそれぞれ最初に呼び出される処理にデバッグログを仕込んで実行してみます。

// MARK: - AppDelegate
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        print("AppDelegate", #function)
        return true
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        print("AppDelegate", #function)
        return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role)
    }
}

// MARK: - SceneDelegate
class SceneDelegate: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        print("SceneDelegate", #function)
        guard let _ = (scene as? UIWindowScene) else { return }
    }
}

// MARK: - AppDelegateMock
@objc(AppDelegateMock)
class AppDelegateMock: UIResponder, UIApplicationDelegate {

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        print("AppDelegateMock", #function)
        return true
    }

    // MARK: UISceneSession Lifecycle

    func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        print("AppDelegateMock", #function)
        let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = SceneDelegateMock.self
        sceneConfiguration.storyboard = nil
        return sceneConfiguration
    }
}

// MARK: - SceneDelegateMock
class SceneDelegateMock: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        print("SceneDelegateMock", #function)
        guard let windowScene = (scene as? UIWindowScene) else { return }
        // 最初のViewControllerを差し替える
        window = .init(windowScene: windowScene)
        window?.rootViewController = UIViewController()
        window?.makeKeyAndVisible()
    }
}

コンソールログ
// アプリ未インストール - デバッグ実行
AppDelegate application(_:didFinishLaunchingWithOptions:)
AppDelegate application(_:configurationForConnecting:options:)
SceneDelegate scene(_:willConnectTo:options:)

// アプリインストール済み - デバッグ実行
AppDelegate application(_:didFinishLaunchingWithOptions:)
SceneDelegate scene(_:willConnectTo:options:)

// アプリ未インストール - ユニットテスト実行
AppDelegateMock application(_:didFinishLaunchingWithOptions:)
AppDelegateMock application(_:configurationForConnecting:options:)
SceneDelegateMock scene(_:willConnectTo:options:)

// デバッグ実行でアプリインストール済み - ユニットテスト実行
AppDelegateMock application(_:didFinishLaunchingWithOptions:)
SceneDelegate scene(_:willConnectTo:options:) // Mockが使われていない

シーンセッションが生成されるときにapplication(_:configurationForConnecting:options:)が呼ばれるのですが、
アプリが既にインストール済みの場合は、シーンセッションがキャッシュされているようで、キャッシュからシーンを復元しているのか上記処理が呼ばれないパターンがありました。

デバッグ実行をしてシーンセッションのキャッシュがある状態でユニットテストを実行した場合は、
application(_:configurationForConnecting:options:)が呼ばれず、Mockを使用したいのに本物のSceneDelegateが呼び出されてしまいました。

application(_:configurationForConnecting:options:)が呼び出されるかどうか

キャッシュ無し SceneDelegateのキャッシュあり
(デバッグ実行した)
SceneDelegateのキャッシュなし
(デバッグ実行していない)
デバッグ実行 ×(SceneDelegateが呼び出されるからOK)
ユニットテスト実行 ×(SceneDelegateが呼び出されるからNG)

ログを見る限り、以下のような挙動になっているようでした。

デバッグ実行 ユニットテスト実行
シーンセッションのキャッシュが生成される
(次回、シーンセッションの生成が省略される)
シーンセッションのキャッシュが生成されない

Appスイッチャーからタスクキルをすれば、シーンセッションのキャッシュはなくなるようで、
次回起動時にはapplication(_:configurationForConnecting:options:)が呼ばれるのですが、
ユニットテスト前に毎回タスクキルをするというのは現実的ではありません。

app_kill.gif

そのため、ユニットテスト実行時にはキャッシュされたシーンセッションを削除する処理を追加する必要があります。

参考情報:
 Problem using fake SceneDelegate for unit tests

キャッシュされたシーンセッションを削除する処理を追加

ユニットテスト実行前にAppスイッチャーから毎回タスクキルをするというのは現実的ではないので、シーンセッションを削除する処理をAppDelegateMockに追加していきます。

ユニットテストでアプリを起動して最初に呼ばれるAppDelegateMockのapplication(_:didFinishLaunchingWithOptions:)でシーンセッションを削除したいと思います。

application.openSessionsで、現在アクティブなセッションとシステムによってアーカイブされているセッションのSetを取得できるようです。

ただ、それらを削除するPublic APIが見つかりませんでした。。

Private APIを使って対応します。
Private API = リジェクトっていうように思われてしまうこともあるかもですが、
今回は、テストターゲットのクラス内でPrivate APIを利用します。

スクリーンショット 2021-08-17 1.26.50.png

通常のアプリ開発では、アプリの審査のためのArchiveはテストターゲットが関与することはないので、テストターゲット内に留めてPrivate APIを使用することは特に問題がなさそうです。

Private APIなので、いつか仕様が変わって動かなくなる可能性もあるという前提になってしまいますが、実装していきます。

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

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        removeSessions(application: application) // 追加
        return true
    }

    // 省略
}

extension AppDelegateMock {
    private func removeSessions(application: UIApplication) {
        application.openSessions.forEach {
            application.perform(Selector(("_removeSessionFromSessionSet:")), with: $0)
        }
    }
}

これで、ユニットテスト実行時にシーンセッションのキャッシュが削除されて、必ずSceneDelegateMockが呼び出されるようになります:metal:

参考情報:
 openSessions

ユニットテストのターゲットのソースコード

Applicationのターゲットのソースコードは、前回記事からSceneDelegateが追加されたくらいしか差分がないので割愛しますが、
ユニットテストのターゲットのソースコードを改めてまとめると以下のようになりますm(_ _)m

AppDelegateMock.swift
import UIKit

@objc(AppDelegateMock)
class AppDelegateMock: UIResponder, UIApplicationDelegate {
    
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        removeSessions(application: application)
        return true
    }
    
    // MARK: UISceneSession Lifecycle
    
    func application(_ application: UIApplication,
                     configurationForConnecting connectingSceneSession: UISceneSession,
                     options: UIScene.ConnectionOptions) -> UISceneConfiguration {
        // delegateClassにSceneDelegateMockを指定したUISceneConfiguration
        let sceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role)
        sceneConfiguration.delegateClass = SceneDelegateMock.self
        sceneConfiguration.storyboard = nil
        return sceneConfiguration
    }
}

extension AppDelegateMock {
    private func removeSessions(application: UIApplication) {
        application.openSessions.forEach {
            application.perform(Selector(("_removeSessionFromSessionSet:")), with: $0)
        }
    }
}
SceneDelegateMock.swift
import UIKit

class SceneDelegateMock: UIResponder, UIWindowSceneDelegate {

    var window: UIWindow?

    func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        guard let windowScene = (scene as? UIWindowScene) else { return }
        // 最初のViewControllerを差し替える
        window = .init(windowScene: windowScene)
        window?.rootViewController = UIViewController()
        window?.makeKeyAndVisible()
    }
}

さいごに

今までSceneDelegateを使用して開発をする機会が少なかったので、今回SceneDelegateをいろいろ触ってみて勉強になりました。

シーンセッションの削除は、Private APIを使っているので力技って感じが否めないですが、テストターゲットだからいいかなって思ったりしています。

そこまでするんだったら、本物のSceneDelegateをテストでも使えばいいんじゃないかって言われてしまいそうですが、やり方の一つとして覚えておくのはいいのではないかなーと思います:upside_down:

7
1
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
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?