概要
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テストのように、実装技術を変えた場合に使い回しは効かない