前回実装したClean Architecture APIのUnitテストを書いていきます。
目次
JUnit
JUnitとはJavaで開発されたプログラムにおいてユニットテストの自動化を行うためのフレームワークです。
よく使いそうなアノテーション
@Test
メソッドに付与することでJUnitにテストメソッドであることを認識します。
@DisplayName
テストの表示名を設定できます。
@BeforeEach
付与されたメソッドは、各テストメソッド実行前に@BeforeEachアノテーションがついたメソッドが実行されます。JUnit 4の@Beforeと同じです。
@Disabled
@Disabled はクラスまたはメソッドに付与してテストを実行させないようにすることができます。JUnit4における@Ignoreに相当します
JUnit5のアノテーション一覧は以下の公式で見ることができます。
MockK
MockKとは Kotlin用のモックライブラリです。
every
everyを使うと特定のモックインスタンスが呼ばれた時に指定した値を返却します。
verify
verifyを使うとメソッドが指定した引数で呼び出されたかを確認できます。
よく使いそうなアノテーション
@MockK
モックインスタンスとしてインジェクションしたい場合に使用します。
@SpyK
Spy用のインスタンスとしてインジェクションしたい場合に使用します。
Spyはオブジェクトを実際のコードで動かしつつ、メソッドの引数や呼び出し回数、戻り値などを検証するために使用します。
注意点としては、アノテーションを利用して初期化する際には@Spyk var hoge = Hoge()のようにvarかつインスタンス生成する必要があります。
@InjectMockKs
該当オブジェクトのもつ属性に対してインジェクトしたい場合に使用します。
ControllerのUnitテスト
@ExtendWith(SpringExtension::class)でSpring拡張機能をインクルードしていましたが、調べたら@SpringBootTestアノテーションを使用している場合は@ExtendWith(SpringExtension::class)を付与する必要はなくなったようです。
参考:https://github.com/spring-projects/spring-boot/issues/13739
・TaskControllerTest.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 | package com.example.controller import com.example.domain.* import com.example.usecase.TaskUseCase import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import org.springframework.test.web.servlet.setup.MockMvcBuilders @SpringBootTest class TaskControllerTest { @InjectMockKs private lateinit var taskController: TaskController @MockK private lateinit var taskUseCase: TaskUseCase private lateinit var mockTaskController: MockMvc @BeforeEach fun setup() { mockTaskController = MockMvcBuilders.standaloneSetup(taskController).build() } @Test @DisplayName("TasksをTasksJsonに変換する") fun convertTasksJsonTest() { val target = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) val expected = TasksJson(listOf(TaskJson("title", "content"), TaskJson("title2", "content2"))) assertEquals(expected, target.toJson()) } @Test @DisplayName("TaskをTaskJsonに変換する") fun convertTaskJsonTest() { val target = Task(Title("title"), Content("content")) val expected = TaskJson("title", "content") assertEquals(expected, target.toJson()) } @Test @DisplayName("task一覧を取得する") fun getTasksTest() { val tasks = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) every { taskUseCase.getTasks() } returns tasks mockTaskController.get("/api/tasks").andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) json("""{"tasks":[{"title":"title","content":"content"},{"title":"title2","content":"content2"}]}""") } } verify { taskUseCase.getTasks() } } @Test @DisplayName("taskを1つ取得する") fun getTaskTest() { val taskId = TaskId(1) val task = Task(Title("title"), Content("content")) every { taskUseCase.getTask(taskId) } returns task mockTaskController.get("/api/tasks/1").andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) json("""{"title":"title","content":"content"}""") } } verify { taskUseCase.getTask(taskId) } } } |
HTTPメソッドへのテスト
MockMvcBuilders.standaloneSetup()でControllerの動作を再現するための準備をします。
参考:https://terasolunaorg.github.io/guideline/5.4.1.RELEASE/ja/UnitTest/ImplementsOfUnitTest/UsageOfLibraryForTest.html#mockmvc
@BeforeEachアノテーションを付けて、全ての@Testの前に実行させます。以降、このmockMvcインスタンスを利用して、仮想のリクエストを発生させテストを実行します。
今回はテスト対象のTaskControllerを指定して、mockMvcを生成しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 | @InjectMockKs private lateinit var taskController: TaskController @MockK private lateinit var taskUseCase: TaskUseCase … @BeforeEach fun setup() { mockTaskController = MockMvcBuilders.standaloneSetup(taskController).build() } |
テストしたいPath(今回の場合はGET /api/tasks)を指定し、andExceptメソッドでレスポンスのテストを行います。
今回はHTTPステータスコードのテストなのでstatus()を使います。ステータスコード200はstatus().isOkでテストできます。代表的なステータスコードは以下の通りです。
- 200: status().isOk()
- 308: status().isPermanentRedirect()
- 404: status().isNotFound()
- 403: status().isForbidden()
- 503: status().isServiceUnavailable()
あとはcontent()でレスポンスの形と中身を見ています。今回はREST APIなのでJSON形式であることJSONの中身が正しいことをアサートしています。
また、Controllerないのメソッドにおいて、該当のuseCaseメソッドが呼ばれたこともテストしています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | fun getTasksTest() { val tasks = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) every { taskUseCase.getTasks() } returns tasks mockTaskController.get("/api/tasks").andExpect { status { isOk() } content { contentType(MediaType.APPLICATION_JSON) json("""{"tasks":[{"title":"title","content":"content"},{"title":"title2","content":"content2"}]}""") } } verify { taskUseCase.getTasks() } } |
ドメインからJSONへの変換テスト
ControllerではUseCaseから返ってきたドメインをレスポンスの形であるJSONに変換しているため、そのテストも行っています。
今回は各ドメインにtoJson()というメソッドを生やしているので、そのメソッドを実行してちゃんとドメインからJSONに変換されているかをアサートしています。
assertEquals()で値が等しいかを確認しています。左が期待値で右が実測値を入れます。
1 2 3 4 5 6 | fun convertTasksJsonTest() { val target = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) val expected = TasksJson(listOf(TaskJson("title", "content"), TaskJson("title2", "content2"))) assertEquals(expected, target.toJson()) } |
UseCaseのUnitテスト
UseCaseのテストではPortのメソッドを呼び出して、その返り値が正しいかをアサートしています。今回は簡素なAPIなので特にUseCaseでロジック挟んでおらず、シンプルなUnitテストになっています。
・TaskUseCaseTest.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | package com.example.usecase import com.example.domain.Task import com.example.domain.TaskId import com.example.domain.Tasks import com.example.port.TaskPort import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.assertEquals import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class TaskUseCaseTest { @InjectMockKs private lateinit var target: TaskUseCase @MockK lateinit var taskPort: TaskPort @Test @DisplayName("task一覧を取得する") fun getTasksTest() { val expected = mockk<Tasks>() every { taskPort.getTasks() } returns expected assertEquals(expected, target.getTasks()) verify { taskPort.getTasks() } } @Test @DisplayName("taskを1つ取得する") fun getTaskTest() { val taskId = mockk<TaskId>() val expected = mockk<Task>() every { taskPort.getTask(taskId) } returns expected assertEquals(expected, target.getTask(taskId)) verify { taskPort.getTask(taskId) } } } |
GatewayのUnitテスト
GatewayではDriverメソッドを呼び出し、その返り値をアサートしています。またGatewayではDriverで受け取ったプリミティブなJSONオブジェクトをドメインに変換する処理をしているため、変換メソッドのテストも追加しています。
・TaskGatewayTest.kt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | package com.example.gateway import com.example.domain.* import com.example.driver.TaskDriver import com.example.driver.TaskJson import com.example.driver.TasksJson import io.mockk.every import io.mockk.impl.annotations.InjectMockKs import io.mockk.impl.annotations.MockK import io.mockk.verify import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.DisplayName import org.junit.jupiter.api.Test import org.springframework.boot.test.context.SpringBootTest @SpringBootTest class TaskGatewayTest { @InjectMockKs private lateinit var target: TaskGateway @MockK lateinit var taskDriver: TaskDriver @Test @DisplayName("task一覧を取得する") fun getTasksTest() { val tasksJson = TasksJson(listOf(TaskJson("title", "content"), TaskJson("title2", "content2"))) val expected = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) every { taskDriver.getTasks() } returns tasksJson assertEquals(expected, target.getTasks()) verify { taskDriver.getTasks() } } @Test @DisplayName("TasksJsonをTasksに変換する") fun convertToTasks() { val target = TasksJson(listOf(TaskJson("title", "content"), TaskJson("title2", "content2"))) val expected = Tasks(listOf(Task(Title("title"), Content("content")), Task(Title("title2"), Content("content2")))) assertEquals(expected, target.toTasks()) } @Test @DisplayName("taskを1つ取得する") fun getTaskTest() { val taskJson = TaskJson("title", "content") val taskId = TaskId(1) val expected = Task(Title("title"), Content("content")) every { taskDriver.getTask(taskId.value) } returns taskJson assertEquals(expected, target.getTask(taskId)) verify { taskDriver.getTask(taskId.value) } } @Test @DisplayName("TaskJsonをTaskに変換する") fun convertToTask() { val target = TaskJson("title", "content") val expected = Task(Title("title"), Content("content")) assertEquals(expected, target.toTask()) } } |
MockKでlateinitプロパティが初期化されない問題
springのMockKだとうまくいくのにMockKだと以下のエラーが出る問題がありました。
1 | kotlin.UninitializedPropertyAccessException: lateinit property hoge has not been initialized |
調べてみるとinitメソッドで解決できそうでしたので、実際やってみたらうまくいきました。
参考:https://github.com/mockk/mockk/issues/546
以下のように@BeforeEachを追加するだけです。テスト実行前にMockKAnnotations.initでモックインスタンスをインジェクションすることで解決できます。
1 2 3 4 | @BeforeEach fun init() { MockKAnnotations.init(this) } |