Testes de integração com Testcontainers - Criando uma "Extension Customizada"
Continuando a explorar os Testes de Integração com TestContainer e Spring Boot, cheguei num ponto onde foi preciso criar uma Extension
do JUnit personalizada para resolver um problema que estava acontecendo ao executar mais de uma classe de teste.
Bom, esse é o segundo post sobre testes de integração e caso não tenha lido o primeiro, de uma olhada onde explico como iniciei e como cheguei até aqui.
Estou usando o mesmo projeto do post anterior, então caso queira acompanhar e seguir a linha de raciocínio é interessante dar uma lida antes de iniciar esse aqui.
A prática
Dessa vez vamos criar um teste para a atualização de uma conta bancária, onde eu já tenho uma conta cadastrada, e desejo atualizar as informações dessa conta.
O endpoind para este teste é bem simples:
PUT /v1/api/contas-bancarias/1
{
"nome": "Conta Nubank",
"agencia": "9922",
"conta": "4444-1",
"banco": "222",
"gerente": "Assunção",
"observacao": "Observações sobre a conta bancária"
}
É o mesmo payload do primeiro endpoint onde cadastramos uma nova conta, agora o que muda é o método que passou a ser um PUT e o Id no final da url.
Esse Id serve para identificar a conta que queremos atualizar.
Criando o teste de integração
Eu decidi criar uma classe de teste para cada endpoint que irei testar, dito isso, "duplique" o teste ContaBancariaResourceITTest
e renemeie para ContaBancariaResourceAtualizarContaITTest
. Pronto, devemos ter agora duas classes de teste.
Agora remova o caso de teste (o método anotado com @Test
), deixando a classe apenas com o código de configuração. Neste ponto, deve estar dessa forma:
/// IMPORTS
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ContaBancariaResourceAtualizarContaITTest {
@Container
private static final PostgreSQLContainer<?> POSTGRESQL_DB = new PostgreSQLContainer("postgres:14.1")
.withDatabaseName("testcontainers-db")
.withUsername("testcontainers-db")
.withPassword("testcontainers-db");
@DynamicPropertySource
static void propertyConfig(final DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRESQL_DB::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRESQL_DB::getUsername);
registry.add("spring.datasource.password", POSTGRESQL_DB::getPassword);
registry.add("spring.datasource.driverClassName", POSTGRESQL_DB::getDriverClassName);
registry.add("spring.flyway.url", POSTGRESQL_DB::getJdbcUrl);
registry.add("spring.flyway.user", POSTGRESQL_DB::getUsername);
registry.add("spring.flyway.password", POSTGRESQL_DB::getPassword);
}
@LocalServerPort
private Integer portaHttp;
@Autowired
private ContaBancariaRepository contaBancariaRepository;
@BeforeEach
void setUp() {
contaBancariaRepository.deleteAll();
}
}
Perceba que é o mesmo código da classe de teste anteiror. Agora é hora de criarmos o caso de teste. Ele ficou dessa forma:
@Test
@DisplayName("Deve atualizar dados da conta bancária")
void t1() {
/// 1 - ARANGE
final var contaBancariaJaExste = ContaBancaria.builder()
.nome("Conta Nubank")
.agencia("0001")
.conta("4444-1")
.banco("222")
.gerente("Assunção")
.observacao("Observações sobre a conta bancária")
.build();
contaBancariaRepository.saveAndFlush(contaBancariaJaExste);
/// 1 - ARANGE - Crio a URL de Request com o Id da conta ja cadastrada que quero atualizar
final var urlRequest = String.format("http://localhost:%s/v1/api/contas-bancarias/%s", portaHttp, contaBancariaJaExste.getId());
/// 1 - ARANGE - Request com os novos dados
final var contaBancariaComDadosAtualizadosRequest = ContaBancariaRequest.builder()
.nome("Conta Bradesco")
.agencia("2222")
.conta("9999-1")
.banco("382")
.gerente("Maria Pereira")
.observacao("Conta despezas da casa")
.build();
/// 2 - ACTION
final var response = RestAssured
.given()
.header("Content-Type", "application/json")
.and()
.body(Json.toString(contaBancariaComDadosAtualizadosRequest))
.when()
.put(urlRequest)
.then()
.extract()
.response();
/// 3 - ASSERTS - Verifico se realmente atulizou os dados, pois a "response" tem que ser o mesmo que foi enviado na "request"
Assertions.assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value());
Assertions.assertThat(response.jsonPath().getString("nome")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getNome());
Assertions.assertThat(response.jsonPath().getString("agencia")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getAgencia());
Assertions.assertThat(response.jsonPath().getString("conta")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getConta());
Assertions.assertThat(response.jsonPath().getString("gerente")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getGerente());
Assertions.assertThat(response.jsonPath().getString("banco")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getBanco());
Assertions.assertThat(response.jsonPath().getString("observacao")).isEqualTo(contaBancariaComDadosAtualizadosRequest.getObservacao());
/// 3 - ASSERTS - Verifico se tem apenas uma conta, afinal foi uma atualização e não um novo cadastro
Assertions
.assertThat(contaBancariaRepository.findAll())
.hasSize(1);
/// 3 - ASSERTS - Agora verifico se a conta que está no banco os dados sãpo iguais aos novos que pedi para atualizar
contaBancariaRepository
.findById(contaBancariaJaExste.getId())
.ifPresent(resultado -> {
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getNome()).isEqualTo(resultado.getNome());
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getAgencia()).isEqualTo(resultado.getAgencia());
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getConta()).isEqualTo(resultado.getConta());
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getBanco()).isEqualTo(resultado.getBanco());
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getGerente()).isEqualTo(resultado.getGerente());
Assertions.assertThat(contaBancariaComDadosAtualizadosRequest.getObservacao()).isEqualTo(resultado.getObservacao());
});
}
- Na sessão de
ARANGE
, estou criando uma nova conta e inserindo no banco, afinal o banco de dados está zerado. Também estou criando aurlRequest
e aproveitando oId
da conta recém cadastrada. Crio também arequest
com os novos dados, ou seja, os dados que vou atualizar. Nesso exemplo ficou assim:
/// CONTA INICIAL
ContaBancaria.builder()
.nome("Conta Nubank")
.agencia("0001")
.conta("4444-1")
.banco("222")
.gerente("Assunção")
.observacao("Observações sobre a conta bancária")
.build();
/// CONTA COM DADOS ATUALIZADOS
ContaBancariaRequest.builder()
.nome("Conta Bradesco")
.agencia("2222")
.conta("9999-1")
.banco("382")
.gerente("Maria Pereira")
.observacao("Conta despezas da casa")
.build();
-
Na sessão
ACTION
, faço a requisição usando oRestAssured
, mas dessa vez enviando umPUT
. -
Na sessão
ASSERTS
, faço minhas asserções.- Primeiro: Confirmos se a
response
que foi retornada tenha os mesmo dados da qual eu enviei - Segundo: Verifico se tem apenas uma conta, afinal foi uma atualização e não um novo cadastro
- Terceiro: Agora verifico se a conta que está no banco, os dados são iguais aos novos que pedi para atualizar
- Primeiro: Confirmos se a
Ok, rodando todos os testes, os dois irão executar, subuir container, fazer inserts, select, updates e tudo funcionando.
Código duplicado não dá. É preciso refatorar.
Neste ponto, estamos com praticamente duas classes de testes idênticas e todo código de configuração é igual, ou seja duplicado e isso não é bom. Vamos refatorar e criar uma classe que servirá apenas para as configurações, assim podemos extende-la
, deixando nossas classes de teste apenas com o código que é de seu interesse, os casos de teste.
Crie uma nova classe no pacote raiz dos teste, e dê o nome de AppTestContainer
. Essa classe será responsavél por conter todo código de configuração, seu conteúdo ficou assim:
/// IMPORTS
@Testcontainers
public class AppTestContainer {
@Container
protected static final PostgreSQLContainer<?> POSTGRESQL_DB = new PostgreSQLContainer("postgres:14.1")
.withDatabaseName("testcontainers-db")
.withUsername("testcontainers-db")
.withPassword("testcontainers-db");
@DynamicPropertySource
static void propertyConfig(final DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRESQL_DB::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRESQL_DB::getUsername);
registry.add("spring.datasource.password", POSTGRESQL_DB::getPassword);
registry.add("spring.datasource.driverClassName", POSTGRESQL_DB::getDriverClassName);
registry.add("spring.flyway.url", POSTGRESQL_DB::getJdbcUrl);
registry.add("spring.flyway.user", POSTGRESQL_DB::getUsername);
registry.add("spring.flyway.password", POSTGRESQL_DB::getPassword);
}
@BeforeAll
protected static void startContainer() {
POSTGRESQL_DB.start();
}
@AfterAll
protected static void stopContainer() {
POSTGRESQL_DB.stop();
}
}
Agora podemos usar nas duas classes de testes, removendo assim o código duplicado:
ContaBancariaResourceAtualizarContaITTest
/// IMPORTS
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ContaBancariaResourceAtualizarContaITTest extends AppTestContainer {
// ....
}
ContaBancariaResourceITTest
/// IMPORTS
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ContaBancariaResourceITTest extends AppTestContainer {
// ....
}
Pronto, podemos executar cada uma delas que tudo continuará funcionando numa boa, agora sem a repetição de código. Repetir código nunca é bom, mesmo que seja o código de teste.
Deu ruim. Está quebrando tudo.
Se você executar as duas classes de teste, uma de cada vez
vai funcionar perfeitamente. O problema ocorre quando você tenta executar todas as classes de teste de uma só vez
. Isso você consegue na IDE (IntelliJ) clicando no pacote os estão seus testes ( que no nosso caso é dev.bstk.testcontainerscomspringboot.api
) e pedindo para rodar todos:
Vai ocorrer o seguinte erro:
Isso está acontecendo porque quando é executado a primeira classe de teste, o TestContainer sobe UM CONTAINER DO POSTGRESQL
, ai quando todos os casos de testes dessa primeira classe finalizar, ele tenta PARAR O CONTAINER DO POSTGRESQL
. Até ai ok, mas isso vai se repetir quando for executar os casos de teste da segunda classe de teste, assim, ele tenta mais uma vez subir um novo container mas o primeiro ainda não foi finalizado totalmente, assim não conseguindo subir um novo container.
É meio confuso, então vamos tentar visualizar:
CLASSE DE TESTE A
- SOBE CONTAINER A
- EXECUTA CASO DE TESTE 1
- EXECUTA CASO DE TESTE 2
- TENTA FINALIZAR CONTAINER A
CLASSE DE TESTE B
- SOBE CONTAINER B
- AINDA NÃO FINALIZOU O CONTAINER A
- EXECUTA CASO DE TESTE 1
- ERRO DE CONEXÃO, NÃO TEM UM CONTAINER ATIVO
O legal seria ter apenas um container para todas as classes de teste, algo assim:
- SOBE CONTAINER_POSTGRESQL
CLASSE DE TESTE A
- EXECUTA CASO DE TESTE 1
- EXECUTA CASO DE TESTE 2
CLASSE DE TESTE B
- EXECUTA CASO DE TESTE 1
- EXECUTA CASO DE TESTE 2
CLASSE DE TESTE C
- EXECUTA CASO DE TESTE 1
- EXECUTA CASO DE TESTE 2
- FINALIZA CONTAINER_POSTGRESQL
Bom, dá pra fazer isso ai e consegui por meio de uma extensão customizada do JUnit
.
Criando nossa extensão customizada
Para que possamos ter o comportamento de apenas um container para todos os teste, vamos criar um extension
.
Você já deve ter usando uma, algo como:
@ExtendWith(SpringExtension.class)
@ExtendWith(MockitoExtension.class)
Pois bem, vamos criar a nossa, chamando de AppTestExtension
e usaremos ela na nossa classe de configuração, ficando dessa forma:
@Testcontainers
@ExtendWith(AppTestExtension.class) /// NOSSA EXTENSÃO CUSTOMIZADA!!
public abstract class AppTestContainer {
...
}
Nossa extensão ficou assim:
public class AppTestExtension implements BeforeAllCallback, ExtensionContext.Store.CloseableResource {
private static boolean START = false;
@Override
public void beforeAll(ExtensionContext extensionContext) {
if (!START) {
START = true;
AppTestContainer.startContainer();
extensionContext.getRoot().getStore(GLOBAL).put(AppTestExtension.class.getName(), this);
}
}
@Override
public void close() {
AppTestContainer.stopContainer();
}
}
- O método
beforeAll()
será executadoantes
de cada classe de teste começar a executar. Porém aqui colocamos uma flag booleana, assim podemos fazer com que o conteúdo desse método seja executado apenas uma vez. UmHackzinho maroto!
.
Bom, assim fica um ponto ideal para iniciarmos na mão o nosso container:
AppTestContainer.startContainer();
- O método
close()
esse sim, será executado quando todas as classes de testes terminar de executar, novamente um ponto ideal para finalizarmos o container:
AppTestContainer.stopContainer();
Para que seja feito assim foi preciso ajustar alguns pontos na classe AppTestContainer
, e os pontos são esses:
- Deixei os métodos
startContainer
estopContainer
como publicos e estáticos.
public static void startContainer() {
POSTGRESQL_DB.start();
log.info("\n\n");
log.info("***********************************************");
log.info("**** INICIANDO O CONTAINER : POSTGRESQL_DB ****");
log.info("***********************************************");
log.info("\n\n");
}
public static void stopContainer() {
POSTGRESQL_DB.stop();
log.info("\n\n");
log.info("*************************************************");
log.info("**** FINALIZANDO O CONTAINER : POSTGRESQL_DB ****");
log.info("*************************************************");
log.info("\n\n");
}
- Deixei a definição do nosso container como
privado
e adicionei a configuração:.withReuse(true)
(Bom, fiz uns teste aqui e não precisou, funciona sem, mas na documentação diz algo que é bom usar)
private static final PostgreSQLContainer<?> POSTGRESQL_DB = new PostgreSQLContainer<>("postgres:14.1")
.withDatabaseName("testcontainers-db")
.withUsername("testcontainers-db")
.withPassword("testcontainers-db")
.withReuse(true);
Aviso: Para usar
.withReuse(true)
, você precisa configurar um arquivo oculto chamado.testcontainers.properties
que está na pasta home do seu usuário, deixando ele assim:
# Adicionar linha:
testcontainers.reuse.enable=true
Pronto, agora executando todos os teste, eles voltam a funcionar direitinho, e só com um container para todos os testes.
Procure o log da IDE as marcações que deixamos e veja que só será executado apenas uma vez!
INFO - INICIANDO O CONTAINER : POSTGRESQL_DB
INFO - FINALIZANDO O CONTAINER : POSTGRESQL_DB
Ficou extenso esse post, muita coisa mas é divetido. Criar testes de integração, extensões e etc é sair do básico e ir um pouco além.
Bom é isso ai, espero que tenha curtido e até a próxima!
Compartilhe, dê uma estrela la no GitHub.
Links: