Digite algo para buscar...
Testes de Integração no Quarkus usando TestContainers

Testes de Integração no Quarkus usando TestContainers

Quase toda aplicação utiliza algum recurso externo, como banco de dados (MySQL, PostgreSQL, Oracle), mensageria (ActiveMQ Artemis, Apache Kafka), cache (Redis), serviço de autenticação (KeyCloak), entre outros. Essas dependências acabam adicionando uma complexidade extra na hora de realizar testes de integração, pois além de rodar os testes, teria que ter esses serviços externos rodando, ou serem substituídos por dependências mais simples.

É comum por exemplo, ao realizar um teste de integração em uma aplicação que utiliza banco de dados, utilizar um banco de dados em memória (como H2 por exemplo), para não depender de uma dependência externa. O problema é que nem sempre o banco de dados em memória vai ter todas as funcionalidades utilizadas na aplicação, ou mesmo vai se comportar exatamente da mesma forma.

Uma alternativa bem interessante é utilizar a biblioteca Testcontainers, que permite subir instâncias de banco de dados ou qualquer serviço que rode em um container Docker durante os testes.

Info

A partir do Quarkus 1.11, foi introduzido o Dev Services, recurso que automatiza a inicialização de dependências como PostgreSQL, Kafka e Redis durante o desenvolvimento e execução de testes. Ele cobre a maioria das necessidades de testes de integração sem a necessidade de configurações adicionais. Por baixo, o Dev Services utiliza Testcontainers para provisionar os serviços necessários.

Mesmo com a chegada do Dev Services, o uso direto do Testcontainers continua sendo uma opção importante em cenários mais avançados, como quando é necessário controlar versões específicas dos serviços, utilizar imagens customizadas, orquestrar múltiplos containers ou reproduzir ambientes mais próximos da produção.

De modo geral, recomendo a utilização do Dev Services sempre que possível, por sua simplicidade e integração nativa com o Quarkus. O uso direto do Testcontainers deve ser considerado quando houver requisitos de customização que extrapolem os recursos oferecidos pelo Dev Services.

Criando o projeto Quarkus

Vamos criar o projeto utilizando o Quarkus CLI:

quarkus create app com.renanwillian:quarkus-testcontainers --extension=resteasy,resteasy-jackson,flyway,hibernate-orm-panache,jdbc-postgresql

Configuração do banco + flyway

Arquivo: /resources/application.properties

quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=postgres
quarkus.datasource.password=password
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5435/postgres

quarkus.flyway.locations=db/migration
quarkus.flyway.clean-at-start=true
quarkus.flyway.migrate-at-start=true

/resources/db/migration/V1__init.sql

CREATE TABLE car (
    id BIGSERIAL NOT NULL,
    make VARCHAR(255) NOT NULL,
    model VARCHAR(255) NOT NULL,
    year INTEGER NOT NULL
);

ALTER TABLE car ADD CONSTRAINT pk_car PRIMARY KEY (id);

INSERT INTO car (make, model, year) VALUES ('Mercedes-Benz', '500 SL', 1992);

Classes

Domain:

@Entity
public class Car {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String make;
    private String model;
    private Integer year;

	// getters / setters
}

DTO:

public class CarRequest implements Serializable {

    private static final long serialVersionUID = 7937264281409749414L;

    private String make;
    private String model;
    private Integer year;

    public CarRequest() {}

    public CarRequest(String make, String model, Integer year) {
        this.make = make;
        this.model = model;
        this.year = year;
    }

    public Car toDomain() {
        Car car = new Car();
        car.setMake(make);
        car.setModel(model);
        car.setYear(year);
        return car;
    }

	// getters / setters
}

public class CarResponse implements Serializable {

    private static final long serialVersionUID = 2348428287037849372L;

    private Long id;
    private String make;
    private String model;
    private Integer year;

    public CarResponse(Car car) {
        this.id = car.getId();
        this.make = car.getMake();
        this.model = car.getModel();
        this.year = car.getYear();
    }

	// getters / setters
}

Repository:

@ApplicationScoped
public class CarRepository implements PanacheRepository<Car> {

    public Long countByMakeModelYear(String make, String model, Integer year)  {
        return count("lower(make) = lower(?1) AND lower(model) = lower(?2) AND year = ?3", make, model, year)
    }
}

Service:

@ApplicationScoped
@Transactional
public class CarService {

    @Inject
    CarRepository carRepository;

    public List<CarResponse> list() {
        return carRepository.listAll().stream().map(CarResponse::new).collect(Collectors.toList());
    }

    public CarResponse create(CarRequest carRequest) {
        if (carRepository.countByMakeModelYear(carRequest.getMake(), carRequest.getModel(), carRequest.getYear()) > 0) {
            throw new BadRequestException();
        }
        Car car = carRequest.toDomain();
        carRepository.persist(car);
        return new CarResponse(car);
    }

    public CarResponse find(Long id) {
        return new CarResponse(findById(id));
    }

    public CarResponse update(Long id, CarRequest carRequest) {
        Car car = findById(id);
        car.setMake(carRequest.getMake());
        car.setModel(carRequest.getModel());
        car.setYear(carRequest.getYear());
        carRepository.persist(car);
        return new CarResponse(car);
    }

    public void delete(Long id) {
        carRepository.delete(findById(id));
    }

    public Car findById(Long id) {
        return carRepository.findByIdOptional(id).orElseThrow(NotFoundException::new);
    }
}

Resources:

@Path("/cars")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CarResources {

    @Inject
    CarService carService;

    @GET
    public Response list() {
        return Response.ok(carService.list()).build();
    }

    @POST
    public Response create(CarRequest carRequest) {
        return Response.status(Response.Status.CREATED.getStatusCode())
                       .entity(carService.create(carRequest))
                       .build();
    }

    @GET
    @Path("/{id}")
    public Response find(@PathParam("id") Long id) {
        return Response.ok(carService.find(id)).build();
    }

    @PUT
    @Path("/{id}")
    public Response update(@PathParam("id") Long id, CarRequest carRequest) {
        return Response.ok(carService.update(id, carRequest)).build();
    }

    @DELETE
    @Path("/{id}")
    public Response delete(@PathParam("id") Long id) {
        carService.delete(id);
        return Response.noContent().build();
    }
}

Adicionando o Testcontainers

Para utilizar essa biblioteca, é necessário que o docker esteja instalado nas máquinas que forem rodar os testes. O primeiro passo é adicionar a dependência do testcontainers e do container postgresql:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers</artifactId>
  <version>1.17.3</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>1.17.3</version>
  <scope>test</scope>
</dependency>

Depois é necessário adicionar um LifecycleManager com a configuração do container Postgres:

public class PostgresTestLifecycleManager implements QuarkusTestResourceLifecycleManager {

    public static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:12.2");

    @Override
    public Map<String, String> start() {
        POSTGRES.start();
        Map<String, String> properties = new HashMap<>();
        properties.put("quarkus.datasource.jdbc.url", POSTGRES.getJdbcUrl());
        properties.put("quarkus.datasource.username", POSTGRES.getUsername());
        properties.put("quarkus.datasource.password", POSTGRES.getPassword());
        return properties;
    }

    @Override
    public void stop() {
        if (POSTGRES.isRunning()) {
            POSTGRES.stop();
        }
    }
}

Ali em properties estamos sobrescrevendo as propriedades de conexão do banco de dados com os valores do container postgres.

Agora todos os testes de integração que precisarem utilizar o container configurado, basta anotar os testes com o recurso @QuarkusTestResource(PostgresTestLifecycleManager::class), o melhor é que podemos utilizar quantos containers docker forem necessários para atender a necessidade do teste.

@QuarkusTest
@QuarkusTestResource(PostgresTestLifecycleManager.class)
public class CarServiceIT {

    @Inject
    Flyway flyway;

    @Inject
    CarService carService;

    @Inject
    CarRepository carRepository;

    @BeforeEach
    void setUp() {
        flyway.clean();
        flyway.migrate();
    }

    @Test
    void whenListMethodIsCalledAllCarsAreReturned() {
        var list = carService.list();

        assertEquals(1, list.size());
        assertEquals(1, list.get(0).getId());
        assertEquals("Mercedes-Benz", list.get(0).getMake());
        assertEquals("500 SL", list.get(0).getModel());
        assertEquals(1992, list.get(0).getYear());
    }

    @Test
    void whenCreateMethodIsCalledWithValidDataCarResponseIsReturned() {
        var carRequest = new CarRequest("BMW", "1 Series", 2013);
        var carResponse = carService.create(carRequest);

        assertEquals(2, carResponse.getId());
        assertEquals("BMW", carResponse.getMake());
        assertEquals("1 Series", carResponse.getModel());
        assertEquals(2013, carResponse.getYear());
    }

    @Test
    void whenCreateMethodIsCalledWithDuplicatedDataThrowsBadRequestException() {
        var carRequest = new CarRequest("Mercedes-Benz", "500 SL", 1992);
        assertThrowsExactly(BadRequestException.class, () -> carService.create(carRequest));
    }

    @Test
    void whenUpdateMethodIsCalledWithValidDataCarResponseIsReturned() {
        var carRequest = new CarRequest( "BMW", "1 Series", 2013);
        var carResponse = carService.update(1L, carRequest);

        assertEquals(1, carResponse.getId());
        assertEquals("BMW", carResponse.getMake());
        assertEquals("1 Series", carResponse.getModel());
        assertEquals(2013, carResponse.getYear());
    }

    @Test
    void whenFindMethodIsCalledWithValidIdCarResponseIsReturned() {
        var carResponse = carService.find(1L);

        assertEquals(1, carResponse.getId());
        assertEquals("Mercedes-Benz", carResponse.getMake());
        assertEquals("500 SL", carResponse.getModel());
        assertEquals(1992, carResponse.getYear());
    }

    @Test
    void whenFindMethodIsCalledWithInvalidIdThrowsNotFoundException() {
        assertThrowsExactly(NotFoundException.class, () -> carService.find(500L));
    }

    @Test
    void whenDeleteMethodIsCalledWithValidIdTheCarIsDeleted() {
        carService.delete(1L);
        assertNull(carRepository.findById(1L));
    }

    @Test
    void whenDeleteMethodIsCalledWithInvalidIdThrowsNotFoundException() {
        assertThrowsExactly(NotFoundException.class, () -> carService.delete(500L));
    }
}

Para garantir que os dados cadastrados em um teste não interfira no outro teste, utilizei o flyway clean / migrate, porém não recomendo essa abordagem em bases de dados muito maiores que esse exemplo.

REST assured + Testcontainers

Também é possível utilizar testcontainers em conjunto com rest-assured para testar endpoints REST, para isso precisamos primeiro adicionar a lib do rest-assured:

<dependency>
    <groupId>io.rest-assured</groupId>
    <artifactId>rest-assured</artifactId>
    <scope>test</scope>
</dependency>

E utilizar o PostgresTestLifecycleManager no teste do rest-assured:

@QuarkusTest
@QuarkusTestResource(PostgresTestLifecycleManager.class)
@TestHTTPEndpoint(CarResources.class)
public class CarResourcesIT {

    @Inject
    Flyway flyway;

    @BeforeEach
    void setUp() {
        flyway.clean();
        flyway.migrate();
    }

    @Test
    void whenListResourceIsCalledAllCarsIsReturned() {
        RestAssured.given()
                   .when().get()
                   .then()
                   .statusCode(Status.OK.getStatusCode())
                   .body(is("[{\"id\":1,\"make\":\"Mercedes-Benz\",\"model\":\"500 SL\",\"year\":1992}]"));
    }

    @Test
    void whenCreateResourceIsCalledWithValidDataCreatedIsReturned() {
        RestAssured.given()
                   .when()
                   .contentType(ContentType.JSON)
                   .body("{\"make\": \"BMW\", \"model\": \"1 Series\", \"year\": 2013}")
                   .post()
                   .then()
                   .statusCode(Status.CREATED.getStatusCode())
                   .body("id", is(2))
                   .body("make", is("BMW"))
                   .body("model", is("1 Series"))
                   .body("year", is(2013));
    }

    @Test
    void whenCreateResourceIsCalledWithDuplicatedDataBadRequestIsReturned() {
        RestAssured.given()
                   .when()
                   .contentType(ContentType.JSON)
                   .body("{\"make\": \"Mercedes-Benz\", \"model\": \"500 SL\", \"year\": 1992}")
                   .post()
                   .then()
                   .statusCode(Status.BAD_REQUEST.getStatusCode());
    }

    @Test
    void whenUpdateResourceIsCalledWithValidDataOkIsReturned() {
        RestAssured.given()
                   .when()
                   .contentType(ContentType.JSON)
                   .body("{\"make\": \"BMW\", \"model\": \"1 Series\", \"year\": 2013}")
                   .put("/1")
                   .then()
                   .statusCode(Status.OK.getStatusCode())
                   .body("id", is(1))
                   .body("make", is("BMW"))
                   .body("model", is("1 Series"))
                   .body("year", is(2013));
    }


    @Test
    void whenFindResourceIsCalledWithValidIdTheCarIsReturned() {
        RestAssured.given()
                   .when()
                   .get("/1")
                   .then()
                   .statusCode(Status.OK.getStatusCode())
                   .body("id", is(1))
                   .body("make", is("Mercedes-Benz"))
                   .body("model", is("500 SL"))
                   .body("year", is(1992));
    }

    @Test
    void whenFindResourceIsCalledWithInvalidIdNotFoundIsReturned() {
        RestAssured.given()
                   .when()
                   .get("/500")
                   .then()
                   .statusCode(Status.NOT_FOUND.getStatusCode());
    }

    @Test
    void whenDeleteResourceIsCalledWithValidIdNoContentIsReturned() {
        RestAssured.given()
                   .when()
                   .delete("/1")
                   .then()
                   .statusCode(Status.NO_CONTENT.getStatusCode());
    }

    @Test
    void whenDeleteResourceIsCalledWithInvalidIdNotFoundIsReturned() {
        RestAssured.given()
                   .when()
                   .delete("/500")
                   .then()
                   .statusCode(Status.NOT_FOUND.getStatusCode());
    }
}

Você pode ler mais sobre rest-assured aqui.

Github do Projeto:

Posts relacionados