SpringBootのアプリケーションを1つ作りました。そのときにSpringBootでのテストについて触ったので書きます
何を作った?
PDFを切り取るウェブアプリを作成しました。こちらから実際に触ることができます(もしかしたらノードの要領なくて死んでるかも. タイミング次第ですね)
もともと自分のiPhoneSEで電子書籍を見ると画面が小さく文字が小さかったので、できるだけ文字を大きくするために余白を切り取るwebアプリを作りました。仕組みとしては、ReactとSpringBootを使ったシンプルなSSRです。(React使ってるのにSSRかよとか言わないでください、react-routerとか触るの面倒だったので)
コードはこちらに置いてます
テストについて
基本的なテストの書き方
Kotlinでのテストの書き方は、クラス内の関数に@Test
をつけて関数内にテストコードを書くだけです。
IntelliJであれば左側の再生ボタンから手軽に実行できます
Spring Boot でのテストの書き方
SpringBootを利用していると、DIの仕組みを自然と利用していると思います。DIを利用してテスタブルなコードを書いていても、@Test
を利用するだけではDIが有効になりません。例えばControllerからService, ServiceからRepositoryの呼び出しに@Service, @Repository, @Componentsを利用していると解決できずに失敗します。
なのでDIの仕組みを使いつつテストを実行するには、クラスに@SpringBootTest
をつける必要があります。
例えば、Serviceを呼び出しているControllerのPOSTAPIをテストする場合、以下のように書くことができます
@SpringBootTest
class TrimmingerApplicationTests(
@Autowired val controller: HomeController) {
@Test
fun contextLoads() {
assertThat(controller).isNotNull()
File("src/test/out.pdf").createNewFile()
val pdf = File("src/test/test.pdf")
val file = MockMultipartFile(
"uploaded_pdf",
"test.pdf",
MediaType.APPLICATION_PDF_VALUE,
pdf.readBytes())
val res: ResponseEntity<StreamingResponseBody> = controller.cropPdf(file, 0, 40, 60, 100)
assert(res.statusCode.is2xxSuccessful)
assert(res.headers.contains("Content-Type"))
assertThat(res.headers["Content-Type"]).isEqualTo(listOf("application/pdf"))
}
}
MockMvcについて
この方法を利用すると、テスト実行時に毎回SpringBootが起動してしまいます。これは時間がかかったり目的ではないコードのバグで失敗する可能性があります。
そのような場合はMockMvc
を利用することで、SpringBootの起動をせずにテストを実行することができます。
MockMvc
を利用する方法はいくつかあるのですが、一番簡単に利用するには @AutoConfigureMockMvc
と@Autowired
を使った方法かと思われます。
@AutoConfigureMockMvc
は自動でMockMvc
をBeanとしてコンテキストに登録します。またそのBeanを @Autowired
を使ったコンストラクタインジェクションで利用しています。実際に利用しているコードは以下の通りです、
@SpringBootTest
@AutoConfigureMockMvc
class TrimmingerApplicationTests(
@Autowired val controller: HomeController,
@Autowired val mockMvc: MockMvc) {
@Test
fun checkResponse() {
val pdf = File("src/test/test.pdf")
val file = MockMultipartFile(
"upload_file",
"test_contract.pdf",
MediaType.APPLICATION_PDF_VALUE,
pdf.readBytes())
val params = LinkedMultiValueMap<String, String>()
params.add("padding_top", "10")
params.add("padding_right", "20")
params.add("padding_bottom", "30")
params.add("padding_left", "40")
MockMvcRequestBuilders.multipart("/api/trim")
.file(file)
.params(params)
mockMvc.perform(
MockMvcRequestBuilders.multipart("/api/trim").file(file).params(params)
).andDo(
print()
).andExpect(status().isOk).andExpect(content().contentType(MediaType.APPLICATION_PDF))
}
}
SpringBootが都度起動しないので先ほどの方法よりも高速にテストを行うことができています
モックの実装
先ほど、ControllerからDIを経由してServiceを呼び出していると書きましたが、場合によっては、 Serviceクラスが未完成だったりServiceクラスで意図的に例外処理を発生させたい場合があります。
DIを利用してServiceの処理を挿げ替えることができます。
例えば、PDFの切り取りはPdfService.crop
で行っていますが、テストコードで @Service
と@Primary
の組み合わせでPdfService.crop
の処理を挿げ替えることができます
// in TrimmingerApplicationTests.kt
@Service
@Primary
class MockPdfService : PdfService {
override fun crop(file: InputStream, paddingLeft: Int, paddingTop: Int, paddingRight: Int, paddingBottom: Int): PDDocument {
return PDDocument.load(File("src/test/dummy.pdf"))
}
}
@Service
をつけることでBeanに登録し、なおかつPdfService
インタフェースのresolveが失敗しないように @Primary
をつけることで優先度を上げています
この方法では、実際のSpringBoot起動時もこのファイルがスキャンされてMockPdfService
がBean登録されてしまうのではないかと思っていたのですが、どうやらスキャンされるのは、@Configuration
が記述されているパッケージ以下なのでMockPdfService
はスキャンされないようです
Also, note that the main application class and the configuration class are not necessarily the same. If they are different, it doesn’t matter where to put the main application class. Only the location of the configuration class matters as component scanning starts from its package by default.
Mockito
他にも、Mockito
を利用することで処理をすげ替えることができます。これはDIを利用していないクラスの処理もすげ替えが可能でとても便利そうです
Mockito
はJavaでモックを書くためのライブラリです。spring initializerを利用したプロジェクトでは自動で含まれているようです(僕の環境でも特に設定せずに利用できました)
Mockitの場合、対象のプロパティに@Mock
またはBean登録されている場合は@MockBean
をつけると、該当のクラスがモックとして設定されます。
設定されたクラスは、テストコード中でwhen().thenReturn()
などの処理を書くことで自由に振る舞いを変更することができます。
上述のモック処理と同様のことをする場合、以下のように記述することで可能です
import org.mockito.Mockito.`when`
// ...
`when`(service!!.crop(
MockitoHelper.anyObject(), // ※1
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt())
).thenReturn(PDDocument.load(File("src/test/dummy.pdf")))
引数に指定した値が実際に与えられた場合、thenReturn
に指定した値が返却されます。ArgumentMatchers.anyXxxx()
を利用するとどんな値でも反応して値が返却されます。なので上記コードはservice.crop()
を呼び出すと必ずPDDocument.load(File("src/test/dummy.pdf"))
が返却されます
実際にテストコードに組み込むと以下のような形になります
@SpringBootTest
@AutoConfigureMockMvc
class TrimmingerApplicationTests(
@Autowired val controller: HomeController,
@Autowired val mockMvc: MockMvc) {
@MockBean
private val service: PdfService? = null
@Test
fun contextLoads() {
`when`(service!!.crop(
MockitoHelper.anyObject(), // ※1
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt())
).thenReturn(PDDocument.load(File("src/test/dummy.pdf")))
assertThat(controller).isNotNull()
File("src/test/out.pdf").createNewFile()
val pdf = File("src/test/test.pdf")
val file = MockMultipartFile(
"file",
"test_contract.pdf",
MediaType.APPLICATION_PDF_VALUE,
pdf.readBytes())
val res: ResponseEntity<StreamingResponseBody> = controller.cropPdf(file, 0, 40, 60, 100)
assert(res.statusCode.is2xxSuccessful)
assert(res.headers.contains("Content-Type"))
assertThat(res.headers["Content-Type"]).isEqualTo(listOf("application/pdf"))
}
}
Mockito
の一点不便な点を挙げると、when
によるモックの条件に引っかからなかった場合 null
を返す点です。例えば同じクラスの中で関数Aと関数Bでモックする場合は両方の関数内でwhen()
を書く必要があり少し冗長に感じました(だからといってモックではなくもともとの処理が走ると困りますが…)(もしかして@BeforeAll
に記述すれば解決?)
また、Kotlinを利用している場合に限り、Mockito
ではnotnullな引数をモックしようとしてArgumentMatchers.any()
を利用すると、 ArgumentMatchers.any(xxxxxxxxxxx::class.java) must not be null
と必ずエラーがでるので、以下のようにゴニョゴニョする必要がありました。
KotlinからJavaに変換する上で、引数のnull許可の情報がなくなってしまうから見たいです
/**
* link: https://stackoverflow.com/questions/59230041/argumentmatchers-any-must-not-be-null
*/
private fun <T> any(type: Class<T>): T = Mockito.any<T>(type)
object MockitoHelper {
fun <T> anyObject(): T {
Mockito.any<T>()
return uninitialized()
}
@Suppress("UNCHECKED_CAST")
fun <T> uninitialized(): T = null as T
}
`when`(service.crop(
- ArgumentMatchers.any(),
+ MockitoHelper.anyObject(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt(),
ArgumentMatchers.anyInt())
).thenReturn(PDDocument.load(File("src/test/dummy.pdf")))
https://stackoverflow.com/questions/59230041/argumentmatchers-any-must-not-be-null
所感・その他学んだこと
- Reactのコードがサボりすぎてやばい
- 個人でコード書くときはリファクタの圧力を常に感じるけど、それ以上に作るのが楽しい、だけどこの感覚は絶対に仕事に持ち入れてはいけない
- ReactHooksでのコンポーネントの粒度決め・設計をちゃんと勉強したい
- useStateをどうやって使い回す????
- やっぱりSpringBoot好き
- 典型的なDIがアノテーションの利用だけで済むので
- 型がある喜び
- ドキュメント見なくても補完だけでガリガリかける
- ドキュメントも豊富なので
- baeldung.comが網羅性高い
- 総合的に見て十分プロダクションで利用できる
- Mockitoを4年ぶりぐらいに使った。某SIerで書いてた記憶が蘇って懐かしい
Mockito
の利用を提案したけど当時のアーキテクトはMockito
を知らなかってモヤッとした記憶
- SpringBootで静的ファイルを配信する場合は
/static
,/public
,/resources
,/META-INF/resources
に配置するだけで良いらしい-
- ただし、 ルート
/
にアクセスされた場合にエラーになるのでforwardの処理は必須そう
-
@RequestMapping("/")
protected fun redirect(): String? {
return "forward:/index.html"
}