Help us understand the problem. What is going on with this article?

Spring BootのインメモリでのAPIレイヤーテスト

More than 1 year has passed since last update.

概要

Spring BootのインメモリでのAPIレイヤーテストを試してみました

背景

自動のUI test、Integration testを実サーバでやる場合、テスト自体の実装はもちろんですが、テスト用のサーバー、関連するDBやES等の起動や初期データ投入も含めたテスト環境へのデプロイ、連携する外部APIのダミーAPI、テスト結果の通知等、多くのものが必要になります。

なるべくコストをかけずにAPIレイヤーテストを行なうため、spring-boot-starter-test等を試してみました。

使用技術

  • プロダクション側
    • spring-boot-starter-web:2.0.0
    • spring-boot-starter-data-jpa:2.0.0
  • テスト側
    • spring-boot-starter-test:2.0.0.RELEASE
    • DbSetup-kotlin:2.1.0
    • assertj-db:1.2.0
    • jmockit:1.38
    • assertj:3.9.1

Kotlinでの実装例

テスト対象のプロダクション側の想定は以下です。

  • POSTで受け取った国名と都市名の組み合わせを受け取り、その登録を行うAPI
  • 登録時、外部のAPIからその都市の人口を取得して一緒に登録する
プロダクジョン側
@ControllerAdvice
@RestController
class CityController: ResponseEntityExceptionHandler() {

    @Autowired
    lateinit var cityService:CityService

    @PostMapping("/city")
    fun createCity(@Valid @RequestBody requet: CityRequest):CityResponse {
        val created = cityService.create(CityDomain(requet.name, requet.country))
        return CityResponse(created.id)
    }

    data class CityRequest(val name:String="",val country:String="")
    data class CityResponse(val id:Int)
    data class CityError(val message:String,val detail:String?)
}

@Transactional
@Service
class CityService {

    @Autowired
    lateinit var cityDomainRepositoryRepository: CityDomainRepository

    fun create(city: CityDomain):CityDomain {
        cityDomainRepositoryRepository.find(city.name)?.let {
            throw ExistCityError()
        }
        return cityDomainRepositoryRepository.create(city)
    }

}

@Repository
class CityDomainRepository {
    @Autowired
    private lateinit var cityRepository: CityRepository
    @Autowired
    private lateinit var countryRepository: CountryRepository
    @Autowired
    lateinit var populationApi: PopulationApi

    fun create(city: CityDomain):CityDomain {
        val existCountry = countryRepository.findByName(city.country)
        val population = populationApi.get(city.name)
        if(existCountry == null){
            val saved = cityRepository.save(City(name = city.name, country = countryRepository.save(Country(name = city.country)),population = population.value))
            return city.copy(id=saved.id)
        } else {
            val saved = cityRepository.save(City(name = city.name, country = existCountry,population = population.value))
            return city.copy(id=saved.id)
        }
    }

    fun find(name: String): CityDomain? {
        val city = cityRepository.findByName(name)
        city?.let {
            return CityDomain(city.id, city.name, city.country.name)
        }
        return null
    }
}

テスト側の想定は以下です。

  • APIのDBへの書き込み結果は他のアプリからも使用されているため、確認はPOSTに対するGETではなくDBに行なう
テスト側
import com.ninja_squad.dbsetup_kotlin.DbSetupBuilder
import com.ninja_squad.dbsetup_kotlin.dbSetup
import org.assertj.db.api.Assertions
import org.assertj.db.api.Assertions.assertThat
import org.assertj.db.type.Changes
import org.assertj.db.type.Table
import org.junit.Test
import org.junit.runner.RunWith
import org.k3yake.repository.PopulationApi
import org.mockito.BDDMockito.given
import org.springframework.test.context.junit4.SpringRunner
import org.springframework.test.web.servlet.MockMvc
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import javax.sql.DataSource

@RunWith(SpringRunner::class)
@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase
class CityApiTestBySpringBootTest {

    @Autowired
    lateinit var mockServer: MockMvc
    @Autowired
    lateinit var dataSource: DataSource
    @MockBean
    lateinit var populationApi: PopulationApi

    @Test
    fun 未登録の国の都市の場合_人口情報を付与して都市と国を登録する() {
        //準備
        dbSetup(to = dataSource) {
            deleteAll()
        }.launch()
        given(populationApi.get("ebisu")).willReturn(PopulationApi.PopulationApiResponse("ebisu", 800000))

        //実行
        mockServer.perform(MockMvcRequestBuilders.post("/city")
                .content("""{"name":"ebisu", "country":"Japan"}""".toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(content().json("""{id=1}""".toString()))

        //確認
        assertThat(Table(dataSource, "country"))
                .hasNumberOfRows(1)
                .row(0)
                .value("name").isEqualTo("Japan")
        assertThat(Table(dataSource, "city"))
                .hasNumberOfRows(1)
                .row(0)
                .value("name").isEqualTo("ebisu")
                .value("population").isEqualTo(900000)
    }

    @Test
    fun 登録済みの国の都市の場合_都市のみを登録する() {
        //準備
        dbSetup(to = dataSource) {
            deleteAll()
            insertInto("country") {
                columns("id", "name")
                values(1, "Japan")
            }
        }.launch()
        given(populationApi.get("ebisu")).willReturn(PopulationApi.PopulationApiResponse("ebisu", 900000))

        //実行
        mockServer.perform(MockMvcRequestBuilders.post("/city")
                .content("""{"name":"ebisu", "country":"Japan"}""".toString())
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
                .andExpect(content().json("""{id=1}""".toString()))

        //確認
        assertThat(Table(dataSource, "country"))
                .hasNumberOfRows(1)
                .row(0)
                .value("name").isEqualTo("Japan")
        assertThat(Table(dataSource, "city"))
                .hasNumberOfRows(1)
                .row(0)
                .value("name").isEqualTo("ebisu")
                .value("population").isEqualTo(900000)
    }
}

fun DbSetupBuilder.deleteAll() {
    val tables = listOf("city", "country")
    deleteAllFrom(tables)
    tables.forEach {
        sql("ALTER TABLE ${it} ALTER COLUMN id RESTART WITH 1")
    }
}

テストで行っていること

  • DBはデフォルトでインメモリDBが使用されます。
  • 準備のdbSetup(to = dataSource) {のブロックで、他のテストの実行結果の影響を受けないようにDBの初期化と必要な初期データを投入しています。
  • 準備のgiven(populationApi・・・で、@MockBeanでモック化した外部API呼び出し部分の振る舞いを設定しています。
  • 実行のperform(・・・からcontentType(・・・でテスト対象のAPIの呼び出しを行っています。
  • 実行のandExpect(・・・でAPIのレスポンスの期待値を設定しています。
  • 確認のassertThat(・・・でDBの確認を行っています。このassertThatはorg.assertj.db.api.Assertions.assertThatです。

プロジェクトに導入した感想

  • ポジティブ

    • 全てメモリ上で完結するので実サーバーを使用するより圧倒的に手軽
    • 手軽なので、チームメンバーも受け入れやすくプロジェクトへのインパクトも少ないので導入しやすい
    • GradleやMavenのビルドで実行されるので、ちゃんとしたデプロイメントパイプラインが作れてなくてもテストが落ちればデプロイ出来ない仕組みを作りやすく、テストをクリーンに保ちやすい
    • デバッグしやすい。障害等でサクッと動きを確認したい場合にも使いやすい
    • 実サーバーで外部APIのダミーAPIを作成すると、ダミーAPIの振る舞いのバリエーションを作るのが面倒だったり、かなり工夫しないとテストクラスと離れた場所にダミーの振るまいを記述することなるが、一箇所にまとまっているのでテストケースの見通しが良い
  • ネガティブ

    • 手軽な分、単体テストでやったほうが良いテストもAPIテストで書きがちになるのでテストピラミッドはより強く意識する必要があると思う
    • フレームワーク依存なので、実サーバを使用したAPIテストのように、実装技術を変えた場合に使い回しは効かない
k-3yake
forcas
『FORCAS(フォーカス)』は、データ分析に基づいて成約確度の高いアカウントを予測し、マーケティングと営業のリソースをそのターゲットアカウントに集中する最新マーケティング手法「Account Based Marketing (ABM) 」の実践を強力にサポートするマーケティングプラットフォームを開発・提供してます。
https://www.forcas.com/overview/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした