簡易的な機能を持つAPIについて、メモがてらJUNITを書いてみた
前提条件
出版社における著者情報を「閲覧・登録・更新」するAPIを考える。
Controllerクラス
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
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クラス
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クラス
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)
}
}
}