Testes de Repositório com @DataJpaTest e Postgres Real

Depois de concluirmos este webinar, nós:

  • Estaremos familiarizados com as anotações de Test Slices de persistência fornecidas pelo Spring Boot 4 (@DataJpaTest, @JdbcTest e @JooqTest).
  • Saberemos escrever testes de integração para repositórios gerenciados pelo Spring com JUnit 6 e PostgreSQL real (ou banco em memória).

Este webinar assume que:

  • Você está familiarizado com a inicialização de estado de banco de dados via @Sql e Testcontainers.
  • Você sabe como configurar seus testes de integração.
  • Você sabe escrever testes de integração para repositórios gerenciados pelo Spring.
  • Você está familiarizado com o JUnit 6.
  • Você sabe estruturar um projeto que executa testes unitários, de integração e End-to-End usando @Tag.

A Configuração Comum Compartilhada Pelos Nossos Testes de Integração

Durante este webinar, escreveremos testes de integração para repositórios que utilizam Spring Data JPA, Spring JDBC (JdbcClient / JdbcTemplate) e jOOQ. Obviamente, cada classe de teste de integração utiliza uma configuração ligeiramente diferente. Contudo, todas as classes de teste de integração também compartilham uma configuração comum. Podemos criar essa configuração seguindo estes passos:

  1. Marcar a classe de teste como uma classe de teste de integração (@IntegrationTest).
  2. Configurar a conexão com o banco de dados PostgreSQL via @Import(PostgresTestcontainersConfiguration::class) (ou manter o datasource configurado com @AutoConfigureTestDatabase(replace = Replace.NONE)).
  3. Inicializar nosso banco de dados em um estado conhecido antes que um teste de integração seja executado usando @Sql.

Após termos criado a configuração comum, o código-fonte base da nossa classe de teste de integração fica assim:

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

import com.testwithspring.master.IntegrationTest
import org.junit.jupiter.api.DisplayName
import org.springframework.test.context.jdbc.Sql

@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ]
)
@IntegrationTest
@DisplayName("Demonstra a configuração compartilhada pelos nossos testes")
class ExampleTest {

    // Injete o repositório testado aqui
}

A anotação @IntegrationTest é uma meta-anotação customizada que nos permite marcar uma classe de teste como um teste de integração. Isso significa que todos os métodos de teste dessa classe são executados quando rodamos nossa suíte de integração. Esta anotação configura o perfil ativo do Spring e garante que todos os métodos de teste pertençam à tag correta do JUnit 6:

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

import com.testwithspring.master.config.Profiles
import org.junit.jupiter.api.Tag
import org.springframework.test.context.ActiveProfiles

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@ActiveProfiles(Profiles.INTEGRATION_TEST)
@Tag(TestTags.INTEGRATION_TEST)
annotation class IntegrationTest

A seguir, descobriremos como podemos escrever testes de integração para um repositório que utiliza Spring Data JPA.

Escrevendo Testes de Integração para um Repositório Spring Data JPA

Nossa aplicação de exemplo possui um repositório Spring Data JPA chamado TaskRepository, e ele possui um método findAll() que retorna o id, status e title de todas as tarefas encontradas no banco de dados.

Podemos finalizar a configuração da nossa classe de teste para JPA seguindo estes passos:

  1. Anotar a classe de teste com @DataJpaTest. No JUnit 6 e Spring Boot 4, @DataJpaTest já inclui @ExtendWith(SpringExtension::class) como meta-anotação, garantindo que o Spring TestContext carregue apenas os componentes relevantes para testes JPA (@Entity e repositórios Spring Data JPA).
  2. Injetar o repositório TaskRepository via construtor e escrever os três testes para o método findAll().

Após termos configurado nossos testes de integração, o código-fonte da nossa classe de teste 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
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.Tasks
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.test.context.jdbc.Sql

@DataJpaTest
@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ]
)
@IntegrationTest
@DisplayName("Buscar todas as tarefas do banco de dados quando duas tarefas são encontradas (JPA)")
class FindAllTasksWhenTwoTasksAreFoundJpaTest(
    @Autowired private val repository: TaskRepository
) {

    @Test
    @DisplayName("Deve retornar duas tarefas")
    fun shouldReturnTwoTasks() {
        val tasks = repository.findAll()
        assertThat(tasks).hasSize(2)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da primeira tarefa")
    fun shouldReturnCorrectInformationOfFirstTask() {
        val first = repository.findAll()[0]

        assertThat(first.id).isEqualByComparingTo(Tasks.WriteExampleApp.ID)
        assertThat(first.status).isEqualTo(Tasks.WriteExampleApp.STATUS)
        assertThat(first.title).isEqualTo(Tasks.WriteExampleApp.TITLE)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da segunda tarefa")
    fun shouldReturnCorrectInformationOfSecondTask() {
        val second = repository.findAll()[1]

        assertThat(second.id).isEqualByComparingTo(Tasks.WriteLesson.ID)
        assertThat(second.status).isEqualTo(Tasks.WriteLesson.STATUS)
        assertThat(second.title).isEqualTo(Tasks.WriteLesson.TITLE)
    }
}

Quando executamos nossos testes de integração, vemos que eles passam! A razão para isso é que a anotação @DataJpaTest configura o banco de dados de teste, escaneia as classes @Entity e configura os repositórios Spring Data JPA.

Há quatro pontos importantes que quero destacar antes de continuarmos este webinar:

  • Se você quiser usar o datasource real configurado no seu arquivo de propriedades (ou fornecido por @ServiceConnection do Testcontainers com PostgreSQL real) em vez de um banco de dados em memória, você deve anotar sua classe de teste com @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE).
  • A anotação @DataJpaTest garante que beans @Component, @Service ou @Controller comuns não sejam carregados no ApplicationContext.
  • Por padrão, testes @DataJpaTest são transacionais e fazem rollback ao final de cada método de teste. Se você quiser desabilitar esse comportamento, anote sua classe de teste com @Transactional(propagation = Propagation.NOT_SUPPORTED).
  • Você também pode injetar os beans TestEntityManager e JdbcTemplate (ou JdbcClient) diretamente na sua classe de teste.

Mesmo que nossos testes passem, o problema é que, se tivermos que escrever testes de integração para múltiplos repositórios Spring Data JPA, acabaremos escrevendo muita configuração duplicada. Podemos remover essa duplicação criando uma meta-anotação customizada que agrupa a configuração compartilhada pelos nossos testes JPA:

  1. Criar uma nova anotação @CustomDataJpaTest (ou @PostgresDataJpaTest).
  2. Mover as anotações @DataJpaTest, @AutoConfigureTestDatabase(replace = Replace.NONE), @Import(PostgresTestcontainersConfiguration::class) e @IntegrationTest para a anotação criada.

Após termos criado nossa anotação customizada, seu código-fonte fica assim:

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

import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.context.annotation.Import

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
annotation class CustomDataJpaTest

Nosso último passo é remover a configuração duplicada da nossa classe de teste de integração e anotá-la com @CustomDataJpaTest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@CustomDataJpaTest
@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ]
)
@DisplayName("Buscar todas as tarefas do banco de dados quando duas tarefas são encontradas (JPA)")
class FindAllTasksWhenTwoTasksAreFoundJpaTest(
    @Autowired private val repository: TaskRepository
) {
    // Métodos @Test idênticos...
}

Vamos avançar e descobrir como podemos escrever testes de integração para um repositório que utiliza JDBC.

Escrevendo Testes de Integração para um Repositório JDBC (@JdbcTest)

Nossa aplicação de exemplo possui um repositório que utiliza Spring JDBC (NamedParameterJdbcTemplate / JdbcClient). O nome desta classe de repositório é TaskJdbcRepository, e ela implementa um método findAll() que retorna o id, status e title de todas as tarefas encontradas no banco de dados.

Podemos configurar nosso teste de integração para o repositório JDBC seguindo estes passos:

  1. Anotar a classe de teste com @JdbcTest, garantindo que o Spring TestContext carregue apenas a infraestrutura relevante para JDBC (DataSource, JdbcTemplate, NamedParameterJdbcTemplate, JdbcClient).
  2. Garantir que o bean do repositório testado seja adicionado ao contexto de aplicação carregado usando @Import(TaskJdbcRepository::class) (pois @JdbcTest não escaneia classes @Repository automaticamente).

Após termos configurado nossos testes de integração, o código-fonte da nossa classe de teste 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
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.Tasks
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.jdbc.Sql

@JdbcTest
@Import(TaskJdbcRepository::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ]
)
@IntegrationTest
@DisplayName("Buscar todas as tarefas do banco de dados quando duas tarefas são encontradas (JDBC)")
class FindAllTasksWhenTwoTasksAreFoundJDBCTest(
    @Autowired private val repository: TaskJdbcRepository
) {

    @Test
    @DisplayName("Deve retornar duas tarefas")
    fun shouldReturnTwoTasks() {
        val tasks = repository.findAll()
        assertThat(tasks).hasSize(2)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da primeira tarefa")
    fun shouldReturnCorrectInformationOfFirstTask() {
        val first = repository.findAll()[0]

        assertThat(first.id).isEqualByComparingTo(Tasks.WriteExampleApp.ID)
        assertThat(first.status).isEqualTo(Tasks.WriteExampleApp.STATUS)
        assertThat(first.title).isEqualTo(Tasks.WriteExampleApp.TITLE)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da segunda tarefa")
    fun shouldReturnCorrectInformationOfSecondTask() {
        val second = repository.findAll()[1]

        assertThat(second.id).isEqualByComparingTo(Tasks.WriteLesson.ID)
        assertThat(second.status).isEqualTo(Tasks.WriteLesson.STATUS)
        assertThat(second.title).isEqualTo(Tasks.WriteLesson.TITLE)
    }
}

Há três pontos importantes sobre @JdbcTest:

  • Se você quiser usar o datasource real configurado nas propriedades (ou via Testcontainers), anote a classe com @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE).
  • A anotação @JdbcTest garante que componentes @Component ou @Repository comuns não sejam escaneados automaticamente — por isso importamos explicitamente @Import(TaskJdbcRepository::class).
  • Por padrão, testes @JdbcTest são transacionais e fazem rollback ao final de cada teste.

Novamente, podemos eliminar a configuração duplicada criando uma meta-anotação customizada @CustomJdbcTest:

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

import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest
import org.springframework.context.annotation.Import

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@JdbcTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
annotation class CustomJdbcTest

E simplificamos a classe FindAllTasksWhenTwoTasksAreFoundJDBCTest anotando-a com @CustomJdbcTest e @Import(TaskJdbcRepository::class).

A seguir, descobriremos como podemos escrever testes de integração para um repositório que utiliza jOOQ.

Escrevendo Testes de Integração para um Repositório jOOQ (@JooqTest)

Nossa aplicação de exemplo possui um repositório que utiliza jOOQ chamado TaskJooqRepository, que também implementa o método findAll().

Podemos configurar nosso teste de integração para jOOQ seguindo estes passos:

  1. Anotar a classe de teste com @JooqTest, que configura apenas o DataSource e o bean DSLContext do jOOQ.
  2. Importar a classe do repositório testado com @Import(TaskJooqRepository::class).

Após termos configurado nossos testes de integração, o código-fonte da nossa classe de teste 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
package com.testwithspring.master.task

import com.testwithspring.master.IntegrationTest
import com.testwithspring.master.Tasks
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.jooq.JooqTest
import org.springframework.context.annotation.Import
import org.springframework.test.context.jdbc.Sql

@JooqTest
@Import(TaskJooqRepository::class)
@Sql(
    value = [
        "classpath:/com/testwithspring/master/no-tasks-and-tags.sql",
        "classpath:/com/testwithspring/master/users.sql",
        "classpath:/com/testwithspring/master/tasks.sql"
    ]
)
@IntegrationTest
@DisplayName("Buscar todas as tarefas do banco de dados quando duas tarefas são encontradas (jOOQ)")
class FindAllTasksWhenTwoTasksAreFoundJooqTest(
    @Autowired private val repository: TaskJooqRepository
) {

    @Test
    @DisplayName("Deve retornar duas tarefas")
    fun shouldReturnTwoTasks() {
        val tasks = repository.findAll()
        assertThat(tasks).hasSize(2)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da primeira tarefa")
    fun shouldReturnCorrectInformationOfFirstTask() {
        val first = repository.findAll()[0]

        assertThat(first.id).isEqualByComparingTo(Tasks.WriteExampleApp.ID)
        assertThat(first.status).isEqualTo(Tasks.WriteExampleApp.STATUS)
        assertThat(first.title).isEqualTo(Tasks.WriteExampleApp.TITLE)
    }

    @Test
    @DisplayName("Deve retornar as informações corretas da segunda tarefa")
    fun shouldReturnCorrectInformationOfSecondTask() {
        val second = repository.findAll()[1]

        assertThat(second.id).isEqualByComparingTo(Tasks.WriteLesson.ID)
        assertThat(second.status).isEqualTo(Tasks.WriteLesson.STATUS)
        assertThat(second.title).isEqualTo(Tasks.WriteLesson.TITLE)
    }
}

Há três pontos importantes sobre @JooqTest:

  • Diferente de @DataJpaTest e @JdbcTest, por padrão a anotação @JooqTest não substitui o datasource por um banco em memória: ela utiliza o datasource configurado na sua aplicação (ou fornecido pelo Testcontainers), porque o código gerado pelo jOOQ é específico para o dialeto SQL do banco configurado (como PostgreSQL). Se você quiser substituí-lo por um banco em memória, precisará usar @AutoConfigureTestDatabase.
  • A anotação @JooqTest garante que beans @Component comuns não sejam carregados no ApplicationContext.
  • Por padrão, testes @JooqTest também são transacionais e fazem rollback ao final de cada método de teste.

E também podemos criar a meta-anotação @CustomJooqTest para evitar repetição nas nossas classes de teste jOOQ:

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

import org.springframework.boot.test.autoconfigure.jooq.JooqTest
import org.springframework.context.annotation.Import

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
@JooqTest
@Import(PostgresTestcontainersConfiguration::class)
@IntegrationTest
annotation class CustomJooqTest

Prós e Contras das Anotações de Fatia de Persistência (Test Slices)

Essas três anotações possuem três vantagens:

  • Se usarmos @DataJpaTest ou @JdbcTest, o Spring Boot autoconfigura toda a infraestrutura de persistência para nós sem precisarmos montar uma classe @Configuration manual.
  • Se usarmos @JooqTest (ou @AutoConfigureTestDatabase(replace = NONE) com Testcontainers), podemos rodar nossos testes de integração contra o mesmo banco de dados PostgreSQL usado em produção.
  • Como esses testes carregam apenas os componentes da camada de persistência, a inicialização individual do contexto é mais leve do que carregar a aplicação inteira. Contudo, lembre-se de que o Spring TestContext faz cache dos contextos carregados: se você usar muitos @Import(...) diferentes em dezenas de classes @JdbcTest / @JooqTest, o Spring poderá criar vários pequenos contextos em vez de reutilizar um único contexto compartilhado.

Por outro lado, o comportamento padrão de @DataJpaTest e @JdbcTest de tentar substituir o DataSource por um banco em memória exige atenção: sempre adicione @AutoConfigureTestDatabase(replace = Replace.NONE) (ou use @ServiceConnection do Spring Boot 4 com Testcontainers, que já desativa a substituição por banco em memória automaticamente!) para garantir que seus testes rodem contra o PostgreSQL real.

Resumo

Este webinar nos ensinou cinco coisas:

  • Por padrão, @DataJpaTest e @JdbcTest configuram um banco em memória, a menos que usemos @AutoConfigureTestDatabase(replace = Replace.NONE) ou @ServiceConnection do Testcontainers.
  • Por padrão, @JooqTest utiliza o datasource real configurado na aplicação.
  • Por padrão, os testes anotados com @DataJpaTest, @JdbcTest e @JooqTest são transacionais e fazem rollback ao final de cada método de teste.
  • Essas anotações garantem que beans @Component e @Service de outras camadas não sejam carregados no ApplicationContext.
  • É uma excelente prática remover configurações duplicadas criando meta-anotações customizadas (@CustomDataJpaTest, @CustomJdbcTest, @CustomJooqTest).