Kotlin JUNIT5作成

簡易的な機能を持つAPIについて、メモがてらJUNITを書いてみた

## 前提条件
出版社における著者情報を「閲覧・登録・更新」するAPIを考える。


## Controllerクラス
```kotlin:
package com.example.book_manager.controller

import com.example.book_manager.dto.AuthorRegisterDto
import com.example.book_manager.dto.AuthorResponseDto
import com.example.book_manager.dto.AuthorUpdateDto
import com.example.book_manager.service.AuthorService
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.validation.BindingResult
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/authors")
class AuthorController(private val authorService: AuthorService) {

    /**
     * 著者情報を登録する
     *
     * @param dto 登録情報
     * @return 登録時のレスポンス情報
     */
    @PostMapping
    fun createAuthor(@RequestBody @Valid dto: AuthorRegisterDto, bindingResult: BindingResult): ResponseEntity<String> {
        if (bindingResult.hasErrors()) {
            val errors = bindingResult.allErrors.joinToString(", ") { it.defaultMessage ?: "Invalid value" }
            return ResponseEntity(errors, HttpStatus.BAD_REQUEST)
        }
        authorService.createAuthor(dto)
        return ResponseEntity<String>("Register Completed", HttpStatus.CREATED)
    }

    /**
     * 指定の著者情報を更新する
     *
     * @param dto 更新情報
     * @return 更新時のレスポンス情報
     */
    @PutMapping("/{id}")
    fun updateAuthor(
        @PathVariable id: Int,
        @RequestBody @Valid dto: AuthorUpdateDto,
        bindingResult: BindingResult
    ): ResponseEntity<String> {
        if (bindingResult.hasErrors()) {
            val errors = bindingResult.allErrors.joinToString(", ") { it.defaultMessage ?: "Invalid value" }
            return ResponseEntity(errors, HttpStatus.BAD_REQUEST)
        }
        authorService.updateAuthor(id, dto)
        return ResponseEntity<String>("Update Completed", HttpStatus.OK)
    }

    /**
     * 指定の著者の詳細情報を取得する
     *
     * @param id 著者ID
     * @return 著者の詳細情報
     */
    @GetMapping("/{id}")
    fun getAuthor(@PathVariable id: Int): ResponseEntity<AuthorResponseDto> {
        return ResponseEntity<AuthorResponseDto>(
            authorService.getAuthorById(id),
            HttpStatus.OK
        )
    }
}
```

## ControllerTest
```kotlin:
package com.example.book_manager.controller

import com.example.book_manager.dto.AuthorRegisterDto
import com.example.book_manager.dto.AuthorResponseDto
import com.example.book_manager.dto.AuthorUpdateDto
import com.example.book_manager.service.AuthorService
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.mockito.Mockito.doNothing
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
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.request.MockMvcRequestBuilders.*
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import java.time.LocalDate
import java.time.format.DateTimeFormatter

@SpringBootTest
class AuthorControllerTest {
    // コントローラクラスの動作検証を可能にする
    private lateinit var mockMvc: MockMvc

    // Serviceクラスのモック化
    private val authorService = mock<AuthorService>()

    // テスト対象のControllerクラス
    private val controller = AuthorController(
        authorService
    )

    private val objectMapper = jacksonObjectMapper().registerModule(JavaTimeModule())

    @BeforeEach
    fun setUp() {
        // MockMVCのインスタンス生成
        mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
    }

    @Nested
    @DisplayName("著者情報登録処理API")
    inner class Test01 {

        @Nested
        @DisplayName("正常系")
        inner class Test011 {
            @Test
            @DisplayName("正常に登録できること")
            fun test01() {

                val authorRegisterDto = AuthorRegisterDto(
                    name = "テスト太郎",
                    birthDate = LocalDate.parse("1990-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorRegisterDtoJson = objectMapper.writeValueAsString(authorRegisterDto)

                doNothing().`when`(authorService).createAuthor(authorRegisterDto)

                mockMvc.perform(
                    post("/authors")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorRegisterDtoJson)
                )
                    .andExpect(status().isCreated)
                    .andExpect(content().string("Register Completed"))
            }
        }

        @Nested
        @DisplayName("異常系")
        inner class Test012 {
            @Test
            @DisplayName("著者名が空文字の場合")
            fun test02() {

                val authorRegisterDto = AuthorRegisterDto(
                    name = "",
                    birthDate = LocalDate.parse("1990-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorRegisterDtoJson = objectMapper.writeValueAsString(authorRegisterDto)

                mockMvc.perform(
                    post("/authors")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorRegisterDtoJson)
                )
                    .andExpect(status().isBadRequest)
                    .andExpect(content().string("Name must not be blank"))
            }

            @Test
            @DisplayName("生年月日に未来日を指定した場合")
            fun test03() {

                val authorRegisterDto = AuthorRegisterDto(
                    name = "田中太郎",
                    birthDate = LocalDate.parse("2030-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorRegisterDtoJson = objectMapper.writeValueAsString(authorRegisterDto)

                mockMvc.perform(
                    post("/authors")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorRegisterDtoJson)
                )
                    .andExpect(status().isBadRequest)
                    .andExpect(content().string("Birth date must be in the past"))
            }
        }
    }

    @Nested
    @DisplayName("著者情報更新API")
    inner class Test02 {

        @Nested
        @DisplayName("正常系")
        inner class Test011 {
            @Test
            @DisplayName("正常に更新できること")
            fun test01() {

                val authorUpdateDto = AuthorUpdateDto(
                    name = "テスト太郎",
                    birthDate = LocalDate.parse("1990-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorUpdateDtoJson = objectMapper.writeValueAsString(authorUpdateDto)

                doNothing().`when`(authorService).updateAuthor(1, authorUpdateDto)

                mockMvc.perform(
                    put("/authors/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorUpdateDtoJson)
                )
                    .andExpect(status().isOk)
                    .andExpect(content().string("Update Completed"))
            }
        }

        @Nested
        @DisplayName("異常系")
        inner class Test012 {
            @Test
            @DisplayName("著者名が空文字の場合")
            fun test02() {

                val authorUpdateDto = AuthorUpdateDto(
                    name = "",
                    birthDate = LocalDate.parse("1990-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorUpdateDtoJson = objectMapper.writeValueAsString(authorUpdateDto)

                doNothing().`when`(authorService).updateAuthor(1, authorUpdateDto)

                mockMvc.perform(
                    put("/authors/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorUpdateDtoJson)
                )
                    .andExpect(status().isBadRequest)
                    .andExpect(content().string("Name must not be blank"))
            }

            @Test
            @DisplayName("生年月日に未来日を指定した場合")
            fun test03() {

                val authorUpdateDto = AuthorUpdateDto(
                    name = "テスト太郎",
                    birthDate = LocalDate.parse("2030-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                val authorUpdateDtoJson = objectMapper.writeValueAsString(authorUpdateDto)

                doNothing().`when`(authorService).updateAuthor(1, authorUpdateDto)

                mockMvc.perform(
                    put("/authors/1")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(authorUpdateDtoJson)
                )
                    .andExpect(status().isBadRequest)
                    .andExpect(content().string("Birth date must be in the past"))
            }
        }
    }

    @Nested
    @DisplayName("著者詳細情報取得API")
    inner class Test03 {

        @Nested
        @DisplayName("正常系")
        inner class Test011 {
            @Test
            @DisplayName("正常に取得できること")
            fun test01() {

                // 期待値
                val expected = AuthorResponseDto(
                    id = 1,
                    name = "テスト太郎",
                    birthDate = LocalDate.parse("1990-01-01", DateTimeFormatter.ISO_LOCAL_DATE)
                )

                // サービスクラスの返却値設定
                doReturn(expected).`when`(authorService).getAuthorById(1)

                mockMvc.perform(
                    get("/authors/1")
                )
                    .andExpect(status().isOk)
                    .andExpect(jsonPath("$.id").value(expected.id))
                    .andExpect(jsonPath("$.name").value(expected.name))
                    .andExpect(jsonPath("$.birthDate").value(expected.birthDate.toString()))
            }
        }
    }
}
```

## Serviceクラス
```kotlin:
package com.example.book_manager.service

import com.example.book_manager.dto.AuthorRegisterDto
import com.example.book_manager.dto.AuthorResponseDto
import com.example.book_manager.dto.AuthorUpdateDto
import com.example.book_manager.repository.AuthorRepository
import com.example.generated.tables.records.AuthorRecord
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
class AuthorService(private val authorRepository: AuthorRepository) {

    /**
     * 書籍情報を登録する
     *
     * @param dto 登録情報
     * @return 登録時のレスポンス情報
     */
    @Transactional
    fun createAuthor(dto: AuthorRegisterDto) {
        // 著者テーブルへの登録情報を設定する
        val authorRecord = AuthorRecord().apply {
            name = dto.name
            birthDate = dto.birthDate
        }

        // 著者テーブルへの登録処理
        authorRepository.insertAuthor(authorRecord)
    }

    /**
     * 指定の著者情報を更新する
     *
     * @param id 著者ID
     * @param dto 更新情報
     */
    @Transactional
    fun updateAuthor(id: Int, dto: AuthorUpdateDto) {
        // 著者IDの存在チェック
        val authorRecord = authorRepository.findAuthorByAuthorId(id) ?: throw NoSuchElementException("Author not found")
        
        dto.birthDate?.let { authorRepository.updateAuthor(dto.name, it, authorRecord.id) }
    }

    /**
     * 指定の著者の詳細情報を取得する
     *
     * @param id 著者ID
     * @return 著者の詳細情報
     */
    @Transactional(readOnly = true)
    fun getAuthorById(id: Int): AuthorResponseDto {
        // パスパラメータ.著者IDをもとに詳細情報を取得する（存在チェックも兼ねる）
        val authorRecord = authorRepository.findById(id) ?: throw NoSuchElementException("Author not found")

        // レスポンス返却
        return AuthorResponseDto(
            id = authorRecord.id,
            name = authorRecord.name,
            birthDate = authorRecord.birthDate
        )
    }
}
```

## ServiceTestクラス
```kotlin:
package com.example.book_manager.service

import com.example.book_manager.dto.AuthorRegisterDto
import com.example.book_manager.dto.AuthorResponseDto
import com.example.book_manager.dto.AuthorUpdateDto
import com.example.book_manager.repository.AuthorRepository
import com.example.generated.tables.records.AuthorRecord
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Test
import org.mockito.Mockito.*
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
import java.time.LocalDate

class AuthorServiceTest {

    private lateinit var authorRepository: AuthorRepository
    private lateinit var authorService: AuthorService

    @BeforeEach
    fun setUp() {
        authorRepository = mock()
        authorService = AuthorService(authorRepository)
    }

    @Nested
    @DisplayName("createAuthor")
    inner class CreateAuthorTest {

        @Test
        @DisplayName("authorRepositoryのinsertAuthorが1回呼ばれること")
        fun test01() {
            val dto = AuthorRegisterDto(
                name = "テスト太郎",
                birthDate = LocalDate.parse("1990-01-01")
            )

            doNothing().`when`(authorRepository).insertAuthor(any())

            authorService.createAuthor(dto)

            verify(authorRepository).insertAuthor(any())
        }
    }

    @Nested
    @DisplayName("updateAuthor")
    inner class UpdateAuthorTest {

        @Test
        @DisplayName("正常に著者情報を更新できる")
        fun test01() {
            val id = 1
            val oldRecord = AuthorRecord().apply {
                this.id = id
                this.name = "旧テスト太郎"
                this.birthDate = LocalDate.parse("1980-01-01")
            }
            val dto = AuthorUpdateDto(
                name = "新テスト太郎",
                birthDate = LocalDate.parse("1990-01-01")
            )
            `when`(authorRepository.findAuthorByAuthorId(id)).thenReturn(oldRecord)
            doNothing().`when`(authorRepository).updateAuthor(dto.name, dto.birthDate, oldRecord.id)

            authorService.updateAuthor(id, dto)

            verify(authorRepository).updateAuthor(dto.name, dto.birthDate, oldRecord.id)
        }

        @Test
        @DisplayName("著者IDが存在しない場合は例外を送出する")
        fun test02() {
            val id = 99
            val dto = AuthorUpdateDto(
                name = "だれか",
                birthDate = LocalDate.parse("2000-01-01")
            )
            `when`(authorRepository.findAuthorByAuthorId(id)).thenReturn(null)

            val exception = assertThrows(NoSuchElementException::class.java) {
                authorService.updateAuthor(id, dto)
            }
            assertEquals("Author not found", exception.message)
            verify(authorRepository, never()).updateAuthor(dto.name, dto.birthDate, id)
        }
    }

    @Nested
    @DisplayName("getAuthorById")
    inner class GetAuthorByIdTest {

        @Test
        @DisplayName("存在する著者を正常に取得できる")
        fun test01() {
            val id = 1
            val authorRecord = AuthorRecord().apply {
                this.id = id
                this.name = "テスト太郎"
                this.birthDate = LocalDate.parse("1990-01-01")
            }
            `when`(authorRepository.findById(id)).thenReturn(authorRecord)

            val result: AuthorResponseDto = authorService.getAuthorById(id)

            assertEquals(id, result.id)
            assertEquals("テスト太郎", result.name)
            assertEquals(LocalDate.parse("1990-01-01"), result.birthDate)
        }

        @Test
        @DisplayName("存在しない著者IDの場合は例外を送出する")
        fun test02() {
            val id = 2
            `when`(authorRepository.findById(id)).thenReturn(null)

            val exception = assertThrows(NoSuchElementException::class.java) {
                authorService.getAuthorById(id)
            }
            assertEquals("Author not found", exception.message)
        }
    }
}
```