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


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

はじめに

Selenideについて

SelenideはSeleniumeのラッパーです。
Selenide: concise UI tests in JavaSelenideではWebDriverは自動で閉じたりElementの取得にCSSセレクタが使えます。かなり使いやすい感じになっています。

Selenium WebDriver:

1
WebElement customer = driver.findElement(By.id("customerContainer"));

Selenide:

1
WebElement customer = $("#customerContainer"); 

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

E2Eテスト自動化の必要性

次の一休.comのスライドにあるように、ユーザに価値を届けるスピードを向上に限ります。
一休.comのE2Eテスト事情 ~Selenium 3.0 対応~ /seleniumjp4_ikyu // Speaker Deckまた継続的インテグレーションの一環として必要なテストです。

何をテストするのか

前置きが長くなりましたが、ここからはアプリケーションについてまとめます。
はじめにテスト内容は認証処理にしました。FRESH!(フレッシュ) - 生放送がログイン不要・高画質で見放題をテスト対象サイトにします。FRESH!(フレッシュ) - 生放送がログイン不要・高画質で見放題テスト対象ページにはコード認証画面があります。以下、認証画面で行う内容がE2Eテストの内容になります。

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    val targetUrl = "https://freshlive.tv/auth/code"
    init {
        given("GET: $targetUrl") {
            `when`("非ログイン状態で認証ページを表示する") {
                then("画面構成に必要なモジュールがある") {
                    // 認証ページへアクセスする
                    // スクリーンショットを保存する
                    // 認証フォームがあるか
                }
            }

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

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

先程の一休.comのスライドでもふれていますがPage Object Design Patternで作るのが定石です。画面を1つのオブジェクトとして扱いページオブジェクトとテストシナリオを分離するような設計をベースにします。
またテストシナリオとテストデータを分離することでテストコードの拡張性の高さを保ちます。
各オブジェクトクラスをまとめるパッケージ構成は次のスライドを参考にまとめました。
Selenium2でつくるテストケースの構成について

パッケージ構成

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

1
2
3
4
5
6
7
spec-test
├── features
├── fixtures
├── modules
├── operators
├── pages
└── support

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

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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()
}

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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などのオペレーションを実行します。オペレータクラスの機能をページクラスに委譲させる
オペレータクラスは委譲を利用してページクラスに機能を委譲させます。次のようなコードでページクラスへオペレータクラスの処理を委譲させます。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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ライブラリを利用して画面のスクリーンショットをサポートします。


ashotを使うことでchromeブラウザで縦長な画面でも画面全体を撮ることができます。
※画面をスクロールして撮るためヘッダー要素が追随する場合はヘッダー要素が連続して写ります。ユーティリティクラスの機能をページクラスに委譲させる
ユーティリティクラスも同様に委譲を利用してページクラスに機能を委譲させます。

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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)
}

完成したSpecテスト

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
    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)
                }
            }
        }
    }

まとめ

ソースを公開しています

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

https://github.com/soushin/selenide-test

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

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