Slide 1

Slide 1 text

⼿動確認はもう限界 〜XCUITestでCustom URL Schemeの 遷移を起動種別ごとに⾃動テストする〜 Arata Goto / @otouto555 2026/09/13 iOSDC Japan 2026 1

Slide 2

Slide 2 text

⾃⼰紹介 ・Arata Goto (@otouto555) ・株式会社レコチョク ・緊張してます 2

Slide 3

Slide 3 text

Custom URL Schemeとは アプリの特定画⾯へ遷移させるために 使われる仕組み 例: myapp://iosdc 3

Slide 4

Slide 4 text

Custom URL Schemeの確認って ⾯倒じゃないですか・・・︖

Slide 5

Slide 5 text

Custom URL Schemeの確認がつらい •確認を⼿動で⾏っていた •Safariで開く •メモ帳などにURLを貼ってタップする •アプリ内にデバッグ機能をつける •遷移できたかは⽬視で確認していた •Custom URL Schemeの数も 増えていきがち(弊社アプリは30個) 5

Slide 6

Slide 6 text

🔥SceneDelegate対応🔥

Slide 7

Slide 7 text

Custom URL Schemeの呼び出しメソッドの変更 AppDelegateではCustom URL Scheme で呼ばれるメソッドは1つだった application(_:open:options:) 7

Slide 8

Slide 8 text

Custom URL Schemeの呼び出しメソッドの変更 SceneDelegateでは起動種別に応じて どちらかのメソッドが呼ばれる scene(_:willConnectTo:options:) scene(_:openURLContexts:) 8

Slide 9

Slide 9 text

起動種別について整理する 起動種別 プロセス メモリ 例 Cold Start なし なし 端末再起動後 Warm Start なし ⼀部キャッシュ 終了直後の起動 Resume あり 全保持 サスペンドから復帰 参考︓WWDC19 "Optimizing App Launch" 9

Slide 10

Slide 10 text

起動種別について整理する 起動種別 プロセス メモリ 例 Cold Start なし なし 端末再起動後 Warm Start なし ⼀部キャッシュ 終了直後の起動 scene(_:willConnectTo:options:) Resume あり 全保持 サスペンドから復帰 scene(_:openURLContexts:) 10

Slide 11

Slide 11 text

起動種別について整理する 起動種別 プロセス メモリ 例 Cold Start なし なし 端末再起動後 Warm Start なし ⼀部キャッシュ 終了直後の起動 Launch Resume Resume scene(_:willConnectTo:options:) あり 全保持 サスペンドから復帰 scene(_:openURLContexts:) 11

Slide 12

Slide 12 text

確認のパターンがLaunchと Resumeで2倍になった

Slide 13

Slide 13 text

開発しているアプリはCustom URL Schemeが30個ある

Slide 14

Slide 14 text

60パターンも⾒るのはつらい

Slide 15

Slide 15 text

そこで

Slide 16

Slide 16 text

XCUITest

Slide 17

Slide 17 text

XCUITestで動作を担保する •起動種別に応じた テストができる •ユニットテストは 起動種別まで確認 できない Launch 17 Resume

Slide 18

Slide 18 text

実装⽅法

Slide 19

Slide 19 text

テストの全体像 1. テストしたい画⾯のスキームと確認 したい要素を定義する 2. Launch・Resumeを再現する 3. 遷移後の画⾯で、要素を確認する 確認の途中で画⾯操作を経由しないので 壊れにくいUITestになる 19

Slide 20

Slide 20 text

テストの全体像 1. テストしたい画⾯のスキームと確認 したい要素を定義する 2. Launch・Resumeを再現する 3. 遷移後の画⾯で、要素を確認する ※ソースコード全体は後ほど共有する 本スライドのAppendixを参照ください 20

Slide 21

Slide 21 text

Launchのテスト /// Launchのテスト func assertLaunch() { app.open(schemeURL) // 要素の検証処理 } 21

Slide 22

Slide 22 text

Launchのテスト /// Launchのテスト func assertLaunch() { app.open(schemeURL) // 要素の検証処理 XCUIApplication } func open(_ url: URL) Launches the application by URL. 22

Slide 23

Slide 23 text

Resumeのテスト /// Resumeのテスト func assertResume() { app.open(url) app.open(url) // 要素の検証処理 } 23

Slide 24

Slide 24 text

Resumeのテスト /// Resumeのテスト func assertResume() { app.open(url) openを⼆回呼ぶことで、 app.open(url) Resumeを再現できる︖ // 要素の検証処理 } 24

Slide 25

Slide 25 text

Resumeのテスト /// Resumeのテスト func assertResume() { app.open(url) openを⼆回呼ぶことで、 app.open(url) Resumeを再現できる︖ // 要素の検証処理 } 25

Slide 26

Slide 26 text

Resumeのテストでつまづいたところ XCUIApplication func open(_ url: URL) Launches the application by URL. open(_:)は呼ぶたびに、アプリを終了 して起動し直す 26

Slide 27

Slide 27 text

Resumeのテスト(修正版) /// Resumeのテスト func assertResume() { app.launch() XCUIDevice.shared.system.open(schemeURL) // 要素の検証処理 } 27

Slide 28

Slide 28 text

Resumeのテスト(修正版) /// Resumeのテスト func assertResume() { app.launch() XCUIDevice.shared.system.open(schemeURL) // 要素の検証処理 XCUIApplication } func launch() Launches the application. 28

Slide 29

Slide 29 text

Resumeのテスト(修正版) /// Resumeのテスト func assertResume() { app.launch() XCUIDevice.shared.system.open(schemeURL) // 要素の検証処理 } XCUISystem func open(_ url: URL) Opens an item in the default application for a URL. 29

Slide 30

Slide 30 text

もう何⼗パターンも ⾒る必要はない

Slide 31

Slide 31 text

No content

Slide 32

Slide 32 text

まとめ •SceneDelegateでは起動種別によって 呼ばれるメソッドが変わる •テストはLaunchとResumeの両⽅の 観点が必要 •XCUITestを⽤いることで、両⽅の観点 を確認できる 32

Slide 33

Slide 33 text

Appendix

Slide 34

Slide 34 text

テストに必要なプロトコルのコード import XCTest /// Launchのテスト func assertLaunch() { app.open(schemeURL) /// URLスキームテストに必要な要素を定義するプロトコル protocol URLSchemeTestable: XCTestCase { /// スキームのホスト名(例: "iosdc") var schemeHost: String { get } /// クエリパラメータ var queryItems: [URLQueryItem] { get } /// 遷移先の画⾯要素 var targetElement: XCUIElement { get } /// クエリパラメータの反映を検証する要素 var queryAssertionElement: XCUIElement? { get } } XCTAssertTrue(targetElement.waitForExistence(timeout: 10)) if let element = queryAssertionElement { XCTAssertTrue(element.exists) // クエリの反映も⾒たいスキームだけ } } /// Resumeのテスト func assertResume() { app.launch() XCUIDevice.shared.system.open(schemeURL) extension URLSchemeTestable { var app: XCUIApplication { XCUIApplication() } var queryAssertionElement: XCUIElement? { nil } var schemeURL: URL { var components = URLComponents() components.scheme = "myapp" components.host = schemeHost components.queryItems = queryItems return components.url! } // 起動させ、Launchを検証する // 「起動済み」の状態を作る // システムにURLを開かせる XCTAssertTrue(targetElement.waitForExistence(timeout: 10)) if let element = queryAssertionElement { XCTAssertTrue(element.exists) } } } 34

Slide 35

Slide 35 text

プロトコルを確認したい画⾯に準拠 import XCTest final class IOSDCSchemeTests: XCTestCase { func test_Launch_iosdcスキームで詳細画⾯が表⽰される() { assertLaunch() } func test_Resume_iosdcスキームで詳細画⾯に遷移する() { assertResume() } } extension IOSDCSchemeTests: URLSchemeTestable { var schemeHost: String { "iosdc" } var queryItems: [URLQueryItem] { [URLQueryItem(name: "year", value: "2026")] } var targetElement: XCUIElement { app.otherElements["IOSDCDetailViewController"] } var queryAssertionElement: XCUIElement? { app.staticTexts["iOSDC 2026"] } } 35