Kotlin + SelenideでE2E自動テストのアプリケーションをつくってみた

昨年末のAdvent Calendarを読み漁ってたときにSelenideやE2E、kotlinなどのキーワードが頭に残っていました。キーワードを全部ひっくるめてkotlinでSelenideを使いE2Eテストをつくってみたいなぁと思いを馳せていたところ、プロジェクトでもE2Eテストの必要性が高まっている機運を感じ保守性と拡張性が高い設計を考えながらkotlinでE2Eテストアプリケーションをつくってみました。

はじめに

Selenideについて

SelenideはSeleniumeのラッパーです。
selenide.org

SelenideではWebDriverは自動で閉じたりElementの取得にCSSセレクタが使えます。かなり使いやすい感じになっています。

Selenium WebDriver:
WebElement customer = driver.findElement(By.id("customerContainer"));
Selenide:
WebElement customer = $("#customerContainer"); 

次の比較ページが参考になります。
Selenide vs Selenium · codeborne/selenide Wiki · GitHub

E2Eテスト自動化の必要性

次の一休.comのスライドにあるように、ユーザに価値を届けるスピードを向上に限ります。
一休.comのE2Eテスト事情 ~Selenium 3.0 対応~ /seleniumjp4_ikyu // Speaker Deck

また継続的インテグレーションの一環として必要なテストです。

何をテストするのか

前置きが長くなりましたが、ここからはアプリケーションについてまとめます。
はじめにテスト内容は認証処理にしました。

FRESH!(フレッシュ) - 生放送がログイン不要・高画質で見放題をテスト対象サイトにします。

freshlive.tv

テスト対象ページにはコード認証画面があります。

以下、認証画面で行う内容がE2Eテストの内容になります。

  • 認証画面のモジュールの整合性/画面内に必要な認証フォームが表示されているか、画面要素の崩れがないか(スクリーンショットを撮る)
  • 認証画面に遷移してコードを入力してログインできるか

Specテストのステップにすると次のようになります。

    val targetUrl = "https://freshlive.tv/auth/code"
    init {
        given("GET: $targetUrl") {
            `when`("非ログイン状態で認証ページを表示する") {
                then("画面構成に必要なモジュールがある") {
                    // 認証ページへアクセスする
                    // スクリーンショットを保存する
                    // 認証フォームがあるか
                }
            }

            `when`("非ログイン状態で認証ページに遷移して認証コードを入力する") {
                then("ログインができる") {
                    // 認証ページへアクセスする
                    // 認証コードを入力する
                    // トップへアクセスしてログインできているか確認する
                }
            }
        }

保守性と拡張性が高い設計を考える

先程の一休.comのスライドでもふれていますがPage Object Design Patternで作るのが定石です。

画面を1つのオブジェクトとして扱いページオブジェクトとテストシナリオを分離するような設計をベースにします。
またテストシナリオとテストデータを分離することでテストコードの拡張性の高さを保ちます。
各オブジェクトクラスをまとめるパッケージ構成は次のスライドを参考にまとめました。
Selenium2でつくるテストケースの構成について

パッケージ構成

次のようなパッケージ構成にそれぞれ機能ごとのクラスを配置しページオブジェクトとテストシナリオ、テストシナリオとテストデータを分離します。

spec-test
├── features
├── fixtures
├── modules
├── operators
├── pages
└── support

ここからは各パッケージについての解説です。

featuresパッケージについて
テストシナリオをまとめるパッケージです。このパッケージ配下にテストシナリオのクラスをまとめます。

fixturesパッケージについて
fixturesパッケージにはテストに必要なデータの雛形をまとめます。テストの度にシナリオにテストデータを記述することは面倒なので定型データは容易に参照できるようにオブジェクトにまとめます。fixturesパッケージのクラスにはdata classを使いました。kotlinのdata classは引数にデフォルト値を与えられることで雛形にするデータはデフォルト値にして上書きしたいデータは引数に渡すことでテストデータの拡張性を高く保てます。

// ログインに必要な認証コードの雛形データ
data class AuthCodeFixture(val code: String = "123456789012") 

modulesパッケージについて
参考にしたスライドにはないパッケージとなりますが、ページはモジュールの集合体です。1つのモジュールを複数のページで使うこともあります。1つのモジュールをオブジェクトクラスとしてmodulesパッケージにまとめます。このモジュールクラスは後述するページクラスから参照されます。

以下、モジュールクラスの例です。

interface Module {
    /**
     * WebElementの取得
     */
    fun getElement(): SelenideElement
}

abstract class AuthCodeBodyModuleBase : Module {

    companion object {
        const val cssSelector: String = ".AuthCode__body"
    }

    /**
     * 認証コード入力モジュールの取得
     */
    override fun getElement(): SelenideElement = Selenide.`$`(cssSelector)

    /**
     * 認証コード入力フィールドの取得
     */
    protected fun authCodeInputElement(): SelenideElement {
        return getElement().`$`("span input")
    }

    /**
     * 認証コード送信ボタンの取得
     */
    protected fun authSubmitElement(): SelenideElement {
        return getElement().`$`("button[type=submit]")
    }
}

class AuthCodeBodyModule : AuthCodeBodyModuleBase() {

    fun setAuthCodeInputValue(value: String) = authCodeInputElement().`val`(value)

    fun getAuthSubmitElement() = authSubmitElement()
}
  • 上記はログイン認証を入力するフォームを表したモジュールクラスです。
  • 抽象クラス(AuthCodeBodyModuleBase)はモジュール内の各要素(SelenideElement)を取得するセレクタ記述を担当します。SelenideElementを返す役割に徹底させます。
  • 具象クラス(AuthCodeBodyModule)は抽象クラスの要素(SelenideElement)を外部クラスへ公開したり、要素から見出しテキストなどのプリミティブな型オブジェクトを返す役割を担当します。
  • 抽象クラスにメンテナンス性の高いコードを寄せ、具象クラスで利用をコントロールさせることで保守性と拡張性を高めます。

operatorsパッケージについて
operatorsパッケージにはログインをする、検索するなどページのオペレーションをまとめます。

以下、オペレータクラスの例です。

interface AuthCodeOperatorBase {

    /**
     * 認証コードを入力します
     */
    fun input(module: AuthCodeBodyModule, code: String)

    /**
     * 認証します
     */
    fun submit(module: AuthCodeBodyModule)
}

class AuthCodeOperatorImpl : AuthCodeOperatorBase {

    override fun input(module: AuthCodeBodyModule, code: String) {
        // 認証コードのinputフィールドに認証コードを入力
        module.setAuthCodeInputValue(code)
    }

    override fun submit(module: AuthCodeBodyModule) {
        // 認証処理を送信
        module.getAuthSubmitElement().submit()
    }
}

上記のように認証コードを入力して送信ボタンを押下するオペレーションがまとまりました。モジュールクラスから要素を取得しsetValueやsubmitなどのオペレーションを実行します。

オペレータクラスの機能をページクラスに委譲させる
オペレータクラスは委譲を利用してページクラスに機能を委譲させます。

次のようなコードでページクラスへオペレータクラスの処理を委譲させます。

abstract class AuthCodePageBase constructor(
        private val authCodeOperator: AuthCodeOperatorBase,
        private val authCodeBodyModule: AuthCodeBodyModule
) : PageBase(ScreenshotSupportImpl()), AuthCodeOperatorBase by authCodeOperator ← 委譲させる

supportパッケージについて

テストに共通して必要なユーティリティをパッケージにまとめます。
スクリーンショットを撮る機能をsupportパッケージに配置しました。

以下、スクリーンショットのユーティリティクラスです。

interface ScreenshotSupport {

    /**
     * スクリーンショットを撮る
     */
    fun takeScreenshot(driver: WebDriver): BufferedImage

    /**
     * スクリーンショットをFilesystem保存する
     */
    fun storeImageToFs(image: BufferedImage, storePath: String)
}

class ScreenshotSupportImpl : ScreenshotSupport {

    override fun takeScreenshot(driver: WebDriver): BufferedImage {
        val screenshot = AShot()
                .shootingStrategy(ShootingStrategies.viewportPasting(100))
                .takeScreenshot(driver)
        return screenshot.image
    }

    override fun storeImageToFs(image: BufferedImage, storePath: String) {
        ImageIO.write(image, "PNG", File(storePath));
    }
}

上記のユーティリティクラスはashotライブラリを利用して画面のスクリーンショットをサポートします。

github.com
ashotを使うことでchromeブラウザで縦長な画面でも画面全体を撮ることができます。
※画面をスクロールして撮るためヘッダー要素が追随する場合はヘッダー要素が連続して写ります。

ユーティリティクラスの機能をページクラスに委譲させる
ユーティリティクラスも同様に委譲を利用してページクラスに機能を委譲させます。

abstract class PageBase(screenshot: ScreenshotSupport) : Page, ScreenshotSupport by screenshot ← 委譲させる

pagesパッケージについて
pagesパッケージには先述したモジュールクラスを参照しページを表すページクラスをまとめます。またオペレータクラス、サポートクラスの処理を委譲させることでページクラスに全ての機能が集約されます。

以下、ページクラスのコードです。

abstract class PageBase(screenshot: ScreenshotSupport) : Page, ScreenshotSupport by screenshot

abstract class AuthCodePageBase constructor(
        private val authCodeOperator: AuthCodeOperatorBase,
        private val authCodeBodyModule: AuthCodeBodyModule
) : PageBase(ScreenshotSupportImpl()), AuthCodeOperatorBase by authCodeOperator {

    /**
     * 認証コードの入力フォームモジュールが配置されているか
     */
    protected fun codeBodyModule(): AuthCodeBodyModule {
        return authCodeBodyModule
    }

    /**
     * 認証コードを入力して認証する
     */
    protected fun auth(code: String) {
        authCodeOperator.input(authCodeBodyModule, code)
        authCodeOperator.submit(authCodeBodyModule)
    }
}

class AuthCodePage : AuthCodePageBase(AuthCodeOperatorImpl(), AuthCodeBodyModule()) {

    /**
     * 認証コードの入力フォームモジュールが配置されているか
     */
    fun hasCodeBodyModule(): Boolean = codeBodyModule().getElement().`is`(enabled)

    /**
     * 認証コードを入力して認証する
     */
    fun executeAuth(code: String) = auth(code)
}
  • こちらもmodulesパッケージと同様に抽象クラスと具象クラスで役割を分けます。
  • 抽象クラス(AuthCodePageBase)ではページに必要なモジュールを取得する役割やオペレータクラスから委譲された機能の利用を管理します。
  • 具象クラス(AuthCodePage)ではモジュール要素にアクセスしながらプリミティブな型を外部クラスに公開したり、オペレータクラスの機能を実行させるメソッドを提供します。
  • 具象クラスの各メソッドの返り値の型をプリミティブな型にすることでページクラスを利用するSpecテストではSelenide、Seleniumの機能の理解が必要なくページクラスの機能を扱えます。

完成したSpecテスト

上記のパッケージ構成と各クラスの実装からSpecテストは次のようになりました。

    val url: String = "https://freshlive.tv/auth/code"
    val page = AuthCodePage()

    init {

        given("GET: $targetUrl") {
            `when`("非ログイン状態で認証ページを表示する") {

                then("画面構成に必要なモジュールがある") {

                    // 認証ページへアクセス
                    val driver = page.open(targetUrl, cookies = null)
                    // スクリーンショットを撮って./screenshotsに画像を保存
                    page.storeImageToFs(targetPage.takeScreenshot(driver), "./screenshots/auth_code.png")
                    // 認証フォームがあるか
                    page.hasCodeBodyModule() shouldBe true
                }
            }

            `when`("非ログイン状態で認証ページに遷移して認証コードを入力する") {

                then("ログインができる") {

                    // テストデータを参照する
                    val authCode = AuthCodeFixture("test_code:xxxxx")
                    // 認証ページへアクセス
                    val driver = page.open(targetUrl, cookies = null)
                    // 認証コードの入力
                    page.executeAuth(authCode.code)
                    // トップへアクセス
                    page.open(driver, "https://freshlive.tv/", cookies = null)
                }
            }
        }
    }

まとめ

  • fixtures, modules, operators, pagesが用途ごとに分離することで各パッケージとクラスの役割が明確になりました。
  • 委譲を利用することでページクラスに全ての機能を集約させることができました。
  • Specテストは見通しの良いコードになりました。(ページクラスのメソッド返り値をプリミティブ型の返り値にまとめることが鍵です)
  • kotlinで書いた所感として、data classや委譲など良い形で活用できたのとkotlinでもSelenideをベースにしたE2Eアプリケーションを書けたのでE2E自動テストのベース言語にkotlinいけるのではないかと感じました。

ソースを公開しています

今回はポイントごとにコードをコピペしたので全容はgithubを参照ください。
github.com

参考にさせていただいたページ

アプリケーション開発にあたって次のページを参考にさせていただきました。先駆者の方々ありがとうございます。