Posted at

PresenterのテストにSpek2を導入してテストを構造化してみる

こんにちは。

テスト書いてますか?

今年のDroidKaigiで「How to improve your MVP architecture and tests」というテーマで登壇しました。

https://speakerdeck.com/kirimin/how-to-improve-your-mvp-architecture-and-tests

その中で、フレームワークを使用せずKotlinのローカル関数を駆使してPresenterのテストを構造化してみるという試みを紹介しました。

その時はSpekはversion2が出る直前と言われていたこともあり、導入しなかったのですが、2018年12月現在ではSpekの2.0.0-rc.1が公開されています。

そこで、今回はSpek2を利用したPresenterのテストを構造化を試してみたいと思います。


Spekについて

こちらの記事で紹介されていますので参照ください。

Android Spekでユニットテスト書いてみた


Spek2の導入について

こちらの記事で紹介されていますので参照ください。

Spekの2系を使ってみる


Spek2のbeforeEachとafterEachの挙動

本題の前に、Spek2の仕様で分かりにくかったところがあったので解説します。

beforeEachメソッドの呼ばれるタイミングについてです。

Spekには各テストの前に呼び出されるbeforeEachというメソッドがあります。

JUnitの@Beforeに近いものだと思ってよいでしょう。

たとえば以下のようなコードを書いたとします。

object TestTest : Spek({

describe("test describe") {
beforeEach {
println("1")
}

context("test context") {
println("2")
beforeEach {
println("3")
}
it("test it") {
println("4")
}
}
}
})

私は最初、print内の数字通りに呼ばれると思っていました。

しかし実際には以下の順番で呼ばれます。

2

1
3
4

つまりcontext直下のコードは上位スコープのbeforeEachよりも先に呼ばれます。

ちなみにcontextやitが複数ある場合でも、上位スコープのbeforeEachはitの度に呼ばれます。

こういうことです。

    describe("test describe") {

beforeEach {
println("1")
}

context("test context") {
beforeEach {
println("2")
}
it("test it") {
println("3")
}
}

context("test context2") {
beforeEach {
println("4")
}
it ("test it2") {
println("5")
}
it ("test it3") {
println("6")
}
}
}

1

2
3
1
4
5
1
4
6


Spekを使わないコードのおさらい

さて、まずはDroidKaigiで紹介したSpekを使わないPresenterのテストコードを改めて見てみましょう。

class TopPresenterTest {

lateinit var viewMock: TopView
lateinit var useCaseMock: TopUseCase
lateinit var presenter: TopPresenter

@Before
fun setup() {
viewMock = mock()
useCaseMock = mock()
presenter = TopPresenter(viewMock, useCaseMock)
}

@Test
fun onCreateTest() {
fun callInitView() {
verify(viewMock, times(1)).initView()
}
presenter.onCreate()
callInitView()
}

@Test
fun userIdSubmitTest() {
whenever(useCaseMock.fetchUserInfo(anyString())).thenReturn(Single.never())

fun isError() {
verify(viewMock, times(1)).showErrorToast(anyString())
verify(viewMock, never()).setProgressBarVisibility(View.VISIBLE)
verify(useCaseMock, never()).fetchUserInfo(anyString())
}

fun isSuccess() {
verify(viewMock, never()).showErrorToast(anyString())
verify(viewMock, times(1)).setParentLayoutVisibility(View.GONE)
verify(viewMock, times(1)).setProgressBarVisibility(View.VISIBLE)
verify(viewMock, never()).setParentLayoutVisibility(View.VISIBLE)
verify(viewMock, never()).setProgressBarVisibility(View.GONE)
verify(useCaseMock, times(1)).fetchUserInfo("kirimin")
}

fun isIgnore() {
verify(viewMock, never()).showErrorToast(anyString())
verify(viewMock, never()).setProgressBarVisibility(anyInt())
verify(viewMock, never()).setParentLayoutVisibility(anyInt())
verify(useCaseMock, never()).fetchUserInfo(anyString())
}

fun isSubmitButtonClick() {
initializeMocks()
presenter.onSubmitButtonClick("")
isError()

initializeMocks()
presenter.onSubmitButtonClick("kirimin")
isSuccess()
}

fun withEditTextKeyEnter() {
initializeMocks()
presenter.onUserIdEditTextKeyListener("", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP)
isError()

initializeMocks()
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP)
isSuccess()

initializeMocks()
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_0, KeyEvent.ACTION_UP)
isIgnore()

initializeMocks()
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN)
isIgnore()
}

presenter.onCreate()
isSubmitButtonClick()
withEditTextKeyEnter()
}

@Test
fun onFetchUserInfoTest() {

fun success() {
fun hideProgressAndShowUserInfoLayout() {
verify(viewMock, times(1)).setProgressBarVisibility(View.GONE)
verify(viewMock, times(1)).setParentLayoutVisibility(View.VISIBLE)
}

fun setUserInfoMinCase() {
val userEntity = UserEntity(name = "kirimin", location = null, company = null, blog = null, email = null, avatar_url = null)
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.just(User(userEntity, listOf(RepositoryEntity()))))
initializeMocks()

presenter.onSubmitButtonClick("kirimin")
hideProgressAndShowUserInfoLayout()
verify(viewMock, times(1)).setUserName("kirimin")
verify(viewMock, times(1)).setLocationTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setMailTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setLinkTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setIconVisibility(View.INVISIBLE)
}

fun setUserInfoMaxCase() {
val userEntity = UserEntity(name = "kirimin", location = "tokyo, japan", company = "kirimin inc.", blog = "http://kirimin.me", email = "cc@kirimin.me", avatar_url = "http://kirimin.me/face_icon.png")
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.just(User(userEntity, listOf(RepositoryEntity()))))
initializeMocks()

presenter.onSubmitButtonClick("kirimin")
hideProgressAndShowUserInfoLayout()
verify(viewMock, times(1)).setUserName("kirimin")
verify(viewMock, times(1)).setLocationTextAndVisibility(View.VISIBLE, "tokyo, japan")
verify(viewMock, times(1)).setMailTextAndVisibility(View.VISIBLE, "cc@kirimin.me")
verify(viewMock, times(1)).setLinkTextAndVisibility(View.VISIBLE, "http://kirimin.me")
verify(viewMock, times(1)).setIconVisibility(View.VISIBLE)
verify(viewMock, times(1)).setIcon("http://kirimin.me/face_icon.png")

}

setUserInfoMinCase()
setUserInfoMaxCase()
}

fun failed() {
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.error(Exception("the error")))
initializeMocks()

presenter.onSubmitButtonClick("kirimin")
verify(viewMock, times(1)).setProgressBarVisibility(View.GONE)
verify(viewMock, never()).setParentLayoutVisibility(View.VISIBLE)
verify(viewMock, times(1)).showErrorToast(anyString())
}

presenter.onCreate()
success()
failed()
}
...


Spek2を導入して書き換えたコード

Spek2のcontextやbeforeEach、afterEachを利用して書き換えてみました。

object TopPresenterTest : Spek({

lateinit var viewMock: TopView
lateinit var useCaseMock: TopUseCase
lateinit var presenter: TopPresenter

describe("TopPresenterTest") {
beforeEach {
viewMock = mock()
useCaseMock = mock()
presenter = TopPresenter(viewMock, useCaseMock)
}

it("onCreate") {
presenter.onCreate()
verify(viewMock, times(1)).initView()
}

context("on user id submit") {
beforeEach {
whenever(useCaseMock.fetchUserInfo(anyString())).thenReturn(Single.never())
presenter.onCreate()
}

fun isError() {
verify(viewMock, times(1)).showErrorToast(anyString())
verify(viewMock, never()).setProgressBarVisibility(View.VISIBLE)
verify(useCaseMock, never()).fetchUserInfo(anyString())
}

fun isSuccess() {
verify(viewMock, never()).showErrorToast(anyString())
verify(viewMock, times(1)).setParentLayoutVisibility(View.GONE)
verify(viewMock, times(1)).setProgressBarVisibility(View.VISIBLE)
verify(viewMock, never()).setParentLayoutVisibility(View.VISIBLE)
verify(viewMock, never()).setProgressBarVisibility(View.GONE)
verify(useCaseMock, times(1)).fetchUserInfo("kirimin")
}

fun isIgnore() {
verify(viewMock, never()).showErrorToast(anyString())
verify(viewMock, never()).setProgressBarVisibility(anyInt())
verify(viewMock, never()).setParentLayoutVisibility(anyInt())
verify(useCaseMock, never()).fetchUserInfo(anyString())
}

context("submit button click") {
it("input empty") {
presenter.onSubmitButtonClick("")
isError()
}

it("input text") {
presenter.onSubmitButtonClick("kirimin")
isSuccess()
}
}

context("edit text key enter") {
it("input empty") {
presenter.onUserIdEditTextKeyListener("", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP)
isError()
}

it("input text and enter key up") {
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_UP)
isSuccess()
}

it("input text and other key enter") {
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_0, KeyEvent.ACTION_UP)
isIgnore()
}

it("input text and enter key down") {
presenter.onUserIdEditTextKeyListener("kirimin", KeyEvent.KEYCODE_ENTER, KeyEvent.ACTION_DOWN)
isIgnore()
}
}
}

context("on fetch user info") {
beforeEach {
presenter.onCreate()
}

context("success") {

it("min case") {
val userEntity = UserEntity(name = "kirimin", location = null, company = null, blog = null, email = null, avatar_url = null)
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.just(User(userEntity, listOf(RepositoryEntity()))))

presenter.onSubmitButtonClick("kirimin")
verify(viewMock, times(1)).setUserName("kirimin")
verify(viewMock, times(1)).setLocationTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setMailTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setLinkTextAndVisibility(eq(View.GONE), anyString())
verify(viewMock, times(1)).setIconVisibility(View.INVISIBLE)
}

it("show max view") {
val userEntity = UserEntity(name = "kirimin", location = "tokyo, japan", company = "kirimin inc.", blog = "http://kirimin.me", email = "cc@kirimin.me", avatar_url = "http://kirimin.me/face_icon.png")
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.just(User(userEntity, listOf(RepositoryEntity()))))

presenter.onSubmitButtonClick("kirimin")
verify(viewMock, times(1)).setUserName("kirimin")
verify(viewMock, times(1)).setLocationTextAndVisibility(View.VISIBLE, "tokyo, japan")
verify(viewMock, times(1)).setMailTextAndVisibility(View.VISIBLE, "cc@kirimin.me")
verify(viewMock, times(1)).setLinkTextAndVisibility(View.VISIBLE, "http://kirimin.me")
verify(viewMock, times(1)).setIconVisibility(View.VISIBLE)
verify(viewMock, times(1)).setIcon("http://kirimin.me/face_icon.png")
}

afterEach {
verify(viewMock, times(1)).setProgressBarVisibility(View.GONE)
verify(viewMock, times(1)).setParentLayoutVisibility(View.VISIBLE)
}
}

it("failed") {
whenever(useCaseMock.fetchUserInfo("kirimin")).thenReturn(Single.error(Exception("the error")))

presenter.onSubmitButtonClick("kirimin")
verify(viewMock, times(1)).setProgressBarVisibility(View.GONE)
verify(viewMock, never()).setParentLayoutVisibility(View.VISIBLE)
verify(viewMock, times(1)).showErrorToast(anyString())
}
}
...

テスト結果はこのように見ることが出来ます。

スクリーンショット 2018-12-01 22.42.02.png

SpekとKotlin DLSによってコードがすっきりしたのと、テストがより構造的、説明的になった気がします。

オレオレ設計からSpekベースに移行することでSpekを使った事がある人には読みやすくなったんじゃないでしょうか。(個人の感想です)

beforeEachとafterEachを利用することでシナリオテストで冗長になりがちな共通処理を共通化することが出来ます。

あまり正しい使い方ではない気もしますが、afterEach内にアサーションを書くときちんと呼び出された元のitのテストが失敗するので、共通のアサーションを書くのに便利だなと思いました。


補足

上記の例ではあまりbeforeEachとafterEachがわかりやすく使われていないので、別のコード例も貼っておきます。

describe("UserInfoPresenterTest") {

beforeEach {
viewMock = mock()
repositoryMock = mock()
presenter = UserInfoPresenter(viewMock, repositoryMock)
}

context("onCreate") {
beforeEach {
whenever(repositoryMock.requestRepository("kirimin", 1)).then { Observable.just(listOf<Repository>()) }
}

it("when success") {
val user = User(login = "kirimin", location = "tokyo", company = null, blog = null, email = null, avatar_url = null)
whenever(repositoryMock.requestUser("kirimin")).then { Single.just(user) }

presenter.onCreate("kirimin")
Assert.assertEquals(presenter.layoutVisibility.get(), View.VISIBLE)
Assert.assertEquals(presenter.user.get(), user)
}

it("when error") {
whenever(repositoryMock.requestUser("kirimin")).then { Single.error<User>(Throwable()) }

presenter.onCreate("kirimin")
verify(viewMock, times(1)).networkErrorHandling()
Assert.assertEquals(presenter.layoutVisibility.get(), View.INVISIBLE)
Assert.assertEquals(presenter.user.get(), null)
}

afterEach {
verify(repositoryMock, times(1)).requestUser("kirimin")
verify(repositoryMock, times(1)).requestRepository("kirimin", 1)
verify(viewMock, times(1)).initActionBar("kirimin")
}
}


リンク

サンプルに利用したリポジトリ

DroidKaigiKiriminDemoApp2018

https://github.com/kirimin/DroidKaigiKiriminDemoApp2018

Spek2を導入して書き換えたCommit

https://github.com/kirimin/DroidKaigiKiriminDemoApp2018/commit/9d25290307a31da6e9aa0e7e4cdd7ddfa4bff37e