Testes Unitários de API REST com Kotlin: Escrita

Ao concluir esta aula, seremos capazes de:

  • Escrever testes unitários para uma API REST que salva dados no banco de dados e retorna dados como JSON utilizando Kotlin e JUnit Jupiter 6 (JUnit 5/6).
  • Compreender como podemos criar o objeto UserDetails que é passado para a função do controller testado como parâmetro de função quando configuramos o sistema sob teste utilizando a configuração standalone (StandaloneMockMvcBuilder).

Esta aula pressupõe que:

Introdução ao Sistema Sob Teste

Durante esta aula, escreveremos testes unitários para uma API REST Spring MVC (no Spring Boot 4.1.2 com Kotlin 2.4.21) que salva tarefas no banco de dados. Quando o sistema sob teste salva uma nova tarefa no banco de dados, ele segue estes passos:

Primeiro, quando a classe TaskCrudController processa requisições HTTP, ela segue estes passos:

  1. Processa requisições POST enviadas para o caminho: /api/task.
  2. Lê as informações da tarefa criada a partir do corpo da requisição (request body) e valida essas informações. Se a validação falhar, retorna o código de status HTTP 400 (Bad Request) e retorna os erros de validação como JSON.
  3. Obtém o usuário autenticado (um objeto UserDetails) a partir do contexto de segurança.
  4. Salva uma nova tarefa no banco de dados invocando a função create() da classe TaskCrudService.
  5. Retorna o código de status HTTP 201 (Created) e retorna as informações da tarefa criada como JSON.

A parte relevante da nossa classe controller tem a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import jakarta.validation.Valid
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/task")
open class TaskCrudController(
    @Autowired private val service: TaskCrudService
) {

    @RequestMapping(method = [RequestMethod.POST])
    @ResponseStatus(HttpStatus.CREATED)
    fun create(
        @RequestBody @Valid input: CreateTaskDTO,
        @AuthenticationPrincipal loggedInUser: LoggedInUser
    ): TaskDTO {
        return service.create(input, loggedInUser)
    }
}

Nota sobre o tratamento de erros de validação:

Os erros de validação são processados pela classe TaskErrorHandler. Sua implementação não faz parte do escopo desta aula, mas é útil saber que tipo de documento JSON é retornado ao cliente caso a validação falhe. Por exemplo, se o cliente tentar criar uma nova tarefa que não possui título, o seguinte documento JSON será retornado ao cliente:

1
2
3
4
5
6
7
8
{
    "fieldErrors": [
        {
            "field": "title",
            "errorCode": "NotBlank"
        }
    ]
}

O modelo de dados público da nossa API REST tem a seguinte aparência (utilizando Jakarta Bean Validation no Spring Boot 4):

 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
import com.testwithspring.master.user.PersonDTO
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

enum class TaskResolution {
    DUPLICATE,
    DONE,
    WONT_DO
}

enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

data class CreateTaskDTO(
    @field:NotBlank
    @field:Size(max = 100)
    val title: String?,
    @field:Size(max = 500)
    val description: String?
)

data class PersonDTO(val name: String, val userId: Long)

data class TaskTagDTO(val id: Long, val name: String)

data class TaskDTO(
    val assignee: PersonDTO?,
    val closer: PersonDTO?,
    val creator: PersonDTO,
    val description: String,
    val id: Long,
    val modifier: PersonDTO,
    val resolution: TaskResolution?,
    val status: TaskStatus,
    val tags: List<TaskTagDTO>,
    val title: String
)

A classe LoggedInUser contém as informações de um usuário autenticado. Como estamos utilizando o Spring Security, esta classe deve implementar a interface UserDetails.

O código-fonte das classes relevantes tem a seguinte aparência:

 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
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails

enum class UserRole {
    ROLE_USER, ROLE_ADMIN
}

class LoggedInUser(
    val id: Long,
    private val enabled: Boolean,
    private val password: String,
    val role: UserRole,
    private val username: String
) : UserDetails {

    override fun getAuthorities(): MutableCollection<out GrantedAuthority> =
        mutableListOf(SimpleGrantedAuthority(role.toString()))

    override fun isEnabled(): Boolean = enabled

    override fun getUsername(): String = username

    override fun isCredentialsNonExpired(): Boolean = true

    override fun getPassword(): String = password

    override fun isAccountNonExpired(): Boolean = true

    override fun isAccountNonLocked(): Boolean = true
}

Segundo, quando a classe TaskCrudService cria uma nova tarefa, ela segue estes passos:

  1. Cria um novo objeto CreateTask.
  2. Salva a tarefa criada no banco de dados invocando a função create() da classe TaskRepository. Armazena o objeto Task retornado em uma variável local.
  3. Cria um novo objeto TaskDTO utilizando as informações retornadas. Obtém as informações pessoais do creator (criador) e do modifier (modificador) invocando a função findPersonInformationByUserId() da classe PersonFinder.
  4. Retorna o objeto TaskDTO criado.

O código-fonte da classe TaskCrudService tem a seguinte aparência:

 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
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class TaskCrudService(
    @Autowired private val personFinder: PersonFinder,
    @Autowired private val repository: TaskRepository
) {

    @Transactional
    open fun create(
        input: CreateTaskDTO,
        loggedInUser: LoggedInUser
    ): TaskDTO {
        val newTask = CreateTask(
            creator = Creator(loggedInUser.id),
            title = input.title!!,
            description = input.description,
            status = TaskStatus.OPEN
        )

        val returned = repository.create(newTask)

        return TaskDTO(
            assignee = null,
            closer = null,
            creator = personFinder
                .findPersonInformationByUserId(returned.creator.id),
            description = returned.description,
            id = returned.id,
            modifier = personFinder
                .findPersonInformationByUserId(returned.modifier.id),
            resolution = returned.resolution,
            status = returned.status,
            tags = listOf(),
            title = returned.title
        )
    }
}

O modelo de dados privado da nossa API REST tem a seguinte aparência:

 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
data class Assignee(val id: Long)

data class Closer(val id: Long)

data class Creator(val id: Long)

data class Modifier(val id: Long)

enum class TaskResolution {
    DUPLICATE,
    DONE,
    WONT_DO
}

enum class TaskStatus {
    CLOSED,
    IN_PROGRESS,
    OPEN
}

data class CreateTask(
    val creator: Creator,
    val description: String?,
    val status: TaskStatus,
    val title: String
)

data class TaskTag(val id: Long, val name: String)

data class Task(
    val assignee: Assignee?,
    val closer: Closer?,
    val creator: Creator,
    val description: String,
    val id: Long,
    val modifier: Modifier,
    val resolution: TaskResolution?,
    val status: TaskStatus,
    val tags: List<TaskTag>,
    val title: String
)

Agora já estamos familiarizados com o sistema sob teste. A seguir, descobriremos como podemos fornecer o objeto UserDetails para a função do controller testado.

Configurando o Sistema Sob Teste

Como a função do nosso controller espera receber um objeto UserDetails (LoggedInUser) como parâmetro de função, precisamos fazer algumas alterações na configuração que prepara o sistema sob teste. A solução mais óbvia seria configurar a infraestrutura necessária do Spring Security e incluí-la no sistema sob teste. No entanto, não queremos fazer isso porque transformaria nossos testes unitários em testes de integração.

Em vez disso, implementaremos um HandlerMethodArgumentResolver customizado que fornece um objeto LoggedInUser para a função do nosso controller. Podemos escrever esse componente seguindo estes passos:

  1. Criar uma nova classe que implemente a interface HandlerMethodArgumentResolver.
  2. Escrever um construtor que receba o ID do usuário autenticado como argumento de construtor e armazene o ID fornecido em uma propriedade private.
  3. Implementar o método supportsParameter() da interface HandlerMethodArgumentResolver. Nossa implementação deve retornar true quando o tipo do parâmetro do método for LoggedInUser.
  4. Implementar o método resolveArgument() da interface HandlerMethodArgumentResolver. Nossa implementação deve retornar um novo objeto LoggedInUser cujo ID seja o mesmo que o ID de usuário fornecido como argumento de construtor.

O código-fonte da classe AuthenticationHandlerMethodArgumentResolver tem a seguinte aparência:

 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
import org.springframework.core.MethodParameter
import org.springframework.web.bind.support.WebDataBinderFactory
import org.springframework.web.context.request.NativeWebRequest
import org.springframework.web.method.support.HandlerMethodArgumentResolver
import org.springframework.web.method.support.ModelAndViewContainer

class AuthenticationHandlerMethodArgumentResolver(private val userId: Long) :
    HandlerMethodArgumentResolver {

    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.parameterType == LoggedInUser::class.java
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?
    ): Any {
        return LoggedInUser(
            id = userId,
            enabled = true,
            password = "NOT_IMPORTANT",
            role = UserRole.ROLE_USER,
            username = "NOT_IMPORTANT"
        )
    }
}

Depois de escrevermos um HandlerMethodArgumentResolver customizado, precisamos incluí-lo no sistema sob teste. Se quisermos usar um HandlerMethodArgumentResolver customizado, devemos invocar o método setCustomArgumentResolvers() da classe StandaloneMockMvcBuilder e passar o objeto HandlerMethodArgumentResolver como parâmetro do método.

Após fazermos as alterações necessárias na nossa classe de teste, seu código-fonte fica com a seguinte aparência:

 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
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Tag
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val LOGGED_IN_USER_ID = 5L
        private const val TASK_ID = 1L
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders.standaloneSetup(
            TaskCrudController(service)
        )
            .setControllerAdvice(TaskErrorHandler())
            .setCustomArgumentResolvers(
                AuthenticationHandlerMethodArgumentResolver(LOGGED_IN_USER_ID)
            )
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }
}

Vamos avançar e descobrir como podemos escrever a função do request builder que cria e envia requisições HTTP para criar novas tarefas.

Escrevendo uma Nova Função no Request Builder

Antes de podermos escrever testes unitários para a nossa API REST, precisamos adicionar uma nova função à nossa classe de request builder (TaskCrudRequestBuilder). Podemos escrever nossa função de request builder seguindo estes passos:

  1. Criar um novo objeto ObjectMapper invocando a função objectMapper() da classe WebTestConfig e armazenar o objeto retornado em uma propriedade private. Nossa função de request builder utilizará esse objeto ao transformar objetos de transferência de dados (DTOs) em documentos JSON.
  2. Enviar uma requisição POST para o caminho /api/task utilizando o método perform() (ou a DSL Kotlin mockMvc.post) da classe MockMvc. Lembre-se de criar o documento JSON que contém as informações da tarefa criada e adicioná-lo ao corpo da requisição (request body).

Depois de escrevermos nossa função de request builder, o código-fonte da nossa classe TaskCrudRequestBuilder tem a seguinte aparência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.ResultActions
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post

class TaskCrudRequestBuilder(private val mockMvc: MockMvc) {

    private val objectMapper = WebTestConfig.objectMapper()

    fun create(input: CreateTaskDTO): ResultActions {
        return mockMvc.perform(
            post("/api/task")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsBytes(input))
        )
    }
}

Dica de MockMvc Kotlin DSL:

No Spring Boot 4 / Spring Framework 7, você também pode expressar chamadas diretas ao MockMvc utilizando a DSL idiomática mockMvc.post("/api/task") { contentType = MediaType.APPLICATION_JSON; content = objectMapper.writeValueAsString(input) }, que retorna ResultActionsDsl. Manteremos o método create(input: CreateTaskDTO): ResultActions no TaskCrudRequestBuilder para preservar o encapsulamento da construção de requisições entre todos os testes da suíte, e demonstraremos também a equivalência com a DSL Kotlin.

A seguir, escreveremos os testes unitários para a função do controller testado.

Escrevendo os Testes Unitários

Podemos escrever testes unitários para a função create() da classe TaskCrudController seguindo estas etapas:

Primeiro, precisamos criar as classes internas (inner class anotadas com @Nested) que contêm nossos testes unitários. Podemos criar essas classes internas seguindo estes passos:

  1. Criar a classe interna que contém todos os testes unitários da função create(). Esta classe contém duas constantes que especificam o comprimento máximo do título (MAX_LENGTH_TITLE) e da descrição (MAX_LENGTH_DESCRIPTION).
  2. Criar a classe interna que contém os testes unitários que garantem que nossa API REST funcione conforme esperado quando a validação falha (WhenValidationFails). Esta classe contém uma constante que declara o código de erro dos erros de validação retornados (ERROR_CODE_SIZE).
  3. Criar a classe interna que contém os testes unitários que garantem que nossa API REST funcione conforme esperado quando a validação é bem-sucedida (WhenValidInformationIsGiven). Esta classe contém quatro constantes que especificam as informações fornecidas para a função do controller testado.

Depois de criarmos as classes internas necessárias, o código-fonte da nossa classe de teste tem a seguinte aparência:

 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
import io.mockk.mockk
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Nested
import org.junit.jupiter.api.Tag
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val LOGGED_IN_USER_ID = 5L
        private const val TASK_ID = 1L
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders.standaloneSetup(
            TaskCrudController(service)
        )
            .setControllerAdvice(TaskErrorHandler())
            .setCustomArgumentResolvers(
                AuthenticationHandlerMethodArgumentResolver(LOGGED_IN_USER_ID)
            )
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Create a new task")
    inner class Create {

        private val MAX_LENGTH_DESCRIPTION = 500
        private val MAX_LENGTH_TITLE = 100

        @Nested
        @DisplayName("When validation fails")
        inner class WhenValidationFails {

            private val ERROR_CODE_SIZE = "Size"

        }

        @Nested
        @DisplayName("When valid information is given")
        inner class WhenValidInformationIsGiven {

            private val LOGGED_IN_USER_NAME = "Jane Doe"
            private val MAX_DESCRIPTION = TestStringBuilder
                .createStringWithLength(MAX_LENGTH_DESCRIPTION)
            private val MAX_TITLE = TestStringBuilder
                .createStringWithLength(MAX_LENGTH_TITLE)
            private val STATUS_OPEN = TaskStatus.OPEN

        }
    }
}

Segundo, precisamos escrever os testes unitários que garantem que nossa API REST funcione conforme esperado quando a validação falha. Podemos escrever esses testes seguindo estes passos:

  1. Criar a classe interna que contém os testes unitários que garantem que o sistema sob teste funcione conforme esperado quando a tarefa criada possui título e descrição longos demais (WhenTitleAndDescriptionAreTooLong).
  2. Criar os dados de entrada (INPUT) que possuem título e descrição longos demais (MAX_LENGTH_TITLE + 1 e MAX_LENGTH_DESCRIPTION + 1).
  3. Verificar se o sistema sob teste retorna o código de status HTTP 400 (Bad Request).
  4. Garantir que o sistema sob teste retorne os erros de validação como JSON.
  5. Verificar se o sistema sob teste retorna dois erros de validação.
  6. Garantir que o sistema sob teste retorne um erro de validação sobre o título longo demais.
  7. Verificar se o sistema sob teste retorna um erro de validação sobre a descrição longa demais.
  8. Garantir que o sistema sob teste não insira uma nova tarefa no banco de dados (verify(exactly = 0) { repository.create(any()) }).

Depois de escrevermos os testes unitários necessários, a parte relevante da nossa classe de teste tem a seguinte aparência:

  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
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import io.mockk.mockk
import io.mockk.verify
import org.hamcrest.Matchers.contains
import org.hamcrest.Matchers.hasSize
import org.junit.jupiter.api.*
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val LOGGED_IN_USER_ID = 5L
        private const val TASK_ID = 1L
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders.standaloneSetup(
            TaskCrudController(service)
        )
            .setControllerAdvice(TaskErrorHandler())
            .setCustomArgumentResolvers(
                AuthenticationHandlerMethodArgumentResolver(LOGGED_IN_USER_ID)
            )
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Create a new task")
    inner class Create {

        private val MAX_LENGTH_DESCRIPTION = 500
        private val MAX_LENGTH_TITLE = 100

        @Nested
        @DisplayName("When validation fails")
        inner class WhenValidationFails {

            private val ERROR_CODE_SIZE = "Size"

            @Nested
            @DisplayName("When the title and description are too long")
            inner class WhenTitleAndDescriptionAreTooLong {

                private val INPUT = CreateTaskDTO(
                    title = TestStringBuilder
                        .createStringWithLength(MAX_LENGTH_TITLE + 1),
                    description = TestStringBuilder
                        .createStringWithLength(MAX_LENGTH_DESCRIPTION + 1)
                )

                @Test
                @DisplayName("Should return the HTTP status code bad request")
                fun shouldReturnHttpStatusCodeBadRequest() {
                    requestBuilder.create(INPUT)
                        .andExpect(status().isBadRequest)
                }

                @Test
                @DisplayName("Should return validation errors as Json")
                fun shouldReturnValidationErrorsAsJson() {
                    requestBuilder.create(INPUT)
                        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
                }

                @Test
                @DisplayName("Should return two validation errors")
                fun shouldReturnOneValidationError() {
                    requestBuilder.create(INPUT)
                        .andExpect(jsonPath("$.fieldErrors", hasSize<Any>(2)))
                }

                @Test
                @DisplayName("Should return a validation error about too long title")
                fun shouldReturnValidationErrorAboutTooLongTitle() {
                    requestBuilder.create(INPUT)
                        .andExpect(
                            jsonPath(
                                "$.fieldErrors[?(@.field == 'title')].errorCode",
                                contains(ERROR_CODE_SIZE)
                            )
                        )
                }

                @Test
                @DisplayName("Should return a validation error about too long description")
                fun shouldReturnValidationErrorAboutTooLongDescription() {
                    requestBuilder.create(INPUT)
                        .andExpect(
                            jsonPath(
                                "$.fieldErrors[?(@.field == 'description')].errorCode",
                                contains(ERROR_CODE_SIZE)
                            )
                        )
                }

                @Test
                @DisplayName("Should not create a new task")
                fun shouldNotCreateNewTask() {
                    requestBuilder.create(INPUT)
                    verify(exactly = 0) { repository.create(any()) }
                }
            }
        }
    }
}

Nota:

A propósito, esses testes não cobrem todos os cenários que podem causar um erro de validação (como título nulo ou em branco). Escolhemos cobrir apenas um cenário específico aqui para evitar repetição desnecessária no texto da aula. Os demais cenários seguem exatamente a mesma estrutura dentro de WhenValidationFails.

Terceiro, precisamos escrever os testes unitários que garantem que nossa API REST funcione conforme esperado quando a validação é bem-sucedida (WhenValidInformationIsGiven). Podemos escrever esses testes seguindo estes passos:

  1. Criar os dados de entrada (INPUT) que contêm informações válidas.
  2. Escrever uma função private (repositoryReturnsCreatedTask()) que garanta que a função create() da classe TaskRepository retorne as informações da tarefa criada.
  3. Escrever uma função private (personFinderReturnsPersonData()) que garanta que a função findPersonInformationByUserId() da classe PersonFinder retorne as informações pessoais do usuário autenticado.
  4. Escrever uma função de configuração (@BeforeEach fun configureTestCases()) que seja executada antes de cada função de teste. A implementação desta função simplesmente invoca nossas funções utilitárias private.
  5. Verificar se o sistema sob teste cria uma tarefa que possui o criador (creator) correto.
  6. Garantir que o sistema sob teste crie uma tarefa que possua a descrição (description) correta.
  7. Verificar se o sistema sob teste cria uma tarefa que possui o título (title) correto.
  8. Garantir que o sistema sob teste crie uma tarefa aberta (TaskStatus.OPEN).
  9. Verificar se o sistema sob teste retorna o código de status HTTP 201 (Created).
  10. Garantir que o sistema sob teste retorne as informações da tarefa criada como JSON.
  11. Verificar se o sistema sob teste retorna o identificador da tarefa criada.
  12. Garantir que o sistema sob teste retorne uma tarefa que não possui responsável (assignee nulo).
  13. Verificar se o sistema sob teste retorna uma tarefa que não possui finalizador (closer nulo).
  14. Garantir que o sistema sob teste retorne as informações da pessoa que criou a tarefa (creator).
  15. Verificar se o sistema sob teste retorna as informações da pessoa que modificou a tarefa (modifier).
  16. Garantir que o sistema sob teste retorne a descrição e o título corretos.
  17. Verificar se o sistema sob teste retorna uma tarefa aberta (resolution nulo e status igual a OPEN).
  18. Garantir que o sistema sob teste retorne uma tarefa que não possui tags (tags vazia).

Depois de escrevermos esses testes unitários, a parte relevante da nossa classe de teste tem a seguinte aparência:

  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
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.*
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders

@Tag("unitTest")
@DisplayName("Perform CRUD operations for tasks")
class TaskCrudControllerTest {

    companion object {
        private const val LOGGED_IN_USER_ID = 5L
        private const val TASK_ID = 1L
    }

    private lateinit var personFinder: PersonFinder
    private lateinit var repository: TaskRepository
    private lateinit var requestBuilder: TaskCrudRequestBuilder

    @BeforeEach
    fun configureSystemUnderTest() {
        personFinder = mockk()
        repository = mockk()
        val service = TaskCrudService(personFinder, repository)

        val mockMvc = MockMvcBuilders.standaloneSetup(
            TaskCrudController(service)
        )
            .setControllerAdvice(TaskErrorHandler())
            .setCustomArgumentResolvers(
                AuthenticationHandlerMethodArgumentResolver(LOGGED_IN_USER_ID)
            )
            .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
            .setMessageConverters(
                WebTestConfig.objectMapperHttpMessageConverter()
            )
            .build()
        requestBuilder = TaskCrudRequestBuilder(mockMvc)
    }

    @Nested
    @DisplayName("Create a new task")
    inner class Create {

        private val MAX_LENGTH_DESCRIPTION = 500
        private val MAX_LENGTH_TITLE = 100

        @Nested
        @DisplayName("When valid information is given")
        inner class WhenValidInformationIsGiven {

            private val LOGGED_IN_USER_NAME = "Jane Doe"
            private val MAX_DESCRIPTION = TestStringBuilder
                .createStringWithLength(MAX_LENGTH_DESCRIPTION)
            private val MAX_TITLE = TestStringBuilder
                .createStringWithLength(MAX_LENGTH_TITLE)
            private val STATUS_OPEN = TaskStatus.OPEN

            private val INPUT = CreateTaskDTO(
                title = MAX_TITLE,
                description = MAX_DESCRIPTION
            )

            @BeforeEach
            fun configureTestCases() {
                repositoryReturnsCreatedTask()
                personFinderReturnsPersonData()
            }

            private fun repositoryReturnsCreatedTask() {
                every { repository.create(any()) } returns Task(
                    assignee = null,
                    closer = null,
                    creator = Creator(LOGGED_IN_USER_ID),
                    description = MAX_DESCRIPTION,
                    id = TASK_ID,
                    modifier = Modifier(LOGGED_IN_USER_ID),
                    resolution = null,
                    status = STATUS_OPEN,
                    tags = listOf(),
                    title = MAX_TITLE
                )
            }

            private fun personFinderReturnsPersonData() {
                every { personFinder.findPersonInformationByUserId(LOGGED_IN_USER_ID) } returns
                    PersonDTO(
                        name = LOGGED_IN_USER_NAME,
                        userId = LOGGED_IN_USER_ID
                    )
            }

            @Test
            @DisplayName("Should create a new task with the correct creator")
            fun shouldCreateNewTaskWithCorrectCreator() {
                requestBuilder.create(INPUT)
                verify {
                    repository.create(
                        match { it.creator.id == LOGGED_IN_USER_ID }
                    )
                }
            }

            @Test
            @DisplayName("Should create a new task with the correct description")
            fun shouldCreateNewTaskWithCorrectDescription() {
                requestBuilder.create(INPUT)
                verify {
                    repository.create(
                        match { it.description == MAX_DESCRIPTION }
                    )
                }
            }

            @Test
            @DisplayName("Should create a new task with the correct title")
            fun shouldCreateNewTaskWithCorrectTitle() {
                requestBuilder.create(INPUT)
                verify { repository.create(match { it.title == MAX_TITLE }) }
            }

            @Test
            @DisplayName("Should create an open task")
            fun shouldCreateOpenTask() {
                requestBuilder.create(INPUT)
                verify { repository.create(match { it.status == STATUS_OPEN }) }
            }

            @Test
            @DisplayName("Should return the HTTP status code created")
            fun shouldReturnHttpStatusCodeCreated() {
                requestBuilder.create(INPUT)
                    .andExpect(status().isCreated)
            }

            @Test
            @DisplayName("Should return the information of the created task as Json")
            fun shouldReturnInformationOfCreatedTaskAsJson() {
                requestBuilder.create(INPUT)
                    .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            }

            @Test
            @DisplayName("Should return the information of the created task")
            fun shouldReturnInformationOfCreatedTask() {
                requestBuilder.create(INPUT)
                    .andExpect(jsonPath("$.id", equalTo(TASK_ID.toInt())))
            }

            @Test
            @DisplayName("Should return a task that has no assignee")
            fun shouldReturnTaskThatHasNoAssignee() {
                requestBuilder.create(INPUT)
                    .andExpect(jsonPath("$.assignee", nullValue()))
            }

            @Test
            @DisplayName("Should return a task that has no closer")
            fun shouldReturnTaskThatHasNoCloser() {
                requestBuilder.create(INPUT)
                    .andExpect(jsonPath("$.closer", nullValue()))
            }

            @Test
            @DisplayName("Should return the information of the creator")
            fun shouldReturnCorrectCreator() {
                requestBuilder.create(INPUT)
                    .andExpect(
                        jsonPath(
                            "$.creator.name",
                            equalTo(LOGGED_IN_USER_NAME)
                        )
                    )
                    .andExpect(
                        jsonPath(
                            "$.creator.userId",
                            equalTo(LOGGED_IN_USER_ID.toInt())
                        )
                    )
            }

            @Test
            @DisplayName("Should return the information of the modifier")
            fun shouldReturnCorrectModifier() {
                requestBuilder.create(INPUT)
                    .andExpect(
                        jsonPath(
                            "$.modifier.name",
                            equalTo(LOGGED_IN_USER_NAME)
                        )
                    )
                    .andExpect(
                        jsonPath(
                            "$.modifier.userId",
                            equalTo(LOGGED_IN_USER_ID.toInt())
                        )
                    )
            }

            @Test
            @DisplayName("Should return the correct description and title")
            fun shouldReturnCorrectDescriptionAndTitle() {
                requestBuilder.create(INPUT)
                    .andExpect(
                        jsonPath(
                            "$.description",
                            equalTo(MAX_DESCRIPTION)
                        )
                    )
                    .andExpect(
                        jsonPath(
                            "$.title",
                            equalTo(MAX_TITLE)
                        )
                    )
            }

            @Test
            @DisplayName("Should return an open task")
            fun shouldReturnOpenTask() {
                requestBuilder.create(INPUT)
                    .andExpect(jsonPath("$.resolution", nullValue()))
                    .andExpect(jsonPath("$.status", equalTo(STATUS_OPEN.name)))
            }

            @Test
            @DisplayName("Should return a task that has no tags")
            fun shouldReturnTaskThatHasNoTags() {
                requestBuilder.create(INPUT)
                    .andExpect(jsonPath("$.tags", hasSize<Any>(0)))
            }
        }
    }
}

Agora sabemos como escrever testes unitários para uma API REST que salva dados no banco de dados e retorna dados como JSON em Kotlin. Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou duas coisas fundamentais:

  • Se precisarmos fornecer um objeto UserDetails para a função do controller testado em um teste unitário, devemos implementar um HandlerMethodArgumentResolver customizado que nos permita instanciar e injetar o objeto UserDetails necessário sem acoplar o teste à infraestrutura completa do Spring Security.
  • Se quisermos utilizar um HandlerMethodArgumentResolver customizado, devemos invocar o método setCustomArgumentResolvers() da classe StandaloneMockMvcBuilder ao configurarmos o sistema sob teste.