iOS UIテスト(XCUITest)でアプリとテストランナー間でファイル共有しようと試行錯誤したけどできなかった話

iOS UIテストにて、アプリで保存したファイルの内容をテストコード側で検証したいことがありました。

結論からいうと、シミュレーターではできたけど、実機ではできませんでした

できなかったけど色々試行錯誤したので、記録として残しておこうと思います。

やりたかったこと

例えば、画面内のあるボタンをタップすると、オンライン上のデータがテキストファイルとしてアプリ内に保存され、以降はオフラインでそのデータを閲覧できる機能を考えてみましょう。

UIテストでは、以下のようなテストコードを書くことになります。

  1. アプリを起動し、該当のボタンをタップし、ファイルのダウンロード完了まで待つ(UIテストのときは、そのファイルは共有ディレクトリに保存されるようにする)
  2. 共有ディレクトリに保存されたファイルをロードし、テキストの内容を検証する

iOSのUIテストでは、本体アプリと、テストを実行するテストランナーアプリがインストールされ、テストランナーアプリが本体アプリを操作するという仕組みを取っています。

f:id:bamboohero:20210514011044p:plain:w400
こんなイメージ?

iOSではSandboxという仕組みにより、基本的にアプリ間でのファイル共有はできないようになっています。

ただし、こちらの記事によれば、シミュレーターではシミュレーター内の共有ディレクトリ、実機ではApp Groupを使うことで、本体アプリとテストランナーアプリ間でのファイル共有を実現したとあります。

pfandrade.me

この記事のやり方で試したところ、シミュレーターではファイル共有できたものの、App Groupを使った実機でのファイル共有はできませんでした。記事が投稿されたのが2017年と古いので、もしかしたらiOSのセキュリティ周りがより厳しくなったのかもしれません。

シミュレーターでファイル共有

共有ディレクトリに保存されたファイルを検証するテストコードの例を書いてみました。

import XCTest

class BambooCIAppUITests: XCTestCase {
    var sharedDirectoryPath: String {
        let simulatorSharedDirectory = ProcessInfo().environment["SIMULATOR_SHARED_RESOURCES_DIRECTORY"]!
        return (simulatorSharedDirectory as NSString).appendingPathComponent("Library/Caches")
    }

    func testExample() throws {
        let app = XCUIApplication()
        app.launchEnvironment["sharedDirectoryPath"] = sharedDirectoryPath
        app.launch()

        let path = "\(sharedDirectoryPath)/result.txt"
        let url = URL(fileURLWithPath: path)
        let data = try! Data(contentsOf: url)
        let resultText = String(data: data, encoding: .utf8)
        XCTAssertTrue(resultText == "Hello, World!")
    }
}

SIMULATOR_SHARED_RESOURCES_DIRECTORYというのはシミュレーターを起動すると自動で設定される環境変数で、シミュレーター内のアプリで共通アクセスが可能なディレクトリのパスが設定されています。

具体的には~/Library/Developer/CoreSimulator/Devices/<シミュレーターUDID>/dataというパスです。今回はこのディレクトリ内のLibrary/Cachesを共有ディレクトリとして扱います。

シミュレーターのフォルダ構成についてはこちらの記事で詳しく説明しています。

bamboo-hero.com

app.launchEnvironment["sharedDirectoryPath"] = sharedDirectoryPathは、本体アプリに環境変数を渡すコードです。ファイルの保存先をテストコード側から伝えています。

続いて本体アプリ側を見ていきます。簡単のために、アプリを起動したらファイルが保存されるようにします。

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        let sharedDirectoryPath = ProcessInfo.processInfo.environment["sharedDirectoryPath"]!
        let filePath = "\(sharedDirectoryPath)/result.txt"
        let data = "Hello, World!".data(using: .utf8)
        FileManager.default.createFile(atPath: filePath, contents: data, attributes: [:])
    }
}

最後に、テストコードの以下の部分でファイル内容の検証を行っています。ここではファイルの中身がHello, World!かどうかを検証しています。

let path = "\(sharedDirectoryPath)/result.txt"
let url = URL(fileURLWithPath: path)
let data = try! Data(contentsOf: url)
let resultText = String(data: data, encoding: .utf8)
XCTAssertTrue(resultText == "Hello, World!")

共有ディレクトリのパスをFinderで確認すると、たしかにresult.txtというファイルが保存されています。

f:id:bamboohero:20210514014458p:plain:w600

シミュレーターでのファイル共有はあっさり実現できました。

実機でファイル共有

実機でアプリ間でファイル共有をする方法として、App Groupという仕組みが用意されています。App Groupの設定を行うと、同一のApp Groupに所属するアプリだけがアクセス可能な専用のディレクトリが作成されます。

まずはApp Groupの設定をしていきましょう。

Apple Developerにログインし、App Groupを作成します。

f:id:bamboohero:20210514015808p:plain:w600

続いて、XcodeでメインターゲットのApp Groupを設定します。

f:id:bamboohero:20210514020422p:plain:w600

この状態で、テストコードの以下の部分だけ書き換えてUIテストを実行してみます。appGroupUrlはApp Group用の共有ディレクトリのパスです。

class BambooCIAppUITests: XCTestCase {
    var sharedDirectoryPath: String {
        // let simulatorSharedDirectory = ProcessInfo().environment["SIMULATOR_SHARED_RESOURCES_DIRECTORY"]!
        // return (simulatorSharedDirectory as NSString).appendingPathComponent("Library/Caches")

        let appGroupUrl = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.com.bamboo-hero.BambooCIApp")!
        return (appGroupUrl.path as NSString).appendingPathComponent("Library/Caches")
    }

    ...

UIテストを実機で実行すると、以下のエラーが出力されてクラッシュします。

2021-05-14 02:10:37.708070+0900 BambooCIAppUITests-Runner[44865:3839805] [unspecified] container_create_or_lookup_app_group_path_by_app_group_identifier: client is not entitled
BambooCIAppUITests/BambooCIAppUITests.swift:8: Fatal error: Unexpectedly found nil while unwrapping an Optional value

client is not entitled、つまり、テストランナーアプリ側にApp Groupの設定がされていないと言われています。

では言われた通りApp Groupの設定をしてみましょう。具体的には、UIテストターゲットに設定していきます。

UIテストターゲットの場合、XcodeのSigning & CapabilitiesタブでApp Groupの設定をすることはできません。なので手動で無理やり設定してみます。

まず、UIテストターゲット用のentitlementsファイルを作成します。

f:id:bamboohero:20210514022233p:plain:w600

次に、UIテストターゲットのBuild Settingsタブで、CODE_SIGN_ENTITLEMENTSにentitlementsファイルのパスを指定します。

f:id:bamboohero:20210514022503p:plain:w600

この状態でSigning & Capabilitiesタブを見ると、何やらエラーが出ています。(何をどう調べたか忘れたのですが)XcodeがUIテストターゲットのApp IDを登録するのに失敗しているようです。

f:id:bamboohero:20210514023153p:plain:w600

ではApple Developerにログインして、手動でApp IDを登録しましょう。今回の例ではUIテストターゲットのバンドルIDはcom.bamboo-hero.BambooCIAppUITestsなので、この名前でApp IDを作ります。

すると...こんなエラーが...

f:id:bamboohero:20210514015415p:plain:w600

xxUITestsという名前は予約されてるんでしょうか?仕方ないので、com.bamboo-hero.BambooCIAppGreatTestsという名前に変えて作成します。このApp IDにApp Groupを紐付けます。

f:id:bamboohero:20210514024122p:plain:w500

Xcodeに戻り、UIテストターゲットのバンドルIDを変更します。Build Settingsタブで変更することができます。

f:id:bamboohero:20210514024850p:plain:w600

これで署名周りのエラーがなくなりました。

f:id:bamboohero:20210514025003p:plain:w600

ではテストを実行してみましょう!

f:id:bamboohero:20210514025327p:plain:w600

...ダメでした。

テストランナーアプリのバンドルIDは最終的にcom.bamboo-hero.BambooCIAppGreatTests.xctrunnerになるみたいで、この名前のApp IDがApple Developerに登録されていないため、ワイルドカード指定のProvisioning profileが使用されるのですが、ワイルドカード指定のProvisioning profileではApp Groupが使用できないのです。

この先はもう省略しますが、com.bamboo-hero.BambooCIAppGreatTests.xctrunnerでApp IDを登録して、XcodeでバンドルIDを変更して、というのも試しましたが、結局どれもうまくいきませんでした。

実機でのファイル共有は諦めました。

まとめ

結局シミュレーターでしかできませんでしたが、アプリとテストランナーでファイル共有する方法について説明しました。

あれこれ試行錯誤して結局実機では実現できなかったのですが、おかげでiOSの署名周りの知識が結構身についた気がします。これはこれで良かったのかなと、今では思っています。