MicroAd Developers Blog

マイクロアドのエンジニアブログです。インフラ、開発、分析について発信していきます。

Spring BootとKotlin開発における結合テストの自動化について

はじめに

はじめまして。マイクロアドシステム開発部UDU(UI/UX開発ユニット)でアプリケーションエンジニアをしている崎元です。

今回は、私の所属するユニットでの自動テスト推進についてお話していきます。
まずは、お話を始めるにあたってマイクロアドシステム開発部でのテスト方針について触れさせていただきます。

目次

自動テストピラミッド

自動テストピラミッド
マイクロアドでは、ユニット間のテストに関する認識齟齬を防ぐために上記の図を用いています。
ユニットテスト、統合テスト、システムテストと分類する中、図で示すように下に行くほどテストの量が多くなっているべきという考え方になっています。
システムテストまで自動テストで行えるようにし、受け入れに自動テストのエビデンスを使用することで手動テストの割合を減らすことを目標としています。

このように変えた時に得られるメリット

  • エビデンスが残しづらいケースや発生させづらいケース(外部APIを失敗させた時の動きなど)でも、自動テストで用意したテストデータ及び結果を受け入れ時にエビデンスとして使用出来る。
  • 手動テストが減って自動テストが増えるので、長期的な保守で考えた場合のメンテナンスコストが減る。
  • 自動テストが増えるとリファクタリングが行いやすくなり、間接的にはコード品質が上がる。

このように変えた時のデメリットは短期的な工数

  • 自動テストコードの実装に慣れるまで作業工数が上がる。
  • 自動テストが導入されてこなかったプロジェクトでは、導入方法検討などが大きく作業工数に影響する。

テスト自動化の対応状況

テスト自動化の対応状況ですが、ユニットテストと手動テストによって品質を担保していることが多いです。
ただし、受け入れは手動テストの結果で行っている為、自動テストが有効活用出来ていない状況です。

結合テスト自動化

現在の状況を鑑みて、まずは、今まで手を出していない結合テストの自動化を進めることとなりました。
効率的に拡充していく為の施策として、様々なパターンを自動化する前に、各APIのGET、POSTを1ケースずつ実装する方針で進めています。
簡単にAPIのGETとPOSTの結合テストを試してみましたので、それぞれの使用例をご紹介していきます。

開発環境

  • フレームワーク: Spring Boot v2.3.2
  • 言語: Kotlin v1.6.10
  • テストフレームワーク:
    • dbunit v2.7.0
    • JUnit v5.6.2
    • MockMVC v5.2.8

GETの結合テスト

使用例

GetUserListTest.kt

import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DatabaseSetups
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.nhaarman.mockito_kotlin.doReturn
import com.nhaarman.mockito_kotlin.mock
import jp.microad.universe.entity.UserMaster
import jp.microad.universe.security.AccountUserDetails
import org.junit.jupiter.api.Assertions
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.json.JacksonJsonParser
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.http.MediaType
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.request.RequestPostProcessor

// @SpringBootTest、@ContextConfiguration、@Import、@TestExecutionListeners、@Transactionalは省略
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(SpringExtension::class)
@DbUnitConfiguration(databaseConnection = ["TestDS"])
private class GetUserListTest @Autowired constructor(
    val mockMvc: MockMvc
) {
    fun getAuthority(auth: String) = mock<GrantedAuthority> {
        on { authority } doReturn auth
    }

    fun getMockUser(): RequestPostProcessor {
        val user: UserMaster = mock {
            on { userId } doReturn 1
            on { mailAddress } doReturn "testuser@example.com"
            on { userPassword } doReturn "p@ssword"
            on { userName } doReturn "testuser"
        }
        val authorities = listOf(
            getAuthority("ROLE_USER"),
            getAuthority("ROLE_SYSTEM")
        )
        val userDetails = mock<AccountUserDetails> {
            on { this.user } doReturn user
            on { this.authorities } doReturn authorities
        }
        return user(userDetails)
    }

    @Test
    @DisplayName("ユーザ一覧取得時正常ステータスが返却され、想定されたデータが返却されること")
    @DatabaseSetups(
        value = [
            DatabaseSetup(
                value = ["/dataset/IntegrationTest/user/getUserList/setup_db.xml"],
                connection = "TestDS"
            )
        ]
    )
    fun checkGetUserList() {
        val expected = """
            {
                "users": [
                    {
                        "userId": 1,
                        "userName": "hoge",
                        "userMailAddress": "hoge@example.com"
                    },
                    {
                        "userId": 2,
                        "userName": "fuga",
                        "userMailAddress": "fuga@example.com"
                    }
                ]
            }
        """.trimIndent()

        val actual = mockMvc
            .perform(
                MockMvcRequestBuilders
                    .get("/api/userList")
                    .with(getMockUser())
                    .with(SecurityMockMvcRequestPostProcessors.csrf())
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andReturn()
            .response

        actual.characterEncoding = "UTF-8"

        Assertions.assertEquals(200, actual.status)
        Assertions.assertEquals(
            JacksonJsonParser().parseMap(expected),
            JacksonJsonParser().parseMap(actual.contentAsString)
        )
    }
}

setup_db.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user_master
        user_id="1"
        user_name="hoge"
        user_mail_address="hoge@example.com"
        user_password="p@sswordHoge"
        user_status="active"
    />
    <user_master
        user_id="2"
        user_name="fuga"
        user_mail_address="fuga@example.com"
        user_password="p@sswordFuga"
        user_status="active"
    />
</dataset>

説明

DBからユーザの一覧を取得してJSON形式で返却するAPIの結合テスト例です。
ログインが必要なことを想定して、ユーザと権限をmock化しています。
HTTPレスポンスステータスが正常(200)及び、戻り値のJSONが正しいことを確認しています。

POSTの結合テスト

使用例

ChangeUserStatusTest.kt

import com.fasterxml.jackson.databind.ObjectMapper
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DatabaseSetups
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.github.springtestdbunit.annotation.ExpectedDatabase
import com.github.springtestdbunit.assertion.DatabaseAssertionMode
import com.nhaarman.mockito_kotlin.doReturn
import com.nhaarman.mockito_kotlin.mock
import jp.microad.universe.entity.UserMaster
import jp.microad.universe.security.AccountUserDetails
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.http.MediaType
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.request.RequestPostProcessor
import testproject.UserEditForm

// @SpringBootTest、@ContextConfiguration、@Import、@TestExecutionListeners、@Transactionalは省略
@AutoConfigureMockMvc
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
@ExtendWith(SpringExtension::class)
@DbUnitConfiguration(databaseConnection = ["TestDS"])
private class ChangeUserStatusTest @Autowired constructor(
    var mockMvc: MockMvc
)  {
    fun getAuthority(auth: String) = mock<GrantedAuthority> {
        on { authority } doReturn auth
    }

    fun getMockUser(): RequestPostProcessor {
        val user: UserMaster = mock {
            on { userId } doReturn 1
            on { mailAddress } doReturn "testuser@example.com"
            on { userPassword } doReturn "p@ssword"
            on { userName } doReturn "testuser"
        }
        val authorities = listOf(
            getAuthority("ROLE_USER"),
            getAuthority("ROLE_SYSTEM")
        )
        val userDetails = mock<AccountUserDetails> {
            on { this.user } doReturn user
            on { this.authorities } doReturn authorities
        }
        return user(userDetails)
    }

    @Test
    @DisplayName("ユーザのステータスがactiveからdeletedに変更されていること")
    @DatabaseSetups(
        value = [
            DatabaseSetup(
                value = ["/dataset/IntegrationTest/user/changeUserStatus/setup_db.xml"],
                connection = "TestDS"
            )
        ]
    )
    @ExpectedDatabase(
        value = "/dataset/IntegrationTest/user/changeUserStatus/expected_update_status.xml",
        assertionMode = DatabaseAssertionMode.NON_STRICT
    )
    fun checkChangeStatusFromActiveToDeleted() {
        // リクエストパラメータをjson化
        val form = UserEditForm.UserStatusEditForm("deleted")
        val mapper = ObjectMapper()
        val json = mapper.writeValueAsBytes(form)

        mockMvc.perform(
            MockMvcRequestBuilders
                .post("/api/userList/changeStatus?userId=1")
                .with(getMockUser())
                .with(SecurityMockMvcRequestPostProcessors.csrf())
                .content(json)
                .contentType(MediaType.APPLICATION_JSON)
        )
    }
}

UserEditForm.kt

// importは省略
sealed class UserEditForm {
    data class UserStatusEditForm(
        val userStatus: String
    ) : UserEditForm()
}

setup_db.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user_master
        user_id="1"
        user_name="hoge"
        user_mail_address="hoge@example.com"
        user_password="p@sswordHoge"
        user_status="active"
    />
</dataset>

expected_update_status.xml

<?xml version="1.0" encoding="UTF-8"?>
<dataset>
    <user_master
        user_id="1"
        user_name="hoge"
        user_mail_address="hoge@example.com"
        user_password="p@sswordHoge"
        user_status="deleted"
    />
</dataset>

説明

DBのuser_status項目を変更するAPIの結合テスト例です。
ログインが必要なことを想定して、ユーザと権限をmock化しています。
DBの変更結果が正しいことを確認しています。

注意事項

リクエストにcsrfトークンを設定しないと403エラーが発生します。

今後の展望

ユニットテストの拡充という課題はありますが、これからは結合テストを充実させていきたいです。
システムテストの自動化に向けてまずは一歩ずつ前進していきます。