Documentando APIs com Spring REST Docs: Snippets

Depois de concluirmos este webinar, nós:

  • Saberemos como documentar diferentes códigos de status HTTP retornados por um mesmo endpoint.
  • Compreenderemos como documentar os documentos JSON enviados no corpo da requisição para a nossa API REST.
  • Saberemos documentar as respostas HTTP de sucesso e de erro de validação retornadas pela nossa API REST.

Este webinar assume que:

  • Você sabe especificar o contexto de aplicação carregado pelo Spring TestContext Framework.
  • Você está familiarizado com o JUnit 6.
  • Você sabe criar um projeto que executa testes unitários, de integração e End-to-End com JUnit 6.
  • Você sabe documentar uma API REST simples com o Spring REST Docs (Parte 1).

Introdução ao Sistema sob Teste

Durante este webinar, documentaremos um método controlador simples que processa requisições POST enviadas ao caminho /api/message. Este método lê as informações da mensagem criada a partir do corpo da requisição (@RequestBody), valida as informações da mensagem criada (@Valid) e retorna as informações da mensagem “criada” como JSON com status HTTP 201 (Created).

O código-fonte da classe MessageController em Kotlin é o seguinte:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.testwithspring.master.message

import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/api/message")
class MessageController {

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    fun create(@Valid @RequestBody message: MessageDTO): MessageDTO {
        return message
    }
}

A classe MessageDTO é um objeto de transferência de dados simples que contém as informações de uma única mensagem. Ela também garante (via Jakarta Bean Validation @field:NotBlank) que não podemos criar uma mensagem que tenha o texto em branco:

1
2
3
4
5
6
7
8
package com.testwithspring.master.message

import jakarta.validation.constraints.NotBlank

data class MessageDTO(
    @field:NotBlank
    var text: String = ""
)

A seguir, descobriremos como podemos criar os snippets Asciidoctor necessários com o Spring REST Docs.

Criando os Snippets Asciidoctor Necessários com o Spring REST Docs

Podemos gerar os snippets Asciidoctor necessários com o Spring REST Docs seguindo quatro passos:

Primeiro, temos que criar uma nova classe de teste unitário (MessageControllerTest), configurar o Spring MVC Test framework no modo standalone (incluindo o ValidationErrorHandler em setControllerAdvice(...)) e integrar o Spring REST Docs com o MockMvc:

 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
package com.testwithspring.master.message

import com.testwithspring.master.TestTags
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Tag
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.restdocs.RestDocumentationContextProvider
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder

@ExtendWith(RestDocumentationExtension::class)
@Tag(TestTags.UNIT_TEST)
class MessageControllerTest {

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest(restDocumentation: RestDocumentationContextProvider) {
        this.mockMvc = MockMvcBuilders.standaloneSetup(MessageController())
            .setControllerAdvice(ValidationErrorHandler())
            .apply<StandaloneMockMvcBuilder>(documentationConfiguration(restDocumentation))
            .build()
    }
}

Nota: Este webinar descreve como criar os snippets Asciidoctor necessários escrevendo testes unitários, mas a mesma técnica funciona de maneira idêntica em testes de integração anotados com @AutoConfigureRestDocs.

Segundo, temos que criar uma classe de teste interna @Nested chamada CreateNewMessage que conterá nossos casos de teste e a propriedade input do tipo MessageDTO.

Terceiro, temos que criar os snippets Asciidoctor que nos ajudam a documentar o que acontece quando tentamos criar uma nova mensagem que possui um texto de mensagem vazio (""). Fazemos isso seguindo estes passos:

  1. Criar uma classe interna @Nested chamada WhenEmptyMessageTextIsGiven.
  2. Escrever um método @BeforeEach que cria um novo objeto MessageDTO(text = "").
  3. Escrever um método de teste que envia a requisição POST /api/message e gera os snippets com .andDo(document("create-message-empty-text")).

Quarto, temos que criar os snippets Asciidoctor que nos ajudam a documentar o que acontece quando criamos uma nova mensagem usando informações válidas ("Hello World!"). Fazemos isso seguindo estes passos:

  1. Criar uma classe interna @Nested chamada WhenValidMessageTextIsGiven.
  2. Escrever um método @BeforeEach que cria um novo objeto MessageDTO(text = "Hello World!").
  3. Escrever um método de teste que envia a requisição POST /api/message e gera os snippets com .andDo(document("create-message-success")).

Após termos criado os dois conjuntos de snippets Asciidoctor, nossa classe de teste completa fica assim:

 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
package com.testwithspring.master.message

import com.fasterxml.jackson.databind.ObjectMapper
import com.testwithspring.master.TestTags
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.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.http.MediaType
import org.springframework.restdocs.RestDocumentationContextProvider
import org.springframework.restdocs.RestDocumentationExtension
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document
import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.documentationConfiguration
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder

@ExtendWith(RestDocumentationExtension::class)
@Tag(TestTags.UNIT_TEST)
class MessageControllerTest {

    private lateinit var mockMvc: MockMvc
    private val objectMapper = ObjectMapper()

    @BeforeEach
    fun configureSystemUnderTest(restDocumentation: RestDocumentationContextProvider) {
        this.mockMvc = MockMvcBuilders.standaloneSetup(MessageController())
            .setControllerAdvice(ValidationErrorHandler())
            .apply<StandaloneMockMvcBuilder>(documentationConfiguration(restDocumentation))
            .build()
    }

    @Nested
    @DisplayName("Criar uma nova mensagem")
    inner class CreateNewMessage {

        private lateinit var input: MessageDTO

        @Nested
        @DisplayName("Quando um texto de mensagem vazio é fornecido")
        inner class WhenEmptyMessageTextIsGiven {

            @BeforeEach
            fun createInput() {
                input = MessageDTO(text = "")
            }

            @Test
            @DisplayName("Cria os snippets do Spring REST Docs para texto vazio")
            fun createSpringRESTDocsSnippets() {
                mockMvc.perform(
                    post("/api/message")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(input))
                ).andDo(document("create-message-empty-text"))
            }
        }

        @Nested
        @DisplayName("Quando um texto de mensagem válido é fornecido")
        inner class WhenValidMessageTextIsGiven {

            @BeforeEach
            fun createInput() {
                input = MessageDTO(text = "Hello World!")
            }

            @Test
            @DisplayName("Cria os snippets do Spring REST Docs para sucesso")
            fun createSpringRESTDocsSnippets() {
                mockMvc.perform(
                    post("/api/message")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsBytes(input))
                ).andDo(document("create-message-success"))
            }
        }
    }
}

Atenção aos Identificadores de Recursos:

Quando especificamos os identificadores dos recursos documentados em document(...), devemos usar identificadores únicos para cada caso de uso ("create-message-empty-text" e "create-message-success"). Se usássemos o mesmo nome nos dois testes, o segundo teste sobrescreveria os arquivos Asciidoctor do primeiro no mesmo diretório!

Vamos avançar e escrever o documento Asciidoctor que documenta nossa API REST.

Escrevendo a Documentação da Nossa API REST

Quando documentarmos nossa API REST, utilizaremos dois snippets gerados automaticamente pelo Spring REST Docs em cada diretório:

  • O snippet request-body.adoc descreve o corpo JSON da requisição HTTP enviada para a nossa API REST.
  • O snippet http-response.adoc descreve a resposta HTTP completa (status, cabeçalhos e corpo JSON) retornada pela nossa API REST.

Podemos documentar nossa API REST seguindo estes passos:

Primeiro, documentamos o happy path (caminho feliz) do nosso recurso de API seguindo estes passos:

  1. Adicionar uma subseção chamada Create Message à seção Resources.
  2. Descrever como podemos criar uma nova mensagem e incluir o snippet {snippets}/create-message-success/request-body.adoc[].
  3. Descrever os códigos de status HTTP suportados. É uma excelente ideia usar uma tabela Asciidoctor para esse propósito, pois ela oferece uma visão geral imediata dos códigos HTTP retornados pelo endpoint (201 e 400).
  4. Descrever a resposta HTTP retornada pelo nosso recurso de API quando a criação tem sucesso incluindo {snippets}/create-message-success/http-response.adoc[].

Segundo, temos que descrever o que acontece se tentarmos criar uma nova mensagem usando informações inválidas. Fazemos isso adicionando uma subseção chamada Validation Errors dentro da seção Create Message, e documentamos cada erro de validação em sua própria subseção:

  1. Adicionar uma subseção chamada Empty Message Text dentro da seção Validation Errors.
  2. Descrever o documento JSON que dispara esse erro de validação ({snippets}/create-message-empty-text/request-body.adoc[]).
  3. Descrever a resposta HTTP de erro retornada pela nossa API REST ({snippets}/create-message-empty-text/http-response.adoc[]).

Após termos descrito o caminho feliz e todos os erros de validação, a parte relevante do nosso documento Asciidoctor (index.adoc) fica assim:

 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
= Spring REST Docs Example
Petri Kainulainen;
:doctype: book
:icons: font
:source-highlighter: highlightjs
:toc: left
:toclevels: 4
:sectlinks:

[[resources]]
= Resources

[[resources-create-message]]
== Create Message

Podemos criar uma nova mensagem enviando uma requisição `POST` para
o caminho: `/api/message`. Quando criamos uma nova mensagem, temos
que adicionar as informações da mensagem criada ao corpo da requisição.

O documento JSON que especifica as informações da mensagem criada
é o seguinte:

include::{snippets}/create-message-success/request-body.adoc[]

Após nossa API REST processar a requisição HTTP recebida, ela
identifica o resultado retornando um código de status HTTP apropriado.
Os códigos de status HTTP utilizados por este método da API são descritos
na tabela a seguir:

[options="header"]
|===============================================================
| Código de Status HTTP | Descrição
| 201 | Uma nova mensagem foi criada com sucesso.
| 400 | Uma nova mensagem não foi criada porque a validação falhou.
|===============================================================

Após nossa API REST criar uma nova mensagem, ela retorna as
informações da mensagem criada como JSON. A resposta HTTP retornada
é a seguinte:

include::{snippets}/create-message-success/http-response.adoc[]

=== Validation Errors

Quando a validação falha, nossa API REST retorna os erros de validação
como JSON. Esta seção descreve todos os erros de validação que podem ocorrer
quando criamos uma nova mensagem usando informações inválidas.

==== Empty Message Text

Não podemos criar uma mensagem que possua um texto de mensagem vazio. Em outras
palavras, quando enviamos o seguinte documento JSON para nossa API REST:

include::{snippets}/create-message-empty-text/request-body.adoc[]

Nossa API REST retorna a seguinte resposta HTTP de volta para o cliente:

include::{snippets}/create-message-empty-text/http-response.adoc[]

Documentamos nossa API REST cobrindo tanto o fluxo de sucesso quanto os contratos de erro de validação! Vamos resumir o que aprendemos neste webinar.

Resumo

Este webinar nos ensinou cinco coisas:

  • Se precisarmos documentar múltiplos casos de uso diferentes do mesmo recurso de API (ex.: sucesso 201 e erro 400), devemos garantir que cada caso de uso receba um identificador de recurso único no método document(...).
  • Podemos documentar o payload JSON enviado para a nossa API REST usando o snippet Asciidoctor request-body.adoc.
  • É uma ótima prática documentar todos os códigos de status HTTP suportados pelo endpoint usando uma tabela Asciidoctor.
  • Podemos documentar a resposta HTTP retornada pela nossa API REST usando o snippet Asciidoctor http-response.adoc.
  • Devemos documentar todos os erros de validação usando uma subseção dedicada para cada regra de validação.