So far, we focused on being able to run the application locally without having to install or run any dependent services manually. But there is nothing more painful than working on a codebase without a comprehensive test suite.
Let's fix that!!
For all the integration tests in our application, we need to start PostgreSQL, Kafka, LocalStack and Microcks containers.
So, let's create a BaseIntegrationTest
class under src/test/java
with the common setup as follows:
package com.testcontainers.catalog;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
import com.testcontainers.catalog.ContainersConfig;
import io.restassured.RestAssured;
import org.junit.jupiter.api.BeforeEach;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.context.annotation.Import;
import org.testcontainers.Testcontainers;
@SpringBootTest(
webEnvironment = RANDOM_PORT,
properties = {
"spring.kafka.consumer.auto-offset-reset=earliest"
})
@Import(ContainersConfig.class)
public abstract class BaseIntegrationTest {
@LocalServerPort
private int port;
@BeforeEach
void setUpBase() {
RestAssured.port = port;
Testcontainers.exposeHostPorts(port);
}
}
- We have reused the
ContainersConfig
class that we created in the previous steps to define all the required containers. - We have configured the
spring.kafka.consumer.auto-offset-reset
property toearliest
to make sure that we read all the messages from the beginning of the topic. - We have configured the
RestAssured.port
to the dynamic port of the application that is started by Spring Boot.
Let's create the test class ApplicationTests
under src/test/java
with the following test:
package com.testcontainers.catalog;
import org.junit.jupiter.api.Test;
class ApplicationTests extends BaseIntegrationTest {
@Test
void contextLoads() {}
}
If you run this test, it should pass and that means we have successfully configured the application to start with all the required containers.
Before writing the API tests, let's create src/test/resources/test-data.sql
to insert some test data into the database as follows:
DELETE FROM products;
insert into products(code, name, description, image, price) values
('P101','Product P101','Product P101 description', null, 34.0),
('P102','Product P102','Product P102 description', null, 25.0),
('P103','Product P103','Product P103 description', null, 15.0)
;
Create ProductControllerTest
and add a test to successfully create a new product as follows:
package com.testcontainers.catalog.api;
import com.testcontainers.catalog.BaseIntegrationTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static org.hamcrest.CoreMatchers.endsWith;
@Sql("/test-data.sql")
class ProductControllerTest extends BaseIntegrationTest {
@Test
void createProductSuccessfully() {
String code = UUID.randomUUID().toString();
given().contentType(ContentType.JSON)
.body(
"""
{
"code": "%s",
"name": "Product %s",
"description": "Product %s description",
"price": 10.0
}
"""
.formatted(code, code, code))
.when()
.post("/api/products")
.then()
.statusCode(201)
.header("Location", endsWith("/api/products/%s".formatted(code)));
}
}
Next, let's add a test for product image upload API endpoint.
Copy any sample image with name P101.jpg
into src/main/resources
.
package com.testcontainers.catalog.api;
import com.testcontainers.catalog.BaseIntegrationTest;
import com.testcontainers.catalog.domain.ProductService;
import com.testcontainers.catalog.domain.models.Product;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.context.jdbc.Sql;
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.util.Optional;
import java.util.UUID;
import static io.restassured.RestAssured.given;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.CoreMatchers.endsWith;
@Sql("/test-data.sql")
class ProductControllerTest extends BaseIntegrationTest {
@Autowired
ProductService productService;
@Test
void shouldUploadProductImageSuccessfully() throws IOException {
String code = "P101";
File file = new ClassPathResource("P101.jpg").getFile();
Optional<Product> product = productService.getProductByCode(code);
assertThat(product).isPresent();
assertThat(product.get().imageUrl()).isNull();
given().multiPart("file", file, "multipart/form-data")
.contentType(ContentType.MULTIPART)
.when()
.post("/api/products/{code}/image", code)
.then()
.statusCode(200)
.body("status", endsWith("success"))
.body("filename", endsWith("P101.jpg"));
await().pollInterval(Duration.ofSeconds(3)).atMost(10, SECONDS).untilAsserted(() -> {
Optional<Product> optionalProduct = productService.getProductByCode(code);
assertThat(optionalProduct).isPresent();
assertThat(optionalProduct.get().imageUrl()).isNotEmpty();
});
}
}
This test checks the following:
- Before uploading the image, the product image URL is null for the product with code P101.
- Invoke the Product Image Upload API endpoint with the sample image file.
- Assert that the response status is 200 and the response body contains the image file name.
- Assert that the product image URL is updated in the database after the image upload.
Next, let's add a test for getting the product information by code.
@Sql("/test-data.sql")
class ProductControllerTest extends BaseIntegrationTest {
@Autowired
ProductService productService;
@Test
void getProductByCodeSuccessfully() {
String code = "P101";
Product product = given().contentType(ContentType.JSON)
.when()
.get("/api/products/{code}", code)
.then()
.statusCode(200)
.extract()
.as(Product.class);
assertThat(product.code()).isEqualTo(code);
assertThat(product.name()).isEqualTo("Product %s".formatted(code));
assertThat(product.description()).isEqualTo("Product %s description".formatted(code));
assertThat(product.price().compareTo(new BigDecimal("34.0"))).isEqualTo(0);
assertThat(product.available()).isTrue();
}
}
Checking product information like this is easy but become really cumbersome when the number of properties is growing
or when the Product
class is shared among many different operations of your API. You have to check the properties
presence but also their type and this can result in sprawling code!
If you're using an "API design-first approach", the conformance of your data structure can be automatically checked by
Microcks for you! Check the src/main/resources/catalog-openapi.yaml
file that describes our Catalog API.
Now let's create a test that uses Microcks to automatically check that our ProductController
is conformance to this definition:
import io.github.microcks.testcontainers.MicrocksContainer;
import io.github.microcks.testcontainers.model.TestRequest;
import io.github.microcks.testcontainers.model.TestResult;
import io.github.microcks.testcontainers.model.TestRunnerType;
import io.restassured.RestAssured;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.context.jdbc.Sql;
@Sql("/test-data.sql")
class ProductControllerTest extends BaseIntegrationTest {
@Autowired
MicrocksContainer microcks;
@Test
void checkOpenAPIConformance() throws Exception {
microcks.importAsMainArtifact(new ClassPathResource("catalog-openapi.yaml").getFile());
TestRequest testRequest = new TestRequest.Builder()
.serviceId("Catalog Service:1.0")
.runnerType(TestRunnerType.OPEN_API_SCHEMA.name())
.testEndpoint("http://host.testcontainers.internal:" + RestAssured.port)
.build();
TestResult testResult = microcks.testEndpoint(testRequest);
assertThat(testResult.isSuccess()).isTrue();
}
}
Let's understand what's going on behind the scenes:
- We complete the Microcks container with our additional
catalog-openapi.yaml
artifact file (this could have also been done within theContainersConfig
class at bean initialisation). - We prepare a
TestRequest
object that allows to specify the scope of the conformance test. Here we want to check the conformance ofCatalog Service
with version1.0
that are the identifier found incatalog-openapi.yaml
. - We ask Microcks to validate the
OpenAPI Schema
conformance by specifying arunnerType
. - We ask Microcks to validate the localhost endpoint on the dynamic port provided by the Spring Test
(we use the
host.testcontainers.internal
alias for that).
Finally, we're retrieving a TestResult
from Microcks containers, and we can assert stuffs on this result, checking it's a success.
During the test, Microcks has reused all the examples found in the catalog-openapi.yaml
file to issue requests to
our running application. It also checked that all the received responses conform to the OpenAPI definition elements:
return codes, headers, content-type and JSON schema structure.
If you want to get more details on the test done by Microcks, you can add those lines just before the assertThat()
:
// You may inspect complete response object with following:
ObjectMapper mapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
System.out.println(mapper.writerWithDefaultPrettyPrinter().writeValueAsString(testResult));
- Write tests for create product API fails if the payload is invalid.
- Write tests for create product API fails if the product code already exists.
- Write tests for get product by code API fails if the product code does not exist.
- Write tests for get product by code API that returns
"available": false
when Microcks server return quantity=0. - Write tests for get product by code API that returns
"available": true
from Microcks server throws Exception.