DEV Community

Cover image for Creating a custom testing library for Java Microservices

Creating a custom testing library for Java Microservices

By Pandelis Vardakas, Software Engineer in Test.

Introduction

Microservices have gained popularity in recent years, as most companies adopt them to improve the efficiency and scalability of their applications. Testing is a critical aspect of software development, ensuring that each component functions as expected. However, the complex and distributed nature of microservices can make testing challenging, as each service needs to have specific configurations and requires a considerable amount of boilerplate code in order to implement the tests. This redundancy not only makes maintenance difficult but also increases the risk of errors and inconsistencies. By creating a reusable testing library, we can significantly reduce the duplicate code, make the whole process maintainable, and ensure a consistent level of code quality across our services. The testing library abstracts common testing patterns and utilities, allowing developers to focus on writing meaningful tests rather than dealing with boilerplate code.

This article provides the technical details of implementing such a library in Java, offering out of the box solutions such as Test Containers, a pre-configured WebTestClient, and various utilities to handle JSON file or Kafka requests and responses.

Designing the library

Our purpose in designing our testing library was to create a simple, modular and extensible project, so that anyone in the team can add new features or maintain the existing implementation. Initially we had to decide what we needed to include in the testing library:

  • Test Containers: allow us to manage and configure Docker containers for our tests, providing a reliable way to test against real databases, message brokers, and other services.

  • A pre-configured WebTestClient: which provides out-of-the-box support for making API calls. This configuration ensures that all necessary methods for API testing are readily available.

Additionally, we included a set of utility functions that are commonly used in our tests:

  • JSON File Utilities: to read and write JSON files, making it easier to manage test data.

  • Assert-J library: to create assertions using a custom comparator.

  • Kafka Utilities: to handle Kafka records and topics, or a pre-configured Kafka Consumer facilitating the testing of Kafka-based message flows.

In the following sections, we will delve deeper into each of the items mentioned above.

Setting Up the Project

To create our library, we utilized Spring Initializer with Gradle and Java 21.

As the library itself won’t be executed as a standalone project, we have included only the most necessary dependencies related to Spring Boot testing, Test Containers and Kafka:

[versions]
awaitility = '4.2.1'
apache-kafka = '3.7.0'
mysql-connector-j = '8.4.0'
spring-dependency-management = '1.1.4'
springframework-boot = '3.3.0'
testcontainers-mysql = '1.19.8'
testcontainers-kafka = '1.19.8'
testcontainers-mongodb = '1.19.8'
testcontainers-redis = '1.6.4'

[libraries]
awaitility = { module = 'org.awaitility:awaitility', version.ref = 'awaitility' }
apache-kafka = { module = 'org.apache.kafka:kafka-clients', version.ref = 'apache-kafka' }
mysql-connector-j = { module = 'com.mysql:mysql-connector-j', version.ref = 'mysql-connector-j' }
testcontainers-mysql = { module = 'org.testcontainers:mysql', version.ref = 'testcontainers-mysql' }
testcontainers-kafka = { module = 'org.testcontainers:kafka', version.ref = 'testcontainers-kafka' }
testcontainers-mongodb = { module = 'org.testcontainers:mongodb', version.ref = 'testcontainers-mongodb' }
testcontainers-redis = { module = 'com.redis.testcontainers:testcontainers-redis', version.ref = 'testcontainers-redis' }
spring-boot-testcontainers = { module = 'org.springframework.boot:spring-boot-testcontainers' }
spring-boot-starter-test = { module = 'org.springframework.boot:spring-boot-starter-test' }
spring-boot-starter-webflux = { module = 'org.springframework.boot:spring-boot-starter-webflux' }

[plugins]
spring-dependency-management = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" }
springframework-boot = { id = "org.springframework.boot", version.ref = "springframework-boot" }
Enter fullscreen mode Exit fullscreen mode
import static org.gradle.api.JavaVersion.VERSION_21

plugins {
    id 'java'
    id 'maven-publish'
    alias(libs.plugins.springframework.boot)
    alias(libs.plugins.spring.dependency.management)
}

group = 'com.aa'
version = '1.0.0'

java {
    sourceCompatibility = VERSION_21
}

publishing {
    publications {
        mavenJava(MavenPublication) {
            from components.java
        }
    }
}

repositories {
    mavenCentral()
    mavenLocal()
}

dependencies {
    compileOnly libs.spring.boot.starter.test
    implementation libs.spring.boot.starter.webflux
    implementation libs.spring.boot.testcontainers
    implementation libs.testcontainers.kafka
    implementation libs.testcontainers.mongodb
    implementation libs.testcontainers.mysql
    implementation libs.testcontainers.redis
    implementation libs.awaitility
    implementation libs.mysql.connector.j
    implementation libs.apache.kafka
}
Enter fullscreen mode Exit fullscreen mode

The directory structure of the project looks like this:

Directory structure

Analyzing the core components

Test Containers:

For the Test Containers, the main idea was to create a custom annotation that will provide the ability to enable containers from the available ones with the specific version needed for each service. The version is not mandatory since the annotation uses as default version the latest one.

  • @EnableTestContainers: custom annotation is used to enable test containers in a JUnit 5 test.

  • The ContainerImage[] array is used to specify which containers to initialize and their respective versions.

  • ContainerImage: The nested annotation within the EnableTestContainers annotation allows specifying the type of container to initialize and the version of the container image.

  • @Target({ElementType.TYPE}), @Retention(RetentionPolicy.RUNTIME): annotations specifie the lifecycle of our annotation and can be used on class, interface or, enumeration declarations.

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@ExtendWith({
        RedisContainerInitializer.class, 
        MySqlContainerInitializer.class, 
        MongoDbContainerInitializer.class,
        KafkaContainerInitializer.class
})
public @interface EnableTestContainers {
    ContainerImage[] containers();

    @Retention(RetentionPolicy.RUNTIME)
    @interface ContainerImage {
        Container container();

        String version() default "latest";
    }
}
Enter fullscreen mode Exit fullscreen mode

Each container is instantiated by a Container Initializer class, which implements the beforeAll() method of the BeforeAllCallback interface from the JUnit 5 Extension model. This indicates that the subclass instance should be called once before all tests for the current container.

In the beforeAll method, the container image information is retrieved from the annotation. The method then creates the container if it does not already exist, starts it, and stores it in the Store for reuse in other tests. Additionally, it sets the connection parameters as custom System Properties which can be used to set the connection parameters as environment variables within the project that uses our library. In Library Usage section there are examples that shows how custom System Properties can be utilized.

public final class KafkaContainerInitializer extends ContainerInitializer implements BeforeAllCallback {

    @Override
    public void beforeAll(ExtensionContext extensionContext) {
        getAnnotation(extensionContext, Container.KAFKA.name()).ifPresent(containerImageName -> {
            Store store = getExtensionContextStore(extensionContext, KafkaContainerInitializer.class);

            KafkaContainer container = store.get(StoreKey.KAFKA_CONTAINER.name(), KafkaContainer.class);
            if (container == null) {
                container = new KafkaContainer(parseDockerImageName(containerImageName))
                        .waitingFor(waitAllStrategy());

                container.start();
                store.put(StoreKey.KAFKA_CONTAINER, container);
            }

            System.setProperty(KAFKA_BOOTSTRAP_SERVERS.name(), container.getBootstrapServers());
        });
    }
}

public final class MongoDbContainerInitializer extends ContainerInitializer implements BeforeAllCallback {

    @Override
    public void beforeAll(ExtensionContext extensionContext) {
        getAnnotation(extensionContext, Container.MONGO.name()).ifPresent(containerImageName -> {
            Store store = getExtensionContextStore(extensionContext, MongoDbContainerInitializer.class);

            MongoDBContainer container = store.get(StoreKey.MONGO_DB_CONTAINER.name(), MongoDBContainer.class);
            if (container == null) {
                container = new MongoDBContainer(parseDockerImageName(containerImageName))
                        .waitingFor(waitAllStrategy());

                container.start();
                store.put(StoreKey.MONGO_DB_CONTAINER, container);
            }

            System.setProperty(MONGO_REPLICA_SET.name(), container.getReplicaSetUrl());
        });
    }
}

public final class MySqlContainerInitializer extends ContainerInitializer implements BeforeAllCallback {
    private static final String DATABASE_NAME = "testdb";
    private static final String DB_CREDENTIALS = "root";

    @Override
    public void beforeAll(ExtensionContext extensionContext) {
        getAnnotation(extensionContext, Container.MYSQL.name()).ifPresent(containerImageName -> {
            Store store = getExtensionContextStore(extensionContext, MySqlContainerInitializer.class);

            MySQLContainer<?> container = store.get(StoreKey.MYSQL_CONTAINER.name(), MySQLContainer.class);
            if (container == null) {
                container = new MySQLContainer<>(parseDockerImageName(containerImageName))
                        .withDatabaseName(DATABASE_NAME)
                        .withUsername(DB_CREDENTIALS)
                        .withPassword(DB_CREDENTIALS)
                        .waitingFor(waitAllStrategy());

                container.start();
                store.put(StoreKey.MYSQL_CONTAINER, container);
            }

            System.setProperty(MY_SQL_URL.name(), container.getJdbcUrl());
            System.setProperty(MY_SQL_USERNAME.name(), container.getUsername());
            System.setProperty(MY_SQL_PASSWORD.name(), container.getPassword());
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

The ContainerInitializer class is a sealed abstract class that defines methods for initializing container instances. Sealed classes in Java are used to restrict which other classes can subclass them. The permits keyword lists the classes that are allowed to extend, in our case the each specific container initializer class.

abstract sealed class ContainerInitializer permits KafkaContainerInitializer, MongoDbContainerInitializer,
        MySqlContainerInitializer, RedisContainerInitializer {

    private static final String CONTAINER_NAME_PATTERN = "{0}:{1}";

    protected <T> Store getExtensionContextStore(ExtensionContext extensionContext, T containerInitializerClass) {
        return extensionContext
                .getRoot()
                .getStore(ExtensionContext.Namespace.create(containerInitializerClass));
    }

    protected Optional<ContainerImage> getAnnotation(ExtensionContext extensionContext, String type) {
        return extensionContext.getElement()
                .map(element -> element.getAnnotation(EnableTestContainers.class))
                .map(EnableTestContainers::containers)
                .flatMap(annotations -> processAnnotations(annotations, type));
    }

    private Optional<ContainerImage> processAnnotations(ContainerImage[] annotations, String type) {
        return Arrays.stream(annotations)
                .filter(annotation -> annotation.container().name().equalsIgnoreCase(type))
                .findFirst();
    }

    protected WaitAllStrategy waitAllStrategy() {
        return new WaitAllStrategy(WITH_MAXIMUM_OUTER_TIMEOUT)
                .withStartupTimeout(Duration.ofSeconds(90))
                .withStrategy(Wait.forListeningPort());
    }

    protected DockerImageName parseDockerImageName(ContainerImage containerContainerImageName) {
        return parse(format(CONTAINER_NAME_PATTERN, containerContainerImageName.container().getValue(),
                containerContainerImageName.version()));
    }
}
Enter fullscreen mode Exit fullscreen mode
  • getExtensionContextStore(): This method retrieves a store from the root of the provided ExtensionContext object and it is defined by the containerInitializerClass.

  • getAnnotation(): This method retrieves the containers defined by the user from our custom EnableTestContainers annotation. The method processes these annotations using the processAnnotations() method, filtering out the annotations based on the type.

  • waitAllStrategy(): This method constructs a WaitAllStrategy object, which waits for all of the containers to start. It specifies the startup timeout as 90 seconds and the strategy as “Wait.forListeningPort()”, which makes it wait till all the required ports are available for listening.

  • parseDockerImageName(): This method returns a Docker image constructed from the container image name and its version.

Pre-configured WebTestClient:

The TestClient is an interface with the necessary methods that every test client should implement. These methods are intended to carry out the execution of each client request. Each method returns a TestClientResponse record as custom response that includes httpStatusCode, headers, cookies and the response body.

public interface TestClient<R> {
    public interface TestClient {
    TestClientResponse get(int port, String uri, HttpHeaders headers);

    TestClientResponse put(int port, String uri, HttpHeaders headers, Object body);

    TestClientResponse post(int port, String uri, HttpHeaders headers, Object body);

    TestClientResponse delete(int port, String uri, HttpHeaders headers);
}


public record TestClientResponse(HttpStatusCode httpStatusCode, HttpHeaders headers,
                                 MultiValueMap<String, ResponseCookie> cookies, String body) {
}
Enter fullscreen mode Exit fullscreen mode
  • TestWebClient: class is the implementation of the interface. @ConfigurationProperties: is used to bind external properties to a Java Bean, by annotating our class with this annotation Spring Boot will create a Spring Bean in the application context. This configuration is now a POJO (Plain Old Java Object) and we can inject it to our tests.
@ConfigurationProperties
public class TestWebClient implements TestClient {
    private final WebTestClient webTestClient;

    public TestWebClient(WebTestClient webTestClient) {
        this.webTestClient = webTestClient;
    }

    @Override
    public TestClientResponse get(int port, String uri, HttpHeaders headers) {
        WebTestClientGetRequest request = new WebTestClientGetRequest(port, uri, headers);
        return request.doExecute(webTestClient);
    }

    @Override
    public TestClientResponse put(int port, String uri, HttpHeaders headers, Object body) {
        WebTestClientPutRequest request = new WebTestClientPutRequest(port, uri, headers, body);
        return request.doExecute(webTestClient);
    }

    @Override
    public TestClientResponse post(int port, String uri, HttpHeaders headers, Object body) {
        WebTestClientPostRequest request = new WebTestClientPostRequest(port, uri, headers, body);
        return request.doExecute(webTestClient);
    }

    @Override
    public TestClientResponse delete(int port, String uri, HttpHeaders headers) {
        WebTestClientDeleteRequest request = new WebTestClientDeleteRequest(port, uri, headers);
        return request.doExecute(webTestClient);
    }
Enter fullscreen mode Exit fullscreen mode
  • TestClientConfiguration: is an interface with the necessary method that every test client should implement regarding their configuration.
public interface TestClientConfiguration {

    TestClient createTestClient();
}
Enter fullscreen mode Exit fullscreen mode
  • TestWebClientConfiguration: class is the implementation of the above interface. - @EnableConfigurationProperties: annotation is strictly connected to @ConfiguratonProperties that we have used in the TestWebClient class. Classes that implement auto-configuration can be bundled in external jars and be picked up by Spring Boot. Auto-configuration applies only when relevant classes are found. - @AutoConfigureWebTestClient: sets up a WebTestClient instance to be used in test cases. We use @Primary to give higher preference to a bean when there are multiple beans of the same type.
@EnableConfigurationProperties({TestWebClient.class})
@AutoConfigureWebTestClient
public class TestWebClientConfiguration implements TestClientConfiguration {
    private final WebTestClient webTestClient;

    public TestWebClientConfiguration(WebTestClient webTestClient) {
        this.webTestClient = webTestClient;
    }

    @Override
    @Bean
    @Primary
    public TestWebClient createTestClient() {
        return new TestWebClient(webTestClient);
    }
}
Enter fullscreen mode Exit fullscreen mode

Next, we need to register the above class as a candidate for auto-configuration. To do this, we must include a file within our published jar in the META-INF/spring/ directory with the specific name: org.springframework.boot.autoconfigure.AutoConfiguration.imports.

Spring Boot detects this file and creates the bean accordingly. The file includes the paths of our configuration classes, with one class per line.

Auto Configuration filepath

Auto Configuration filepath

com.aa.test.integration.client.webtestclient.config.TestClientConfiguration
Enter fullscreen mode Exit fullscreen mode

Additionally we have implemented five more classes that include the configuration of the webTestClient request.

  • WebTestClientRequest: is an abstract sealed class and serves as a basis for creating a class hierarchy and sets the structure for each HTTP request. The class takes in a port, URI, and headers as arguments. The port and URI are used later to form the URL to which the request will be made, buildRequest() method is defined, which builds a request with the provided Http method, URL and headers. The abstract method doExecute() also defined and must be implemented by each subclass for making the specific types of request.
public abstract sealed class WebTestClientRequest permits WebTestClientDeleteRequest, WebTestClientGetRequest,
        WebTestClientPostRequest, WebTestClientPutRequest {
    private static final String URL = "http://localhost:";

    private final int port;
    private final String uri;
    private final HttpHeaders headers;

    protected WebTestClientRequest(int port, String uri, HttpHeaders headers) {
        this.port = port;
        this.uri = uri;
        this.headers = headers;
    }

    protected RequestBodySpec buildRequest(WebTestClient webTestClient, HttpMethod httpMethod) {
        return webTestClient.mutate()
                .build()
                .method(httpMethod)
                .uri(URL + port + uri)
                .headers(headers -> headers.addAll(this.headers));
    }

    abstract TestClientResponse doExecute(WebTestClient webTestClient);
}
Enter fullscreen mode Exit fullscreen mode
  • Below is the implementation of the WebTestClientRequest subclasses. Each subclass defines the doExecute method, which is responsible for creating our custom response.
public final class WebTestClientGetRequest extends WebTestClientRequest {
    public WebTestClientGetRequest(int port, String uri, HttpHeaders headers) {
        super(port, uri, headers);
    }

    @Override
    public TestClientResponse doExecute(WebTestClient webTestClient) {
        EntityExchangeResult<String> response = buildRequest(webTestClient, HttpMethod.GET)
                .exchange()
                .expectBody(String.class)
                .returnResult();

        return new TestClientResponse(response.getStatus(), response.getResponseHeaders(), response.getResponseCookies(),
                response.getResponseBody());
    }
}

public final class WebTestClientPostRequest extends WebTestClientRequest {
    private final Object requestBody;

    public WebTestClientPostRequest(int port, String uri, HttpHeaders headers, Object requestBody) {
        super(port, uri, headers);
        this.requestBody = requestBody;
    }

    @Override
    public TestClientResponse doExecute(WebTestClient webTestClient) {
        EntityExchangeResult<String> response = buildRequest(webTestClient, HttpMethod.POST)
                .body(BodyInserters.fromValue(requestBody))
                .exchange()
                .expectBody(String.class)
                .returnResult();

        return new TestClientResponse(response.getStatus(), response.getResponseHeaders(), response.getResponseCookies(),
                response.getResponseBody());
    }
}

public final class WebTestClientPutRequest extends WebTestClientRequest {
    private final Object requestBody;

    public WebTestClientPutRequest(int port, String uri, HttpHeaders headers, Object requestBody) {
        super(port, uri, headers);
        this.requestBody = requestBody;
    }

    @Override
    public TestClientResponse doExecute(WebTestClient webTestClient) {
        EntityExchangeResult<String> response = buildRequest(webTestClient, HttpMethod.PUT)
                .body(BodyInserters.fromValue(requestBody))
                .exchange()
                .expectBody(String.class)
                .returnResult();

        return new TestClientResponse(response.getStatus(), response.getResponseHeaders(), response.getResponseCookies(),
                response.getResponseBody());
    }
}

public final class WebTestClientDeleteRequest extends WebTestClientRequest {
    public WebTestClientDeleteRequest(int port, String uri, HttpHeaders headers) {
        super(port, uri, headers);
    }

    @Override
    public TestClientResponse doExecute(WebTestClient webTestClient) {
        EntityExchangeResult<String> response = buildRequest(webTestClient, HttpMethod.DELETE)
                .exchange()
                .expectBody(String.class)
                .returnResult();

        return new TestClientResponse(response.getStatus(), response.getResponseHeaders(), response.getResponseCookies(),
                response.getResponseBody());
    }
Enter fullscreen mode Exit fullscreen mode

The TestClient interface abstracts the implementation details from the tests, allowing new implementations to be integrated without altering the existing codebase. This method enhances scalability and flexibility, as the interface contract stays consistent, guaranteeing that existing client code operates as intended.

Utility Classes:

Furthermore, we included a set of utility functions that we commonly use in our test cases for handling Kafka actions and Json related files and data.

  • KafkaUtils: is a utility class for handling Kafka Consumer-related tasks. The class contains several static methods for creating a Kafka Consumer, retrieving Kafka records, and deleting all topics.
@Component
public class KafkaUtils {
    private static final String ENABLE_AUTO_COMMIT = "true";

    public static KafkaConsumer<String, String> getKafkaConsumer(String bootstrapServers, String autoOffsetReset) {
        return new KafkaConsumer<>(
                ImmutableMap.of(
                        ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers,
                        ConsumerConfig.GROUP_ID_CONFIG, UUID.randomUUID().toString(),
                        ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, ENABLE_AUTO_COMMIT,
                        ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, autoOffsetReset),
                new StringDeserializer(),
                new StringDeserializer());
    }

    public static ConsumerRecords<String, String> retrieveKafkaRecords(KafkaConsumer<String, String> consumer) {
        AtomicReference<ConsumerRecords<String, String>> records = new AtomicReference<>();
        await().atMost(30, TimeUnit.SECONDS)
                .until(() -> {
                    records.set(consumer.poll(ofMillis(100)));
                    return !records.get().isEmpty();
                });
        return records.get();
    }

    public static void deleteAllTopics(String bootstrapServers, List<String> topics) {
        Properties properties = new Properties();
        properties.put(BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        AdminClient adminClient = AdminClient.create(properties);
        assertNotNull(adminClient.deleteTopics(topics));
        adminClient.close();
    }
}
Enter fullscreen mode Exit fullscreen mode
  • JsonUtils: is a utility class for handling Json-related tasks. The class also contains several static methods to create a JsonNode object from the bytes received in the EntityExchangeResult, to convert a file into a String, to convert a Java object into a formatted JSON String and a method to assert that the given JSON response matches the expected JSON data from a file using a custom comparator.
@Component
public class JsonUtils {
    public static JsonNode getJsonNode(EntityExchangeResult<byte[]> response) throws IOException {
        return new ObjectMapper().readTree(response.getResponseBody());
    }

    public static String readFileAsString(String filePath) throws IOException {
        byte[] fileData = Files.readAllBytes(Path.of(filePath));
        return new String(fileData);
    }

    public static String getObjectAsJson(Object object) throws JsonProcessingException {
        ObjectWriter objectWriter = new ObjectMapper()
                .setSerializationInclusion(JsonInclude.Include.NON_NULL)
                .writer().withDefaultPrettyPrinter();
        return objectWriter.writeValueAsString(object);
    }

    public static void verifyJson(String expectedResponsePath, String response, List<String> jsonPaths)
            throws JSONException, IOException {
        JSONAssert.assertEquals(JsonUtils.readFileAsString(expectedResponsePath),
                response, getComparator(jsonPaths));
    }

    private static CustomComparator getComparator(List<String> jsonPaths) {
        return new CustomComparator(JSONCompareMode.LENIENT,
                jsonPaths.stream()
                        .map(path -> new Customization(path, (o1, o2) -> true))
                        .toArray(Customization[]::new));
    }
}
Enter fullscreen mode Exit fullscreen mode

Enums:

At the end we have implemented three enum classes to define our constants.

  • Container: enum lists the different types of containers being used.

  • StoreKey: enum lists the different types of store key for each test container.

  • SystemProperty: enum enumerates the custom configuration properties needed to connect to test containers, such as URLs and credentials.

public enum Container {
    REDIS("redis"),
    MYSQL("mysql"),
    MONGO("mongo"),
    KAFKA("confluentinc/cp-kafka");

    private final String value;

    private Container(String value) {
        this.value = value;
    }

    public String getValue() {
        return value;
    }
}

public enum StoreKey {
    KAFKA_CONTAINER,
    MONGO_DB_CONTAINER,
    MYSQL_CONTAINER,
    REDIS_CONTAINER
}

public enum SystemProperty {
    KAFKA_BOOTSTRAP_SERVERS,
    MONGO_REPLICA_SET,
    MY_SQL_URL,
    MY_SQL_USERNAME,
    MY_SQL_PASSWORD,
    REDIS_URL,
}
Enter fullscreen mode Exit fullscreen mode

Library Usage

We have thoroughly described the implementation of our library. Now, it’s time to demonstrate its ease of use in testing each service. Below, you’ll find examples showcasing the usage of the test client, custom variables, and the annotation for the test containers.

The TestClient interface can be utilized in tests by simply annotating it with @Autowired, requiring no additional setup:

public class TestExample extends BaseComponentTest {

    @LocalServerPort
    private int port;

    @Autowired
    private TestClient testClient;

    private static HttpHeaders headers;
    private static Request request;

    @BeforeEach
    void setup() {
        headers = new HttpHeaders();
        headers.setContentType(APPLICATION_JSON);

        request = AccountDetailsRequest.builder()
                .accountName("Personal Account")
                .accountType("Savings")
                .build();
    }

    @Test
    @DisplayName("Should successfully create account details for new user")
    void testCreateAccountDetails() {
        TestClientResponse response = testClient.post(port, URI_ACCOUNT_DETAILS, headers, request);
        assertTrue(response.status.status.is2xxSuccessful())
        assertNotNull(response.body()
    }
Enter fullscreen mode Exit fullscreen mode

Custom variables can be used either within the application-test.yaml file or directly integrated into the test as needed:

datasource:
        url: ${MY_SQL_URL}
        username: ${MY_SQL_USERNAME}
        password: ${MY_SQL_PASSWORD}

      data:
        mongodb:
        uri: ${MONGO_REPLICA_SET}
Enter fullscreen mode Exit fullscreen mode
@ExtendWith(SpringExtension.class)
@EnableTestContainers(containers = {
        @ContainerImage(container = Container.MYSQL, version = "8.0.1"),
        @ContainerImage(container = Container.MONGO)
})
class DemoApplicationTests {

    @Test
    void testSaveAndRetrieveStudents() throws IOException {
        System.getPropery("MY_SQL_URL");
    }
}
Enter fullscreen mode Exit fullscreen mode

Annotations within a test class can be utilized for managing test containers as follows:

@ExtendWith(SpringExtension.class)
@EnableTestContainers(containers = {
        @ContainerImage(container = Container.MYSQL, version = "8.0.1"),
        @ContainerImage(container = Container.MONGO)
})
class DemoApplicationTests {
    public static final String SQL_STUDENT = "sql-student";
    public static final String MONGO_STUDENT = "mongo-student";

    @Autowired
    private StudentRepository repository;

    @Autowired
    private StudentMongoRepository mongoRepository;

    @Test
    void testSaveAndRetrieveStudents() throws IOException {
        Student student = new Student();
        student.setName(SQL_STUDENT);
        Student savedStudentMysql = repository.save(student);

        StudentMongo studentMongo = new StudentMongo();
        studentMongo.setName(MONGO_STUDENT);
        StudentMongo savedStudentMongo = mongoRepository.save(studentMongo);

        assertAll(
                () -> assertEquals(SQL_STUDENT, savedStudentMysql.getName()),
                () -> assertEquals(MONGO_STUDENT, savedStudentMongo.getName())
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

By adopting the library, instead of manually copying and pasting code across multiple projects, we saved significant time that would have been otherwise spent reinstalling and configuring each project individually. Our testing process is streamlined, and we reduced the risk of errors associated with repetitive tasks. This approach has allowed us to maintain consistency across projects while improving overall efficiency in our testing workflows. Additionally, it’s now easy for all developers, including junior ones, to incorporate testing processes into a new service simply by integrating the library. This documentation supports seamless onboarding and ongoing development efforts within our team.

Conclusion

In conclusion, creating a custom testing library for Java microservices can significantly enhance code maintainability, minimize code duplication, and ensure consistent quality across services.

The approach outlined in this article includes utilizing Test Containers for reliable testing, a pre-configured WebTestClient for streamlined API testing, and utility functions for managing JSON files and Kafka operations.

Overall, this custom testing library provides a robust framework for developing and testing high-quality microservices in Java. Additionally, it offered a valuable opportunity to deepen our understanding of specific Spring Boot concepts, further enhancing our development capabilities.

At Agile Actors, we thrive on challenges with a bold and adventurous spirit. We confront problems directly, using cutting-edge technologies in the most innovative and daring ways. If you’re excited to join a dynamic learning organization where knowledge flows freely and skills are refined to excellence, come join our exceptional team. Let’s conquer new frontiers together. Check out our openings and choose the Agile Actors Experience!

Top comments (0)