by Roger Frauca

Integration and End-to-End Tests with Spring Boot

Spring Boot

In the previous article, we explored how to set up a CI/CD pipeline with GitHub Actions to ensure code quality using unit tests and to publish Docker images to GitHub Container Registry. In this article, we’ll extend the same source code to include, an integration test and end-to-end-tests. This article provides a walkthrough of the significant parts of the code. For a deeper understanding, you can browse the source code.

Integration tests

The project now includes persistence for endpoints that manage tasks, storing them in a PostgreSQL database.  TestContainers provides lightweight, disposable instances of common databases, or anything else that can run in a Docker container. It allows developers to write tests that use real, fully-functional instances of services, rather than mocked or in-memory versions. This ensures that the tests are more realistic and can catch issues that would only occur in a production-like environment. To run tests with real persistence, we have added a Postgre TestContainer. We do use spring new configuration:

@TestConfiguration
public class TestContainersConfiguration {
  @Bean
  @ServiceConnection
  public PostgreSQLContainer<?> postgresDB(){
    return new PostgreSQLContainer<>("postgres:16.3");
  }
}

This configuration starts a PostgreSQL database and injects its properties, making it easy to run tests with real persistence. To start the context with Spring Boot tests, we use:

@SpringBootTest(classes = Application.class)
@Testcontainers
@ActiveProfiles("test")
@Import(TestContainersConfiguration.class)
public abstract class AbstractIntegrationTest {

All integration tests extend this class, ensuring a single Spring context is used. Here’s an example integration test:

class TaskRepositoryIT extends AbstractIntegrationTest {

  @Autowired
  TaskRepository repo;

  @Test
  void testSimpleUse(){
    var name = "commonName";
    var task = new TaskEntity();
    task.setName(name);

    task = repo.save(task);

    assertThat(task.getId()).isNotNull();

    var foundTask = repo.findById(task.getId());

    assertThat(foundTask).isNotEmpty();
    assertThat(foundTask.get().getName()).isEqualTo(task.getName());

    repo.deleteById(task.getId());

    foundTask = repo.findById(task.getId());

    assertThat(foundTask).isEmpty();
  }

The @ActiveProfiles(“test”) annotation activates application-test.properties, where we define:

spring.jpa.hibernate.ddl-auto = update

This configuration ensures that the database schema is automatically created by the JPA EntityManager when tests run. This should be done with a migration tool like Flyway or Liquibase to manage schema changes. These tools provide version control for database schemas, making it easier to track, manage, and migrate database changes safely. Using Flyway, for instance, we could define SQL migration scripts that would be applied automatically, eliminating the need for the ddl-auto property, but that may be another article. 

Tests are executed with ./gradlew check, running static analysis, unit tests, and integration tests.

Run end-to-end tests with Cucumber

Cucumber, a BDD tool, allows expressing tests in plain language, making it easy to articulate what has been tested. It also enables us to test the Helm chart installation, running the application as it would be in production and checking if it operates as expected.

To conduct end-to-end tests, we need to create the application image, set up a Kind cluster, deploy the application, and run the Cucumber tests. 

Running with Kind

We have added a Helm chart to deploy the generated image on a Kubernetes cluster. The Helm chart is created from basic templates using:

helm create charts

A new Gradle task uses this template to run our Spring image locally:

abstract class ClusterCreateTask : DefaultTask() {
  @get:Input abstract val imageName: Property<String>
  @get:Input abstract val imageTag: Property<String>

  @TaskAction
  fun execute() {
    if (!clusterExists()) {
      println("Creating cluster")
      "kind create cluster --name builder --config buildSrc/src/main/resources/kind-cluster.yaml"
          .runCommand()
    }
    println("Load image")
    "kind --name builder load docker-image ${imageName.get()}".runCommand()
    println("update charts")
    "helm dependency build ./charts".runCommand()
    println("Deploy helm image")
    ("helm upgrade --install local ./charts --create-namespace --namespace builder --wait --atomic " +
            "--set image.tag=${imageTag.get()} " +
            "--set service.type=NodePort --set service.nodePort=31080")
        .runCommand()
  }
}

The key parts include setting the image tag for the Helm chart and loading it into the Kind cluster. The application is exposed using NodePort with ports defined in kind-cluster.yaml:

kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
    extraPortMappings:
      - containerPort: 31080
        hostPort: 8080
        protocol: TCP

Run Cucumber tests

Cucumber tests are straightforward to work with. They use natural language constructs in a Given-When-Then format, making them accessible and easy to understand. Each Given, When, and Then statement corresponds to a Java function in the step definitions. However, I surprised myself when I tried to send the base URL to those steps. I only found the option to set it as an environment variable. To overcome this, I developed a custom solution using a thread-safe singleton pattern to manage configurations.

We use a singleton to hold the configuration:

public class ConfigurationSingleton {
  static AtomicReference<Configuration> singleton = new AtomicReference<>();

This singleton is initialized when the tests are run:

public class RunCucumber {
  static Logger log = LoggerFactory.getLogger(RunCucumber.class);

  public static void main(String[] args) throws Exception {
    log.info("Run cucumber tests");
    ConfigurationSingleton.initialize(ConfigurationArgsBuilder.parse(args));
    byte result = Main.run(ConfigurationArgsBuilder.cucumberArgs(args));
    if (result != 0) {
      throw new RuntimeException("There are errors on the execution");
    }
  }
}

We use the configuration singleton to connect to the application:

public class ClientSingleton {
...
  private static RestTemplate createClient(){
    var baseUrl = ConfigurationSingleton.get().baseUrl();
    ......
  }
}

You can run end-to-end test by ./gradlew cucumberTest

Time cost to run the test

TestCotaniers are a lighter solution as they add little time to the execution of the spring tests. Running Kind clusters and deploying the tool takes about 2 minutes, once all the environment is set running the test is fast. 

Run in local

Spring Boot’s TestContainers configuration allows running the application locally with real PostgreSQL persistence:

@SpringBootApplication
public class TestApplication {

  public static void main(String[] args) {
    new SpringApplicationBuilder(Application.class)
        .profiles("test")
        .sources(TestContainersConfiguration.class)
        .run(args);
  }
}

Now, you can run the application locally with ./gradlew bootTestRun and debug it with a real PostgreSQL database.

Share

Related post