Teste de integração com Testcontainers no Spring Boot 3

1. Visão geral

Testes de integração executando em H2 frequentemente passam na sua máquina, mas ainda falham em produção rodando PostgreSQL. O motivo é que a sintaxe SQL, tipos de dados e comportamento de transação dos dois bancos de dados são diferentes. Testcontainers resolve este problema ao iniciar um banco de dados real em um container Docker quando os testes são executados.

Neste artigo, você configurará Testcontainers para uma aplicação Spring Boot 3.x e usará @ServiceConnection para conectar o container ao Spring. Em seguida, você compartilhará um único container entre várias classes de teste e aprenderá sobre os erros mais comuns.

2. Preparação

2.1. Dependências

Você precisa do Docker rodando na sua máquina, ou um daemon Docker compatível. Em seguida, adicione as seguintes dependências ao seu pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

O parent do Spring Boot já gerencia a versão do Testcontainers, então você não precisa declarar <version>. O artefato spring-boot-testcontainers está disponível desde Spring Boot 3.1 e fornece a anotação @ServiceConnection.

2.2. Código-fonte para teste

Usaremos uma entidade Order simples:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

O Repository tem uma consulta nativa usando ILIKE do PostgreSQL:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

Consultas nativas são o tipo de código que funciona bem em H2 mas quebra em Postgres, ou vice-versa. Este é exatamente o lugar onde você deve testar em um banco de dados real.

3. Teste de repository com @ServiceConnection

3.1. Declarando o container

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

Cada componente funciona assim:

  • @Testcontainers ativa a extensão JUnit 5. Esta extensão procura por campos com @Container e gerencia o ciclo de vida deles.
  • @Container em um campo static: o container inicia uma única vez antes de todos os testes da classe e para após o último teste. Se o campo não for static, o container será recriado para cada método de teste.
  • @ServiceConnection: o Spring Boot lê o host, porta aleatória, nome de usuário e senha do container, e depois cria um bean JdbcConnectionDetails. Com isso, você não precisa configurar manualmente spring.datasource.url.
  • @AutoConfigureTestDatabase(replace = NONE): por padrão, @DataJpaTest pode substituir a DataSource por um banco de dados embarcado. Desde Spring Boot 3.4, o framework reconhece automaticamente quando a DataSource vem de @ServiceConnection. Ainda assim, declarar explicitamente ajuda o código a funcionar corretamente em qualquer versão 3.x.
  • ddl-auto=create-drop: com um banco de dados não embarcado, o Spring Boot por padrão não cria o schema automaticamente. Em um projeto real, você deveria usar Flyway ou Liquibase para garantir que os testes executem corretamente com as migrações de produção.

Quando o teste é executado, o log terá algo como:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

A primeira execução será mais lenta porque o Docker precisa fazer pull da imagem. As execuções seguintes normalmente levam apenas alguns segundos.

3.2. A forma antiga: @DynamicPropertySource

Antes do Spring Boot 3.1, ou com containers que ainda não têm suporte para @ServiceConnection, você empurrava as propriedades manualmente:

@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

Esta abordagem ainda funciona. No entanto, com PostgreSQL, MySQL, Redis, Kafka, MongoDB e muitos outros serviços populares, @ServiceConnection é mais conciso e menos propenso a erros de digitação nos nomes das propriedades.

4. Teste da aplicação completa com @SpringBootTest

Um teste de repository carrega apenas a camada JPA. Para verificar o fluxo HTTP → serviço → banco de dados, você precisa carregar o contexto completo. Suponha que você tenha o seguinte controller:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. Compartilhando container com @TestConfiguration

Em vez de declarar o container em cada classe de teste, você o coloca como um bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

Aqui o container é gerenciado pelo Spring e não pelo JUnit. O Spring inicia o container antes de criar a DataSource e o para quando o contexto é fechado. O TestContext do Spring faz cache do contexto entre classes de teste que compartilham a mesma configuração, então essas classes usam um único container.

4.2. Escrevendo o teste

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void cleanDatabase() {
        orderRepository.deleteAll();
    }

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

Alguns pontos a observar:

  • RANDOM_PORT inicia um servidor real. TestRestTemplate aponta automaticamente para essa porta.
  • Este teste não tem @Transactional, porque a requisição HTTP executa em uma thread diferente da thread do teste. Os dados são de fato commitados no Postgres, então você precisa limpá-los em @BeforeEach.
  • isEqualByComparingTo compara BigDecimal por valor. Isso evita o erro de 250000 ser diferente de 250000.00 devido a uma escala diferente.
  • A classe Order precisa ser desserializada por Jackson. Se você encontrar erros de desserialização, verifique o construtor padrão e os getters, ou use um record DTO separado para a resposta.

4.3. Reutilizando a configuração ao executar a aplicação localmente

TestcontainersConfig tem outro uso pouco conhecido: executar a aplicação localmente sem precisar instalar o Postgres. Crie a seguinte classe em src/test/java:

public class TestOrderApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrderApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}

Em seguida, execute o comando:

./mvnw spring-boot:test-run

A aplicação inicia junto com um container Postgres. O container para quando você desliga a aplicação.

5. Erros comuns

5.1. Static @Container em múltiplas classes usando contexto compartilhado

Este é o erro mais irritante. Suponha que você tenha uma classe base abstrata contendo @Container static junto com @ServiceConnection, e múltiplas classes de teste a herdam:

OrderControllerIT  -> container inicia na porta 55001, contexto é cacheado
CustomerControllerIT -> JUnit para o container antigo, inicia um novo na porta 55017
                    -> Spring reutiliza o contexto em cache, ainda apontando para 55001
                    -> Connection refused / HikariPool timeout

O JUnit para o container após cada classe, mas o Spring mantém o contexto antigo em cache. Você tem dois caminhos para corrigir:

  1. Use um bean container como na seção 4.1. Neste caso, o ciclo de vida do container está vinculado ao contexto. Esta é a abordagem recomendada.
  2. Use um singleton container: remova @Container e @Testcontainers, depois inicie manualmente em um bloco static. Ryuk limpará o container quando a JVM terminar.
public abstract class AbstractIntegrationTest {

    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }
}

5.2. Usar a tag latest

Nota: Sempre fixe a versão da imagem (postgres:16-alpine) e idealmente corresponda à versão em produção. A tag latest pode fazer os testes mudarem de comportamento inesperadamente quando uma nova versão da imagem é lançada.

5.3. Vazamento de dados entre testes

@DataJpaTest faz rollback automaticamente após cada teste, mas @SpringBootTest com RANDOM_PORT não faz. Ao compartilhar um container, os dados da classe anterior ainda estão no banco. Limpe os dados em @BeforeEach, ou use @Sql para executar um script TRUNCATE. Não escreva testes que dependem da ordem de execução.

5.4. Ambiente CI sem Docker

Se Docker não estiver rodando, o teste reportará um erro:

java.lang.IllegalStateException: Could not find a valid Docker environment.

Você precisa garantir que o runner de CI tenha Docker. Se quiser ignorar os testes quando Docker não estiver disponível em vez de falhar, use @Testcontainers(disabledWithoutDocker = true). Considere cuidadosamente antes de ativar essa opção, pois testes ignorados silenciosamente são fáceis de esquecer.

5.5. Acelerar localmente com reuse

Para manter o container vivo entre execuções de testes no seu computador local, ative o reuse no arquivo ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Em seguida, chame .withReuse(true) ao criar o container. Não ative essa opção no CI, pois o container não será limpado automaticamente.

6. Quando não usar Testcontainers

Testcontainers tornam os testes mais confiáveis, mas cada teste será mais lento e exigirá Docker. Alguns casos onde você não deveria usar:

  • Lógica pura como cálculo de preço, validação ou mapeamento: escrever testes unitários com mocks ou objetos reais é suficiente. Testes unitários são centenas de vezes mais rápidos.
  • Cada classe de teste com uma configuração de contexto diferente: cada configuração cria um novo contexto. Se o container estiver vinculado ao contexto, você terá muitos containers rodando em paralelo. Agrupe os testes em poucas configurações comuns.
  • Serviços de terceiros via HTTP: geralmente é melhor usar WireMock em vez de tentar executar o serviço real em um container.

Uma proporção razoável é: a maioria dos testes devem ser unitários, enquanto testes de integração com Testcontainers devem focar nos lugares que tocam a infraestrutura, como repositories, consultas nativas, migrações e message brokers.

7. Conclusão

Neste artigo, você usou Testcontainers para executar testes de integração em um PostgreSQL real. @ServiceConnection elimina completamente a necessidade de configuração manual da datasource. Colocar o container como um bean em @TestConfiguration permite compartilhar um único container entre múltiplas classes de teste e executar a aplicação localmente usando spring-boot:test-run.

Pontos-chave a lembrar:

  • Fixe a versão da imagem para corresponder à produção.
  • Não misture static @Container com cache de contexto do Spring.
  • Limpe os dados manualmente quando os testes não executam dentro de uma transação.
  • Use Testcontainers apenas nos lugares onde você realmente precisa de um banco de dados real.
spring-boottestcontainersintegration-testjunit5postgresql