この記事はSTORES Advent Calendar 2024の12日目の記事です。
こんにちは、@marcy731 です。 STORES ブランドアプリ のモバイルチームのマネージャー兼iOSエンジニアをしています。
STORES ブランドアプリ とは、オーナーさまごとにオリジナルなブランドアプリを作成し、その後の運用や分析をかんたんに行うことのできるサービスになります。
STORES ブランドアプリ では複数のアプリを開発・運用するにあたって、品質の維持と向上への取り組みが課題となっていました。
STORES Advent Calendar 2024 の9日目の記事では @error96num が「STORES ブランドアプリ でE2Eテストはじめました」「STORES ブランドアプリ AndroidのE2Eテストの実装 - Robotパターンの活用」というタイトルで STORES ブランドアプリ におけるE2Eテスト(End-to-Endテスト)導入の背景とプロセスについて紹介しました。 本記事の背景部分をお話ししていますので、まだご覧になっていない方はぜひ見てみてください。
本記事では、iOS アプリのE2Eテスト実装で、可読性・再利用性・保守性を向上するために採用した POM(Page Object Model)の考え方と、新たに導入した TestUseCase という概念を利用した具体的な実装例を紹介します。
はじめに
iOSアプリを開発していると、ユーザーが実際に操作する流れを自動で検証したい場面が出てきます。
これがE2E (End-to-End) テストの役割です。E2Eテストにより、アプリの起動から画面遷移、入力、結果確認までを自動化でき、品質向上やリリース前の安心感が得られます。
しかし、いざE2Eテストを書こうとすると、こんな悩みが出てきませんか。
- テストコードにUI操作がベタ書きで、読むのが大変
- UIが少し変わるたびに、テストコード全体を修正しなきゃいけない
- よく使う操作(例:ログイン処理)を毎回コピペして非効率
本記事では、POM(Page Object Model)パターンとTestUseCaseを活用して、
「UI変更に強く、読みやすく、再利用しやすいE2Eテスト」を書く方法を、初心者向けにステップを踏みながら解説します。
また、POMとよく比較されるRobotパターンについても解説し、なぜ今回のアプローチでRobot的なメリットも取り込めるのかを紹介します。
POM(Page Object Model)とは?
POMは「1画面=1オブジェクト」というコンセプトで、テストする画面に対応するPageオブジェクトを用意します。
POMを使う前の問題点(例)
想像してみてください。ログイン処理のE2Eテストを書きたいとします。
func testSignInWithoutPOM() { let app = XCUIApplication() app.launch() app.tabBars.buttons["com.marcy731.otherTab"].tap() app.buttons["com.marcy731.signInButton"].tap() let emailField = app.textFields["com.marcy731.emailField"] emailField.tap() emailField.typeText("test@example.com") let passwordField = app.secureTextFields["com.marcy731.passwordField"] passwordField.tap() passwordField.typeText("xxxxxxxxxxx") app.buttons["Sign In"].tap() // SignIn後の確認... }
このようにテストコード内にUI要素取得・操作が直接書かれていると、
- UIのidentifierが変わるたびにここを修正
- 同じフローを他のテストでも使いたい場合はコピペ
- コードが長く読みにくい
という問題が発生します。
POMで問題解消
POMでは、このUI要素取得や操作をPageオブジェクトにまとめます。
すると、テストコードはPageオブジェクト経由で画面操作を指示するだけでよくなり、UI変更時の対応や再利用が格段に楽になります。
Robotパターンとの比較
Robotパターンは、UI操作を「ロボット」にまとめる手法で、POMと似た効果があります。ただし、Robotパターンはシナリオ単位の抽象化になりやすく、1画面単位でわかりやすく分割できないこともあります。
- POM:画面単位で抽象化 → UI変更への対応が直感的で簡単
- Robot:シナリオ単位で抽象化 → 複雑なフローをまとめるのに便利
そこで、POMで画面単位を整えた上で、TestUseCaseを使えばRobotパターン的なシナリオ再利用メリットも得られます。
Step by StepでPOM+TestUseCase での実装例
ここからは、実際のコードを少しずつ構築していきます。
例として、SignIn画面への入力とログインボタンタップをE2Eテストで行う流れを扱います。
Step 1: E2Eテストの下準備
まずは、XCTestCaseクラスを用意し、アプリを起動するところから始めます。
final class SignInTests: XCTestCase { let app = XCUIApplication() override func setUpWithError() throws { // テスト用の起動引数を設定(UserDefaults初期化など) app.launchArguments.append("-resetUserDefaults") // アプリ起動 app.launch() // テスト失敗後は続行しない continueAfterFailure = false } override func tearDownWithError() throws { // 必要なクリーンアップがあればここで } }
ここまでで、テストが起動してアプリを準備する最初のステップが完了です。
Step 2: SignIn画面用のPageオブジェクトの雛形を作る
次に、SignInViewPage
を作成します。まずはクラスの構造だけを用意します。
public actor SignInViewPage { let app: XCUIApplication @MainActor public init(app: XCUIApplication) { self.app = app } }
まだ何も書いていませんが、これで「SignIn画面に対応するオブジェクト」ができました。
Step 3: UI要素を定義する
SignIn画面には、emailとpasswordを入力するテキストフィールド、そしてSignInボタンがあります。それぞれをPageオブジェクト内で取得してみましょう。
public actor SignInViewPage { let app: XCUIApplication // UI要素定義 @MainActor private var emailTextField: XCUIElement { app.textFields["com.marcy731.emailTextField"] } @MainActor private var passwordTextField: XCUIElement { app.secureTextFields["com.marcy731.passwordTextField"] } @MainActor private var signInButton: XCUIElement { app.buttons["com.marcy731.signInButton"] } @MainActor public init(app: XCUIApplication) { self.app = app } }
これで、このクラス内でemailTextField
やsignInButton
にアクセスできるようになりました。
Step 4: 操作用メソッドを追加
次に、メール入力やパスワード入力、SignInボタンをタップするメソッドを用意します。
これで、テストコードはtypeEmail()
やtypePassword()
を呼ぶだけで入力できるようになります。
@MainActor public func typeEmail(_ email: String?) -> Self { guard let email else { return self } emailTextField.tap() emailTextField.typeText(email) return self } @MainActor public func typePassword(_ password: String?) -> Self { guard let password else { return self } passwordTextField.tap() passwordTextField.typeText(password) return self } @MainActor public func tapSignInButton() -> Self { signInButton.tap() return self }
Step 5: アサーション(表示確認)を追加
SignIn画面が正しく表示されているかをチェックするメソッドを作ります。
@MainActor public func assertDisplayed() -> Self { XCTAssertTrue(emailTextField.exists, "emailTextField が存在しません") XCTAssertTrue(passwordTextField.exists, "passwordTextField が存在しません") XCTAssertTrue(signInButton.exists, "signInButton が存在しません") return self }
これで、テストコードからSignInViewPage(app: app).assertDisplayed()
と呼べば、この画面が表示されていることを保証できます。
Step 6: Pageオブジェクトを使ってテストを書く
SignIn画面に行くまでには、TabBarから「その他」タブを選んで「ログインするボタン」を押し…といった操作が必要かもしれません。
これらも同様にTabBarPage
、OtherViewPage
、AuthViewPage
などをPOMで作っておくと、テストコード側は以下のように書けます。
@MainActor func testSignIn() throws { let testUserAccount = TestUserAccount.testUser1() // アプリ起動後、タブバーが表示されていることを確認、その他タブを押す let _ = TabBarPage(app: app) .assertDisplayed() .tapOtherTab() // 「ログインする」ボタンを押してAuth画面へ let _ = OtherViewPage(app: app) .tapAuthViewButton() // Auth画面からSignIn画面へ遷移するボタン押下 let _ = AuthViewPage(app: app) .tapSignInButton() // SignIn画面でEmail/Passwordを入力し、SignInボタンを押す let _ = SignInViewPage(app: app) .assertDisplayed() .typeEmail(testUserAccount.email) .typePassword(testUserAccount.password) .tapSignInButton() // SignIn後の状態確認... }
テストコードは、UI操作の詳細から解放されて、「何をしているか?」を読みやすくなっています。
UI変更時は対応するPageオブジェクトを直すだけでOK。
Step 7: TestUseCaseでシナリオ再利用
「SignInしてから行いたいテスト」は他にもあるはずです。
毎回同じフロー(TabBar → OtherView → AuthView → SignInView)を書くのは冗長ですよね。
ここで、TestUseCaseを導入します。
SignInTestUseCase
として、SignInまでの流れを1つにまとめます。
public actor SignInTestUseCase: TestUseCase { let app: XCUIApplication @MainActor public init(app: XCUIApplication) { self.app = app } @MainActor public func execute(testUserAccount: TestUserAccount) { let _ = TabBarPage(app: app) .assertDisplayed() .tapOtherTab() let _ = OtherViewPage(app: app) .tapLoginButton() let _ = AuthViewPage(app: app) .tapLoginButton() let _ = SignInViewPage(app: app) .typeEmail(testUserAccount.email) .typePassword(testUserAccount.password) .tapSignInButton() } }
こうしておけば、テストコードでSignInTestUseCase(app: app).execute(testUserAccount: testUserAccount)
と呼ぶだけでサインイン状態にできます。
@MainActor func testSignIn() throws { let testUserAccount = TestUserAccount.testUser1() // SignInフローをTestUseCaseで一発実行 SignInTestUseCase(app: app) .execute(testUserAccount: testUserAccount) // SignIn後にログイン状態を確認するTestUseCaseもあればここで呼べる CheckLoginStatusTestUseCase(app: app) .execute(testUserAccount: testUserAccount) }
このように、TestUseCaseはRobotパターンのような「シナリオ単位の抽象化」を実現し、POMで整えたUI操作基盤の上にスッキリ乗っかる構造になっています。
まとめ
E2Eテストは簡潔に書ける
POMによりUI操作がPageオブジェクト内に集約され、テストコードはシンプルに。UI変更時のメンテナンスが容易
画面対応のPageオブジェクトを修正するだけで全テストが追従可能。TestUseCaseで再利用性アップ
ログイン処理など繰り返し使うフローをTestUseCase化すれば、一行で呼び出せる。Robotパターン的な利点も兼ね備える
シナリオ再利用(TestUseCase)を組み合わせることで、POM+TestUseCaseはRobotパターン的な良さもカバー。
E2Eテストは、最初は複雑に見えても、POMとTestUseCaseを組み合わせると驚くほどスッキリしたコードになります。
UIが頻繁に変わる現場でも、慌てる必要はありません。Pageオブジェクトを修正すれば済み、テスト全体は影響を受けずに保守できます。
ぜひ、この手法をあなたのプロジェクトでも試してみてください。 アプリ品質の向上と、テストコードの保守効率UPを同時に実現することができます。
さいごに
本記事では、 POM(Page Object Model)の考え方と、新たに導入した TestUseCase という概念を利用したE2Eテストの具体的な実装例を紹介しました。 アプリ品質の向上と、テストコードの保守効率UPを同時に実現することができますので、少しでも何かの参考になりましたら幸いです。
STORES ブランドアプリ ではこのような複数のアプリを開発・運用するにあたって、品質の維持と向上への取り組みを行っています。 少しでも面白そうと感じていただけましたら、ぜひカジュアルにTwitterまで連絡をいただけますと泣いて喜びます。
また STORES では STORES ブランドアプリ 以外のプロダクトでもエンジニアを絶賛募集中です。 ぜひ採用サイトにも遊びに来てください。