Especificações E2E com Kotest: Formulários e Validação

Depois de concluirmos esta aula, nós:

  • Saberemos escrever testes End-to-End com Kotest e Playwright para funcionalidades que submetem um formulário.
  • Compreenderemos como devemos estruturar nossas classes de especificação quando especificamos o comportamento do sistema sob especificação.

Esta aula assume que:

  • Você sabe executar seus testes End-to-End utilizando o Gradle 9 (endToEndTest).
  • Você está familiarizado com o Playwright e o padrão Page Object.
  • Você sabe escrever testes End-to-End para aplicações web Spring utilizando JUnit 6.
  • Você está familiarizado com o Kotest Framework.
  • Você sabe configurar seus testes End-to-End no Spring Boot 4 com PlaywrightExtension e @Sql.
  • Você sabe escrever testes End-to-End no Kotest para funcionalidades que renderizam dados.

Nota importante: Você deve ter concluído a aula Escrevendo Testes End-to-End para Aplicações Web Spring — Submetendo um Formulário antes de prosseguir com esta aula, pois ela explica os Page Objects CreateTaskPage e CreateTaskForm. O objetivo desta aula é demonstrar como escrever especificações End-to-End para formulários usando o Kotest Framework.

Especificando o Comportamento da Funcionalidade de Criação de Tarefa (Create Task Feature)

Podemos especificar o comportamento esperado da funcionalidade de criação de tarefa seguindo estes seis passos:

Primeiro, temos que criar uma nova classe de especificação (CreateTaskSpec) e configurar o servidor Spring Boot, os scripts @Sql e a PlaywrightExtension. Após termos feito isso, o código-fonte da nossa classe de especificação 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
package com.testwithspring.master.web

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import com.testwithspring.master.e2e.BrowserTypeOption
import com.testwithspring.master.e2e.PlaywrightExtension
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.AFTER_TEST_METHOD
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_METHOD

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ],
    executionPhase = BEFORE_TEST_METHOD
)
@Sql(
    value = ["classpath:/com/testwithspring/master/cleandb.sql"],
    executionPhase = AFTER_TEST_METHOD
)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class CreateTaskSpec : BehaviorSpec({

    val playwright = PlaywrightExtension(
        browserOption = BrowserTypeOption.CHROMIUM,
        headless = true
    )

    extensions(
        SpringExtension(SpringTestLifecycleMode.Root),
        playwright
    )
})

Segundo, temos que adicionar duas constantes privadas à nossa classe de especificação. Essas constantes contêm o título (TITLE) e a descrição (DESCRIPTION) da tarefa criada:

1
2
private const val DESCRIPTION = "This is a new lesson"
private const val TITLE = "Write an extra lesson"

Nota: Adicionamos essas constantes diretamente ao arquivo da nossa classe de especificação porque elas não são utilizadas por outras classes de especificação.

Terceiro, temos que adicionar uma variável createTaskPage do tipo CreateTaskPage à nossa classe de especificação. Nosso cenário utiliza esse Page Object quando abre a página de criação de tarefa e submete o formulário de criação de tarefa.

Quarto, inicializamos createTaskPage antes do cenário raiz (beforeContainer) instanciando CreateTaskPage(playwright.page).

Quinto, garantimos que o usuário autenticado seja desconectado (NavigationBar(playwright.page).logUserOut()) após a execução do cenário raiz (afterContainer).

Sexto, temos que escrever o cenário que especifica o comportamento da funcionalidade de criação de tarefa quando um usuário registrado cria uma nova tarefa usando informações válidas. Podemos escrever este cenário seguindo estes passos:

  1. Adicionar um novo bloco Given / When / Then à nossa classe de especificação.
  2. Abrir a página de login (LoginPage(playwright.page).open()) e autenticar o usuário (JohnDoe.EMAIL_ADDRESS, JohnDoe.PASSWORD).
  3. Abrir a página de criação de tarefa (createTaskPage.open().getForm()), preencher o título (typeTitle(TITLE)) e a descrição (typeDescription(DESCRIPTION)) da tarefa criada no formulário, submeter o formulário (submitTaskForm()) e armazenar o Page Object retornado (TaskPage) na variável shownPage.
  4. Garantir que o sistema sob especificação abriu a página de visualização da tarefa recém-criada (shownPage.isOpenWithUnknownTaskId() shouldBe true).
  5. Verificar se a página de visualização exibe o título da tarefa criada (TITLE).
  6. Garantir que a página de visualização exiba a descrição da tarefa criada (DESCRIPTION).
  7. Verificar se a página de visualização não exibe o nome do responsável (lifecycleFields.isAssigneeNameVisible() shouldBe false).
  8. Garantir que a página de visualização exiba o nome da pessoa que criou a tarefa (JohnDoe.NAME).
  9. Verificar se a página de visualização exibe o nome da pessoa que modificou a tarefa (JohnDoe.NAME).
  10. Garantir que a página de visualização não exiba os campos de ciclo de vida de uma tarefa fechada (lifecycleFields.areClosedTaskFieldsVisible() shouldBe false).

Após termos escrito este cenário, o código-fonte completo de CreateTaskSpec 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
 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
package com.testwithspring.master.web

import com.testwithspring.master.EndToEndTest
import com.testwithspring.master.EndToEndTestUsers.JohnDoe
import com.testwithspring.master.PostgresTestcontainersConfiguration
import com.testwithspring.master.TaskTrackerApplication
import com.testwithspring.master.config.Profiles
import com.testwithspring.master.e2e.BrowserTypeOption
import com.testwithspring.master.e2e.PlaywrightExtension
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.extensions.spring.SpringTestLifecycleMode
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.AFTER_TEST_METHOD
import org.springframework.test.context.jdbc.Sql.ExecutionPhase.BEFORE_TEST_METHOD

private const val DESCRIPTION = "This is a new lesson"
private const val TITLE = "Write an extra lesson"

@SpringBootTest(
    classes = [TaskTrackerApplication::class],
    webEnvironment = WebEnvironment.DEFINED_PORT
)
@Import(PostgresTestcontainersConfiguration::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ],
    executionPhase = BEFORE_TEST_METHOD
)
@Sql(
    value = ["classpath:/com/testwithspring/master/cleandb.sql"],
    executionPhase = AFTER_TEST_METHOD
)
@ActiveProfiles(Profiles.END_TO_END_TEST)
@EndToEndTest
class CreateTaskSpec : BehaviorSpec({

    val playwright = PlaywrightExtension(
        browserOption = BrowserTypeOption.CHROMIUM,
        headless = true
    )

    extensions(
        SpringExtension(SpringTestLifecycleMode.Root),
        playwright
    )

    lateinit var createTaskPage: CreateTaskPage

    beforeContainer {
        if (it.descriptor.isRootTest()) {
            createTaskPage = CreateTaskPage(playwright.page)
        }
    }

    afterContainer { (testCase, _) ->
        if (testCase.descriptor.isRootTest()) {
            NavigationBar(playwright.page).logUserOut()
        }
    }

    Given("que um usuário registrado está autenticado com sucesso") {
        val loginPage = LoginPage(playwright.page).open()
        loginPage.login(JohnDoe.EMAIL_ADDRESS, JohnDoe.PASSWORD)

        When("o usuário abre a página de criação de tarefa e submete o formulário com informações válidas") {
            val createTaskForm = createTaskPage.open().getForm()
            createTaskForm.typeTitle(TITLE)
            createTaskForm.typeDescription(DESCRIPTION)
            val shownPage = createTaskForm.submitTaskForm()
            val lifecycleFields = shownPage.getTaskLifecycleFields()

            Then("deve abrir a página de visualização da tarefa criada") {
                shownPage.isOpenWithUnknownTaskId() shouldBe true
            }

            Then("deve exibir o título da tarefa criada") {
                shownPage.getTaskTitle() shouldBe TITLE
            }

            Then("deve exibir a descrição da tarefa criada") {
                shownPage.getTaskDescription() shouldBe DESCRIPTION
            }

            Then("não deve exibir o nome do responsável (assignee)") {
                lifecycleFields.isAssigneeNameVisible() shouldBe false
            }

            Then("deve exibir o nome do criador da tarefa") {
                lifecycleFields.getCreatorName() shouldBe JohnDoe.NAME
            }

            Then("deve exibir o nome do modificador da tarefa") {
                lifecycleFields.getModifierName() shouldBe JohnDoe.NAME
            }

            Then("não deve exibir os campos de ciclo de vida de uma tarefa fechada") {
                lifecycleFields.areClosedTaskFieldsVisible() shouldBe false
            }
        }
    }
})

Agora sabemos escrever testes End-to-End para funcionalidades que submetem um formulário. A seguir, veremos como devemos estruturar nossas classes de especificação.

Estruturando Nossas Classes de Especificação

É tecnicamente possível escrever um único cenário longo que especifique tanto o comportamento do sistema quando a validação falha quanto quando a validação é bem-sucedida. Dito isso, devemos responder a duas perguntas antes de decidir usar essa abordagem:

  • Nosso cenário fica longo demais? Como teríamos que encadear múltiplos passos de erro e sucesso no navegador, o cenário pode ficar extenso e difícil de ler.
  • É trabalhoso demais fornecer um estado inicial limpo para cada caso? Em funcionalidades complexas, preparar o estado da tela entre uma submissão inválida e uma válida pode tornar o teste frágil.

Quando refletimos sobre essas duas questões, fica claro que na maioria das vezes devemos estruturar nossas classes de especificação seguindo estas regras:

  • Devemos criar uma classe de especificação que contenha os cenários que especificam o comportamento do sistema sob especificação quando a validação falha.
  • Devemos criar uma classe de especificação que contenha os cenários que especificam o comportamento do sistema sob especificação quando a validação é bem-sucedida.
  • Devemos escrever um cenário por papel de usuário (user role). Isso ajuda a minimizar o número de classes de especificação e produz relatórios HTML limpos e lógicos.

Nota sobre Estratégia de Testes:

Não precisamos necessariamente criar uma classe de especificação End-to-End para testar falhas de validação de formulário! Essa decisão depende da nossa estratégia de testes: se já escrevemos testes unitários e testes de integração (MockMvc) que garantem que o controlador e o Bean Validation funcionam conforme o esperado quando a validação falha, escrever testes End-to-End no navegador para esse mesmo cenário geralmente não compensa o custo de manutenção e tempo de execução.

Vamos resumir o que aprendemos nesta aula.

Resumo

Esta aula nos ensinou três coisas:

  • Caso decidamos testar falhas de validação no nível E2E, devemos criar uma classe de especificação separada para quando a validação falha.
  • Devemos criar uma classe de especificação dedicada que especifique o comportamento do sistema sob especificação quando a validação é bem-sucedida (CreateTaskSpec).
  • Devemos escrever um cenário por papel de usuário.