SpringBootでのテストについて触った結果をまとめてみた

SpringBootのアプリケーションを1つ作りました。そのときにSpringBootでのテストについて触ったので書きます

何を作った?

PDFを切り取るウェブアプリを作成しました。こちらから実際に触ることができます(もしかしたらノードの要領なくて死んでるかも. タイミング次第ですね)

もともと自分のiPhoneSEで電子書籍を見ると画面が小さく文字が小さかったので、できるだけ文字を大きくするために余白を切り取るwebアプリを作りました。仕組みとしては、ReactとSpringBootを使ったシンプルなSSRです。(React使ってるのにSSRかよとか言わないでください、react-routerとか触るの面倒だったので)

コードはこちらに置いてます

https://gitlab.com/morifuji/pdf-trimminger

テストについて

基本的なテストの書き方

Kotlinでのテストの書き方は、クラス内の関数に@Testをつけて関数内にテストコードを書くだけです。

/img/2020-07-23/01.png

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.

https://www.baeldung.com/spring-component-scanning

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がアノテーションの利用だけで済むので
    • 型がある喜び
      • ドキュメント見なくても補完だけでガリガリかける
    • ドキュメントも豊富なので
    • 総合的に見て十分プロダクションで利用できる
  • Mockitoを4年ぶりぐらいに使った。某SIerで書いてた記憶が蘇って懐かしい
    • Mockitoの利用を提案したけど当時のアーキテクトはMockitoを知らなかってモヤッとした記憶
  • SpringBootで静的ファイルを配信する場合は /static, /public, /resources, /META-INF/resourcesに配置するだけで良いらしい
@RequestMapping("/")
    protected fun redirect(): String? {
        return "forward:/index.html"
    }

See also