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!(フレッシュ) - 生放送がログイン不要・高画質で見放題をテスト対象サイトにします。
テスト対象ページにはコード認証画面があります。
以下、認証画面で行う内容が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) }
完成した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
参考にさせていただいたページ
アプリケーション開発にあたって次のページを参考にさせていただきました。先駆者の方々ありがとうございます。
KotlinでMockテストのまとめ
1ヶ月ほどkotlinで開発をしてきて、不慣れなkotlinであってもテストをしっかり書いていこうと目標を立て臨んだ1ヶ月。
おかげでkotlinにおけるMockテストの知見が溜まってきたので、この機会にまとめていきます。
javaではJMockitでMockテストを書いてきたけど、いざkotlinでとなると弊害が多くMockitoやPowerMockに置き換えながら試していった。
どんなテストをするのか
トランザクションで扱う複数の関数のテストを次のように実現できるとプログラムを網羅的にテストできるでしょう。
- クラス全体のパブリック関数のモック
- クラス一部分のパブリック関数のモック
- 呼び出す関数の引数のモック(anyString()やany()を使う)
- クラスのプライベート関数のモック
- クラスのプライベート関数のアサーションテスト
何をつかったか
次のテストライブラリを使いました。
- Powermockはトランザクションで実行されるプライベート関数のモックに使います。
- PowermockにはMockitoが内包されています。Mockitはクラス全体のパブリック関数のモックや一部分のパブリック関数のモックに使います。
github.com
テストケースはJUnitではなく、kotlintestを使いました。BehaviorSpecを使うとテストコードの見通しが良くなりますね。後述しますがPowermockを使う場合はJUnitでないと動かないところがあるのでJUnitも使っています。
それではテストコードのまとめです
テスト対象とするクラスの説明
次のようなinterfaceを用意しました。
interface Ppap { fun leftHand(sing: Boolean): String fun rightHand(sing: Boolean): String fun woo(right: String, left: String): String }
ピコ太郎のPPAPソングをクラス表現しました。
- leftHand(sing: Boolean)関数は左手に持っているモノを返す関数です。引数のsingがtrueだと歌詞を返し、falseだとモノの名前を返します。(rightHand(sing: Boolean)関数も右手に持つモノで振る舞いは一緒です)
- woo(right: String, left: String)は右手と左手のモノを合わせた歌詞を返します。
Ppapの実装クラスです。
open class PpapImpl constructor(val right: String, val left: String) : Ppap { val iHaveA = "I have" override fun leftHand(sing: Boolean): String { if (sing) return "$iHaveA $left." else return getObj(left) } override fun rightHand(sing: Boolean): String { if (sing) return "$iHaveA $right." else return getObj(right) } override fun woo(right: String, left: String): String = "%s%s.".format(left, right) private fun getObj(obj: String) = obj.split(Regex("\\s+")).get(1) }
こちらのテストコードを見て頂くとプログラムのイメージが伝わるはずです。
PpapImplを使ってPPAPソングを歌うPpapSongクラスを用意しました。
class PpapSong constructor(val ppap: PpapImpl) { fun sing(): String { return "%s %s woo %s".format( ppap.rightHand(true), ppap.leftHand(true), ppap.woo(ppap.rightHand(false), ppap.leftHand(false))) } }
PpapImplの関数をrightHand()からleftHand()、woo()と順番に呼び出しています。
PpapImplクラスの関数をモックしながらPpapSongクラスのテストをしました。
クラス全体のパブリック関数のモック
はじめにクラス全体のパブリック関数のモックです。Mockitoを活用していきます。
val ppapMock: PpapImpl = mock(PpapImpl::class.java) // PpapImplをモック対象にする val ppapSong = PpapSong(ppapMock) Mockito.`when`(ppapMock.rightHand(sing = true)).thenReturn("I have a Ebi.") // 各メソッドをモック Mockito.`when`(ppapMock.leftHand(sing = true)).thenReturn("I have a Bin.") Mockito.`when`(ppapMock.rightHand(sing = false)).thenReturn("Ebi") Mockito.`when`(ppapMock.leftHand(sing = false)).thenReturn("Bin") Mockito.`when`(ppapMock.woo("Ebi", "Bin")).thenReturn("EbiInBin.") val actual = ppapSong.sing() actual shouldBe "I have a Ebi. I have a Bin. woo EbiInBin."
Mockitoをkotlinで使ってみた、のようなコードです。
クラス一部分のパブリック関数のモック
次に一部分の関数をモックするパターンです。Mockitoのspyを使います。
val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = spy(target) // PpapImplをspy対象にする val ppapSong = PpapSong(spy) Mockito.`when`(spy.leftHand(sing = true)).thenReturn("I have a PineApple.") // 左手をAppleからPineAppleにモックする Mockito.`when`(spy.leftHand(sing = false)).thenReturn("PineApple") val actual = ppapSong.sing() actual shouldBe "I have a Pen. I have a PineApple. woo PineApplePen."
leftHandの呼び出しのみモックしました。他の関数はすべてモックされず実行しています。
最後が`ApplePen`ではなく`PineApplePen`になっているのが確認できますね。
関数の引数のモック(anyString()やany()を使う)
次に関数の引数をany()などを使い任意の引数にマッチさせたりマッチが難しい場合にany()で任意の値でマッチさせるケースです。
※マッチが難しいケースとしては引数にプリミティブな型を使っていないケースや、現在時刻のLocalDateTimeを引数にしているケースなどが考えられます。
val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = spy(target) val ppapSong = PpapSong(spy) Mockito.`when`(spy.woo(anyString(), anyString())).thenReturn("PenPineAppleApplePen.") // wooメソッドの引数は任意でマッチさせてReturnの文字列を書き換えています val actual = ppapSong.sing() actual shouldBe "I have a Pen. I have an Apple. woo PenPineAppleApplePen."
このケースではプリミティブな型のためanyString()を利用しています。
プリミティブ型以外の場合はany()やanyObject()を使うところですが、kotlinでは嵌まりました。
詳細は以下の記事に詳しく載っていますので参照ください。
Kotlin + Mockitoでany<T>()やeq<T>()を使いたい - JDBな人生
テスト対象の関数の引数がNon-Nullなオブジェクトかどうかkotlinがチェックするため起こる現象で解決する方法も記載頂いています。
私はこのケースでド嵌まりしまして、この記事に救われました。
クラスのプライベート関数のモック
次にクラスのプライベート関数のモックです。このケースはPowerMockを使います。
@RunWith(PowerMockRunner::class) @PrepareForTest(PpapImpl::class) class PpapSong_Private_Method_MockTest { @Test fun rightHand_test() { val target: PpapImpl = PpapImpl(right = "a Pen", left = "an Apple") val spy = PowerMockito.spy(target) PowerMockito.doReturn("Ebi").`when`(spy, "getObj", "a Pen") val actual = spy.rightHand(false) assertThat(actual, `is`("Ebi")) } }
- PpapImplのrightHand関数をモックしています。doReturn()を`Ebi`にしました。
- クラスに@RunWithと@PrepareForTestのアノテーションを指定します。
- @PrepareForTestにはテスト対象のクラスを指定します。今回はPpapImplクラスになります。
- PowerMockRunnerクラスの実装を見ると今のところはJUnitのみをサポートしているようなので、PowerMockを使う場合はJUnitでテストケースを書きます。
クラスのプライベート関数のアサーションテスト
最後にクラスのプライベート関数のアサーションテストです。
given("getObj method") { val target = PpapImpl(right = "a Pen", left = "an Apple") var param: String `when`("param is 'a Pen'") { then("method should return 'Pen'") { param = "a Pen" val actual = Deencapsulation.invoke<String>(target, "getObj", param) actual shouldBe "Pen" } } `when`("param is 'an Apple'") { then("method should return 'Apple'") { param = "an Apple" val actual = Deencapsulation.invoke<String>(target, "getObj", param) actual shouldBe "Apple" } } `when`("param is 'Ebi'") { then("method throws exception") { param = "Ebi" shouldThrow<ArrayIndexOutOfBoundsException> { Deencapsulation.invoke<String>(target, "getObj", param) } } } }
まとめ
objectやenumクラスの例はありませんでしたがプロジェクト内ではテストケースを書いており上記で紹介した書き方と同様な書き方でテストができています。
kotlinでもモックテストをガシガシ書けますし、これからはJava製のモックライブラリではなくkotlin純正のライブラリが出てくることを期待しています。
mockito-kotlinというkotlinで書かれたモックライブラリがありましたがMockitoをラッピングしているライブラリとなっています。どこかのエントリでこちらの使用感の報告もできればしたいです。
ソースを公開しています
ソースコードを公開しています。
github.com
Kotlin + Spring Boot/ResponseEntityを使ったJSONレスポンスにJacksonの@JsonPropertyを有効にする
Spring Bootを使ってkotlinで書いています。サーバサイドでkotlinを使うと新たな発見があるのでいいですね。
ControllerのレスポンスにResponseEntityを使ったところdata classのプロパティを@JsonPropertyでリネームしたのに有効になりませんでした。
自分のググラビリティが低く解決方法が見つからず小一時間ほど費やしてしまいました。同じような人(いれば)のために解決方法をメモします。
こんな環境で試しました
- Spring Bootは1.4.2
- kotlinは1.0.4
次のようなdata classを用意します。
data class Member( val userId: Long, val name: String, @JsonIgnore val age: Int, @JsonProperty("isGold") val gold: Boolean = false )
@JsonProperty("isGold")のようにJsonレスポンスではgoldのプロパティ名をisGoldにしたいです。
次のようなコントローラーでMemberクラスを返します。
@RequestMapping(value = "/member/{id}", method = arrayOf(RequestMethod.GET)) open fun getUser(@PathVariable id: Long): ResponseEntity<Member> { return ResponseEntity(Member(id, "name", 20, true), HttpStatus.OK) }
次のようなレスポンスになります
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "gold": true, "name": "name", "userId": 1 }
ageは @JsonIgnoreで隠れているのに、goldがisGoldではありません。
こんな状況でした。
kotlinモジュールを使う
kotolinモジュールを追加すると解決します。
github.com
Gradle:
compile "com.fasterxml.jackson.module:jackson-module-kotlin:2.8.4"
kotlinモジュールを追加すると次のようなレスポンスになります
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "age": 20, "isGold": true, "name": "name", "userId": 1 }
isGoldになりました!!・・??、@JsonIgnoreしたageがignoreされていない。。
kotlinモジュールを有効にしたら@JsonIgnoreの宣言は以下のように変える必要があります。
data class User( val userId: Long, val name: String, @get:JsonIgnore val age: Int, @JsonProperty("isGold") val gold: Boolean = false )
data classを更新して再度レスポンスをとると
curl -X GET http://localhost:8080/test/member/1 | python -m json.tool { "isGold": true, "name": "name", "userId": 1 }
期待する結果となりました:clap:
kotlinで@JsonPropertyの使い方を調べていたら棚から牡丹餅のように@JsonIgnoreの使い方も学べました。
ソースを公開しています
ソースコードを公開しています。
github.com