Resetando Sequences e IDs no PostgreSQL

Ao concluir esta lição, você será capaz de:

  • Compreender por que precisamos resetar os valores atribuídos às colunas id (sequences e colunas identity) das tabelas do nosso banco de dados.
  • Saber como criar um componente reutilizável (IdColumnReset) que reinicia os valores atribuídos a colunas de chave primária auto-incrementadas.
  • Resetar os valores dessas colunas de forma determinística no método de setup (@BeforeEach) antes da execução de cada método de teste.

Esta lição assume que:


1. Por Que se Preocupar com Isso?

Um dos problemas fundamentais em testes automatizados é que não podemos fazer asserções sobre o desconhecido. Com muita frequência nos deparamos com a seguinte situação:

Queremos escrever múltiplos testes de integração para um método que insere novas informações no banco de dados, pois seguimos a boa prática de escrever testes granulares onde cada método valida um único conceito lógico.

Se tentarmos escrever asserções para os valores de colunas id que são gerados automaticamente pelo banco de dados (via IDENTITY, SERIAL ou SEQUENCE), notaremos que nossos testes falham de maneira intermitente ou dependente da ordem de execução, pois o JUnit não garante a ordem em que os métodos de teste são invocados — e a cada inserção o contador interno do banco avança (1, 2, 3…).

Nota:

Embora exista a anotação @TestMethodOrder no JUnit Jupiter para forçar a ordem de execução dos métodos de teste, não devemos utilizá-la para contornar esse problema, pois testes bem projetados devem ser completamente independentes e jamais depender do efeito colateral de outros testes.

Além disso, vale entender que fazer asserções na coluna id pode não ser estritamente necessário se estivermos testando uma funcionalidade que insere dados em uma única tabela isolada. Contudo, se a funcionalidade insere dados em múltiplas tabelas relacionadas, é indispensável verificar que as “linhas filhas” (ex: tasks_tags) foram vinculadas à “linha pai” correta (task_id = 1).

Se estivéssemos usando apenas um banco de dados em memória (como H2), poderíamos “resolver” esse problema seguindo estes dois passos:

  1. Anotar nossa classe de teste com @DirtiesContext.
  2. Configurar o atributo classMode da anotação @DirtiesContext para DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD.

Leitura Adicional:

Isso resolveria o sintoma em bancos em memória porque a aplicação recria o banco em memória toda vez que o ApplicationContext é carregado, e @DirtiesContext força a destruição e recriação do contexto do Spring após cada método de teste. Essa técnica só é aceitável se a aplicação tiver meia dúzia de testes de integração rodando em memória.

Dito isso, na imensa maioria das vezes não devemos usar @DirtiesContext porque:

  1. Ela causa uma degradação catastrófica na performance da suíte de testes de integração (recarregar o Hibernate e o Spring Boot a cada teste custa vários segundos).
  2. Ela não funciona quando executamos nossos testes de integração contra um banco de dados real (como PostgreSQL via Testcontainers), pois recriar o ApplicationContext não apaga nem reinicia as sequences do container PostgreSQL em execução.

Em vez disso, devemos criar uma forma rápida e direta de resetar os valores gerados automaticamente antes da execução de cada método de teste. Vamos construir um componente que faz exatamente isso.


2. Criando o Nosso Componente (IdColumnReset)

Vamos criar um componente que cumpra os seguintes requisitos:

  • O próximo valor atribuído a uma coluna de chave primária auto-incrementada (ou sequence) deve ser 1.
  • O nome da coluna de chave primária padrão é id.
  • Os nomes das tabelas cujas colunas id serão resetadas são informados pelo chamador do componente.

Nota: Embora esta lição enfatize colunas auto-incrementadas (IDENTITY), exatamente a mesma técnica se aplica ao reset de SEQUENCEs no PostgreSQL (ALTER SEQUENCE <table_name>_id_seq RESTART WITH 1 ou ALTER TABLE <table_name> ALTER COLUMN id RESTART WITH 1 para colunas GENERATED BY DEFAULT AS IDENTITY).

Podemos construir esse componente passo a passo:

Primeiro, criamos uma classe chamada IdColumnReset:

1
2
3
package com.testwithspring.intermediate

class IdColumnReset

Segundo, declaramos um template de instrução SQL que garante que o próximo valor atribuído à coluna id da tabela especificada seja 1.

Após adicionarmos a constante QUERY_TEMPLATE, o código-fonte da classe IdColumnReset ficará assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.testwithspring.intermediate

class IdColumnReset {

    companion object {
        // Compatível com PostgreSQL (colunas IDENTITY) e H2:
        private const val QUERY_TEMPLATE =
            "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"
        
        // Caso utilize SERIAL / SEQUENCE clássica no PostgreSQL:
        // private const val QUERY_TEMPLATE = "ALTER SEQUENCE %s_id_seq RESTART WITH 1"
    }
}

Suporte a Múltiplos Bancos de Dados:

Se você precisar suportar múltiplos bancos de dados com sintaxes SQL distintas na mesma suíte, pode extrair o template SQL para o arquivo application-integrationTest.yml e injetá-lo no construtor do IdColumnReset via @Value("\${test.reset.sql.template}").

Terceiro, adicionamos uma propriedade NamedParameterJdbcTemplate na classe IdColumnReset e implementamos o construtor para injeção de dependência:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package com.testwithspring.intermediate

import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate

class IdColumnReset(
    private val jdbcTemplate: NamedParameterJdbcTemplate
) {

    companion object {
        private const val QUERY_TEMPLATE =
            "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"
    }
}

Quarto, implementamos o método que reseta os valores atribuídos às colunas id das tabelas informadas. Esse método se chama resetIdColumns() e recebe os nomes das tabelas como vararg tableNames: String:

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

import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate

class IdColumnReset(
    private val jdbcTemplate: NamedParameterJdbcTemplate
) {

    companion object {
        private const val QUERY_TEMPLATE =
            "ALTER TABLE %s ALTER COLUMN id RESTART WITH 1"
    }

    fun resetIdColumns(vararg tableNames: String) {
        tableNames.forEach { tableName ->
            val query = String.format(QUERY_TEMPLATE, tableName)
            jdbcTemplate.update(query, emptyMap<String, Any>())
        }
    }
}

Agora temos um componente capaz de reiniciar o contador id das tabelas especificadas em menos de 1 milissegundo. No entanto, antes de podermos injetá-lo nos nossos testes de integração, precisamos registrá-lo na configuração de contexto dos testes.


3. Configurando o Application Context dos Testes de Integração

Antes de usar o nosso novo componente, precisamos declarar um bean IdColumnReset na classe de configuração dos nossos testes de integração (IntegrationTestContext / @TestConfiguration).

Após adicionarmos esse bean, o código-fonte da nossa classe de configuração ficará 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
package com.testwithspring.intermediate

import com.github.springtestdbunit.bean.DatabaseConfigBean
import com.github.springtestdbunit.bean.DatabaseDataSourceConnectionFactoryBean
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import javax.sql.DataSource

@TestConfiguration
class IntegrationTestContext {

    @Bean
    fun databaseConfigBean(): DatabaseConfigBean {
        return DatabaseConfigBean().apply {
            allowEmptyFields = true
        }
    }

    @Bean
    fun dbUnitDatabaseConnection(
        dataSource: DataSource,
        databaseConfigBean: DatabaseConfigBean
    ): DatabaseDataSourceConnectionFactoryBean {
        return DatabaseDataSourceConnectionFactoryBean().apply {
            setDataSource(dataSource)
            setDatabaseConfig(databaseConfigBean)
        }
    }

    @Bean
    fun idColumnReset(
        jdbcTemplate: NamedParameterJdbcTemplate
    ): IdColumnReset {
        return IdColumnReset(jdbcTemplate)
    }
}

Nota: No Spring Boot 4.1, o starter spring-boot-starter-jdbc ou spring-boot-starter-data-jpa já auto-configura automaticamente o bean NamedParameterJdbcTemplate. Caso sua aplicação não use auto-configuração JDBC do Spring Boot, basta declarar o @Bean fun namedParameterJdbcTemplate(dataSource: DataSource) = NamedParameterJdbcTemplate(dataSource) na própria classe IntegrationTestContext.

Com o contexto configurado, podemos finalmente utilizar o IdColumnReset nos testes de integração que inserem novos registros no banco.


4. Resetando os Valores Gerados da Coluna ID nos Testes

Podemos resetar o valor gerado para a coluna id da tabela tasks seguindo estes dois passos:

Primeiro, injetamos o bean IdColumnReset na nossa classe de teste de integração via @Autowired.

Após adicionarmos esse campo, nossa classe de teste ficará 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
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IdColumnReset
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/empty-database.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ProcessCreateNewTaskFormWhenValidationIsSuccessfulTest {

    @Autowired
    private lateinit var idColumnReset: IdColumnReset

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .build()
    }
}

Segundo, precisamos resetar o valor gerado para a coluna id da tabela tasks antes da execução de cada método de teste. Fazemos isso invocando idColumnReset.resetIdColumns("tasks") dentro do método anotado com @BeforeEach.

Após adicionarmos a chamada de reset no método de setup, o código completo da configuração da nossa classe de teste ficará 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
package com.testwithspring.intermediate.web

import com.github.springtestdbunit.DbUnitTestExecutionListener
import com.github.springtestdbunit.annotation.DatabaseSetup
import com.github.springtestdbunit.annotation.DbUnitConfiguration
import com.testwithspring.intermediate.IdColumnReset
import com.testwithspring.intermediate.IntegrationTest
import com.testwithspring.intermediate.IntegrationTestContext
import com.testwithspring.intermediate.ReplacementDataSetLoader
import com.testwithspring.intermediate.config.Profiles
import org.junit.jupiter.api.BeforeEach
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.TestExecutionListeners
import org.springframework.test.context.support.DependencyInjectionTestExecutionListener
import org.springframework.test.context.transaction.TransactionalTestExecutionListener
import org.springframework.test.context.web.ServletTestExecutionListener
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.web.context.WebApplicationContext

@SpringBootTest
@AutoConfigureMockMvc
@Import(IntegrationTestContext::class)
@TestExecutionListeners(
    listeners = [
        DependencyInjectionTestExecutionListener::class,
        TransactionalTestExecutionListener::class,
        DbUnitTestExecutionListener::class,
        ServletTestExecutionListener::class
    ]
)
@DatabaseSetup("/com/testwithspring/intermediate/empty-database.xml")
@DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
@IntegrationTest
@ActiveProfiles(Profiles.INTEGRATION_TEST)
class ProcessCreateNewTaskFormWhenValidationIsSuccessfulTest {

    @Autowired
    private lateinit var idColumnReset: IdColumnReset

    @Autowired
    private lateinit var webAppContext: WebApplicationContext

    private lateinit var mockMvc: MockMvc

    @BeforeEach
    fun configureSystemUnderTest() {
        mockMvc = MockMvcBuilders.webAppContextSetup(webAppContext)
            .build()

        idColumnReset.resetIdColumns("tasks")
    }
}

Agora podemos escrever asserções determinísticas para o id da tarefa recém-criada (por exemplo, verificando o redirecionamento para /task/1 ou validando <tasks id="1" .../> no @ExpectedDatabase), pois ele será sempre 1, independentemente da ordem em que os testes forem executados!

Vamos resumir o que aprendemos nesta lição.


Resumo

Esta lição nos ensinou quatro pontos fundamentais:

  • Não devemos usar a anotação @DirtiesContext se nossa aplicação tiver muitos testes de integração e/ou se executarmos nossos testes contra um banco de dados real (como PostgreSQL via Testcontainers).
  • Se precisarmos suportar múltiplos bancos de dados com dialetos diferentes, devemos externalizar o template da instrução SQL para um arquivo de propriedades.
  • Podemos usar exatamente a mesma técnica do IdColumnReset tanto para colunas auto-incrementadas (IDENTITY) quanto para SEQUENCEs de banco de dados.
  • Devemos sempre invocar o reset das colunas de ID geradas dentro do método de setup (@BeforeEach) antes de cada método de teste.