Depois de concluirmos esta aula, nós:
- Saberemos como inicializar nosso banco de dados relacional em um estado conhecido antes da execução das nossas especificações do Kotest 6 usando
@Sql e ResourceDatabasePopulator / JdbcClient.
- Saberemos como escrever asserções limpas e precisas para validar os dados salvos no banco de dados após a execução de operações de escrita.
1. Inicializando Nosso Banco de Dados em um Estado Conhecido
Como vimos no Capítulo 02, inicializamos nosso banco de dados PostgreSQL (provisionado via Testcontainers) em um estado determinístico executando scripts SQL antes dos nossos testes de integração.
Quando escrevemos especificações de integração com o Kotest 6 e a SpringExtension, temos duas formas complementares de configurar os scripts SQL que preparam o banco de dados:
Opção 1: Mesmo Dataset SQL para Toda a Classe de Especificação (@Sql na Classe)
Se todos os cenários da nossa classe de especificação compartilharem exatamente o mesmo conjunto inicial de dados no banco (por exemplo, carregar os usuários cadastrados em users.sql), basta anotar a própria classe de especificação com @Sql:
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
|
package com.testwithspring.master.web
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.config.Profiles
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
@SpringBootTest(classes = [IntegrationTestContext::class])
@AutoConfigureMockMvc
@Tags("IntegrationTest")
@Sql("/com/testwithspring/master/users.sql")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class SharedDatasetExampleSpec(
private val mockMvc: MockMvc
) : BehaviorSpec({
extensions(SpringExtension)
Given("Feature scenario one") {
// O script users.sql é executado antes do teste
}
Given("Feature scenario two") {
// O script users.sql é executado novamente garantindo estado limpo
}
})
|
[!NOTE]
Por padrão, a SpringExtension do Kotest 6 aciona os TestExecutionListeners do Spring (incluindo o SqlScriptsTestExecutionListener e o TransactionalTestExecutionListener) no modo SpringTestLifecycleMode.Test (ou Root), garantindo que o script @Sql declarado na classe seja executado antes de cada teste raiz!
Opção 2: Datasets SQL Diferentes por Cenário (ResourceDatabasePopulator no Given / beforeContainer)
Diferentemente do JUnit ou do Spock (onde cada teste é um método Java/Groovy anotável), no Kotest nossos cenários (Given, When, Then, should) são blocos de código (lambdas) e em Kotlin não podemos colocar a anotação @Sql (que tem @Target(CLASS, FUNCTION)) diretamente sobre uma lambda se precisarmos de scripts diferentes para dois blocos Given dentro da mesma classe!
Quando dois cenários dentro da mesma especificação precisam de scripts SQL diferentes (por exemplo, um cenário com banco vazio no-tasks-and-tags.sql e outro cenário com tarefas existentes tasks.sql), temos duas opções idiomáticas:
- Separar em classes de especificação focadas (uma especificação por estado inicial do banco anotada com
@Sql), ou
- Criar uma função auxiliar
executeSql("/caminho/script.sql") na especificação usando ResourceDatabasePopulator + DataSource do Spring:
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.IntegrationTest
import com.testwithspring.master.config.Profiles
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.core.io.ClassPathResource
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.web.servlet.MockMvc
import javax.sql.DataSource
@SpringBootTest(classes = [IntegrationTestContext::class])
@AutoConfigureMockMvc
@Tags("IntegrationTest")
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class PerScenarioDatasetExampleSpec(
private val mockMvc: MockMvc,
private val dataSource: DataSource
) : BehaviorSpec({
extensions(SpringExtension)
fun executeSql(vararg scripts: String) {
val populator = ResourceDatabasePopulator(
*scripts.map { ClassPathResource(it) }.toTypedArray()
)
populator.execute(dataSource)
}
Given("Scenario one - when no tasks exist in the database") {
executeSql(
"/com/testwithspring/master/users.sql",
"/com/testwithspring/master/no-tasks-and-tags.sql"
)
// ...
}
Given("Scenario two - when two tasks exist in the database") {
executeSql(
"/com/testwithspring/master/users.sql",
"/com/testwithspring/master/tasks.sql"
)
// ...
}
})
|
2. Escrevendo Asserções para os Dados Encontrados no Banco de Dados
No curso original com DbUnit XML, validávamos o estado final do banco de dados anotando o método de teste com @ExpectedDatabase("create-task-as-user-expected.xml"), que comparava todas as colunas da tabela tasks com um arquivo XML estático.
Na stack moderna com Spring Boot 4, PostgreSQL e Kotest 6, substituir arquivos XML de comparação por consultas diretas com JdbcClient (ou JdbcTemplate) combinadas com asserções do Kotest (assertSoftly e shouldBe) traz enormes vantagens:
- Foco nas colunas relevantes: validamos explicitamente
assignee_id, closer_id, creator_id, description, modifier_id, resolution, title, status e version sem quebrar o teste por causa de milissegundos em creation_time ou modification_time (ou validamos que creation_time não é nulo).
- Erros claros na IDE: se o
status salvo for diferente de "OPEN", o Kotest aponta a linha e a propriedade exata que falhou.
Veja como validamos que a criação de uma tarefa persistiu exatamente os dados esperados na tabela tasks e manteve a tabela tags vazia:
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
|
package com.testwithspring.master.web
import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.config.Profiles
import io.kotest.assertions.assertSoftly
import io.kotest.core.annotation.Tags
import io.kotest.core.spec.style.BehaviorSpec
import io.kotest.extensions.spring.SpringExtension
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.jdbc.core.simple.JdbcClient
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
@SpringBootTest(classes = [IntegrationTestContext::class])
@AutoConfigureMockMvc
@Tags("IntegrationTest")
@Sql(
"/com/testwithspring/master/users.sql",
"/com/testwithspring/master/no-tasks-and-tags.sql"
)
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class CreateTaskDatabaseAssertionSpec(
private val mockMvc: MockMvc,
private val jdbcClient: JdbcClient
) : BehaviorSpec({
extensions(SpringExtension)
Given("Create task as a registered user") {
When("The user submits the new task") {
// A chamada mockMvc.perform(...) será detalhada nas Aulas 05 e 07 deste tópico
Then("Should save the task row in the database with the expected column values") {
val row = jdbcClient.sql("SELECT * FROM tasks WHERE id = 1")
.query()
.singleRow()
assertSoftly(row) {
this["id"] shouldBe 1L
this["assignee_id"] shouldBe null
this["closer_id"] shouldBe null
this["creation_time"].shouldNotBeNull()
this["creator_id"] shouldBe 1L
this["description"] shouldBe "This example contains integration tests"
this["modification_time"].shouldNotBeNull()
this["modifier_id"] shouldBe 1L
this["resolution"] shouldBe null
this["title"] shouldBe "Write example application"
this["status"] shouldBe "OPEN"
this["version"] shouldBe 0L
}
val tagCount = jdbcClient.sql("SELECT COUNT(*) FROM tags")
.query(Long::class.java)
.single()
tagCount shouldBe 0L
}
}
}
})
|
Uma Única Especificação vs. Múltiplos Blocos de Verificação
Quando escrevemos asserções para a resposta HTTP e para o estado do banco de dados no Kotest, temos um fluxo muito natural graças aos blocos Then e And:
- No bloco
Then e nos primeiros blocos And, verificamos a resposta HTTP retornada pelo sistema sob especificação (status HTTP, view ou JSON).
- No último bloco
And("Should save the task in the database"), validamos o estado persistido nas tabelas do PostgreSQL!
Isso mantém toda a documentação do caso de uso coesa em um único fluxo Given -> When -> Then / And.
Resumo
Nesta aula, aprendemos três pontos essenciais:
- Podemos inicializar o banco de dados para toda uma classe de especificação usando a anotação
@Sql na classe, ou por cenário usando ResourceDatabasePopulator.
- Utilizamos
JdbcClient combinado com assertSoftly do Kotest para validar o estado das tabelas após operações de escrita.
- Estruturamos cada cenário de mutação verificando primeiro a resposta retornada pelo sistema sob especificação e, em seguida, o estado final do banco de dados em um bloco
And dedicado.