STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

Kickstart iOS E2E Testing with POM

この記事はSTORES Advent Calendar 2024の12日目の記事です。

こんにちは、@marcy731 です。 STORES ブランドアプリ のモバイルチームのマネージャー兼iOSエンジニアをしています。

STORES ブランドアプリ とは、オーナーさまごとにオリジナルなブランドアプリを作成し、その後の運用や分析をかんたんに行うことのできるサービスになります。

stores.jp

STORES ブランドアプリ では複数のアプリを開発・運用するにあたって、品質の維持と向上への取り組みが課題となっていました。

STORES Advent Calendar 2024 の9日目の記事では @error96num が「STORES ブランドアプリ でE2Eテストはじめました」「STORES ブランドアプリ AndroidのE2Eテストの実装 - Robotパターンの活用」というタイトルで STORES ブランドアプリ におけるE2Eテスト(End-to-Endテスト)導入の背景とプロセスについて紹介しました。 本記事の背景部分をお話ししていますので、まだご覧になっていない方はぜひ見てみてください。

product.st.inc

product.st.inc

本記事では、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
    }
}

これで、このクラス内でemailTextFieldsignInButtonにアクセスできるようになりました。

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から「その他」タブを選んで「ログインするボタン」を押し…といった操作が必要かもしれません。
これらも同様にTabBarPageOtherViewPageAuthViewPageなどを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 ブランドアプリ 以外のプロダクトでもエンジニアを絶賛募集中です。 ぜひ採用サイトにも遊びに来てください。

jobs.st.inc