Testing Spring Boot Applications with Testcontainers and Oracle
At Spring One in Las Vegas a couple of months ago, I learned about Testcontainers and the expanded support for using them in Spring Boot 3, and I thought they were great! I love testing! So I could not wait to try them out.
In this article, I want to show you how to easily test your Spring Boot applications with a full Oracle 23c database, with only about a 10 second startup time, which is fast enough to use even in unit tests.
Let's create a Spring Boot 3 application that uses JPA and has a simple REST API. Here's the POM file for the application:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.5</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>demo</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.oracle.database.spring</groupId>
<artifactId>oracle-spring-boot-starter-ucp</artifactId>
<type>pom</type>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>oracle-free</artifactId>
<version>1.19.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
It's a pretty standard project, but I've added a couple of extra dependencies, shown again below, the first one is to enable testcontainers itself, and the second is for the Oracle 23c Free database testcontainer:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>oracle-free</artifactId>
<version>1.19.2</version>
<scope>test</scope>
</dependency>
Now, let's write the test! Create a new file called DemoApplicationTests.java in src/test/java/com/example/demo. We are going to want two annotations on this class:
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
The first one enables testcontainers for this test class. Since we are going to run these tests with a real database, we will also run the full application context and the web server (as opposed to using something like @WebMvcTest and MockMvc (which, by the way, are also a great way to write tests without needing a full stack, but that's a subject for another article!). To avoid port conflicts, we tell SpringBootTest to use a random port. We can get the port by injecting a value into the class:
@Value(value = "${local.server.port}")
private int port;
Now, let's handle the container configuration:
@Container
@ServiceConnection
static OracleContainer oracleContainer = new OracleContainer(
DockerImageName.parse("gvenzl/oracle-free:slim-faststart")
.asCompatibleSubstituteFor("gvenzl/oracle-free"))
.withDatabaseName("pdb1")
.withUsername("testuser")
.withPassword(("testpwd"));
I overrided the image because I wanted to use the slim-faststart variant, which (as the name suggests) is a bit smaller and is optimized to start faster, so its a good choice for testing. Then I just configured the database name and a user for the tests to use.
Great, now let's write the actual test:
private static final ObjectMapper mapper = new ObjectMapper();
@Autowired
AnimalRepository animalRepository;
@Test
void testGetAnimals() {
// create the test data
List<Animal> animals = List.of(
new Animal(null, "cat"),
new Animal(null, "dog")
);
animalRepository.saveAll(animals);
// check the api
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Object[]> result = restTemplate.getForEntity(
"http://127.0.0.1:" + port + "/animals", Object[].class
);
Object[] objects = result.getBody();
List<String> actualAnimals = Arrays.stream(objects)
.map(object -> mapper.convertValue(object, Animal.class))
.map(Animal::getName)
.collect(Collectors.toList());
assertTrue(actualAnimals.containsAll(Arrays.asList("cat", "dog")),
"The expected animals should be present");
}
First, I have injected an ObjectMapper so we can convert the JSON response into my Animal POJO. And I've injected the JPA repository.
Then in the actual test method, I start by creating some test data (a cat and a dog) and saving them in the database.
Next, I am calling the actual REST API, using the random port we injected earlier, and grabbing the results into an Object[].
Recommended by LinkedIn
Next, I am converting those Objects into Animals and extracting the name of each one into a new list.
Finally, I am asserting that the list of animals that I got back contains the two that I expect to be there. Note that I am not assuming it contains only those two, because some other test might run in parallel and create some other data in the repository. I could annotate my test with @DirtiesContext so I don't have to worry about other tests, but then I'd have to restart the database container each time and that would slow everything down, so I prefer to write the test with less assumptions instead.
So here's the finished test class:
package com.example.demo;
import com.example.demo.model.Animal;
import com.example.demo.repository.AnimalRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.oracle.OracleContainer;
import org.testcontainers.shaded.com.fasterxml.jackson.databind.ObjectMapper;
import org.testcontainers.utility.DockerImageName;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Testcontainers
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class DemoApplicationTests {
@Value(value = "${local.server.port}")
private int port;
private static final ObjectMapper mapper = new ObjectMapper();
@Container
@ServiceConnection
static OracleContainer oracleContainer = new OracleContainer(
DockerImageName.parse("gvenzl/oracle-free:slim-faststart")
.asCompatibleSubstituteFor("gvenzl/oracle-free"))
.withDatabaseName("pdb1")
.withUsername("testuser")
.withPassword(("testpwd"));
@Autowired
AnimalRepository animalRepository;
@Test
void testGetAnimals() {
// create the test data
List<Animal> animals = List.of(
new Animal(null, "cat"),
new Animal(null, "dog")
);
animalRepository.saveAll(animals);
// check the api
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<Object[]> result = restTemplate.getForEntity(
"http://127.0.0.1:" + port + "/animals", Object[].class
);
Object[] objects = result.getBody();
List<String> actualAnimals = Arrays.stream(objects)
.map(object -> mapper.convertValue(object, Animal.class))
.map(Animal::getName)
.collect(Collectors.toList());
assertTrue(actualAnimals.containsAll(Arrays.asList("cat", "dog")),
"The expected animals should be present");
}
}
Of course, it won't run yet because we have not written the application code, so let's do that next.
In src/main/java/com/example/demo, create a file called DemoApplication.java with this content, a very standard Spring Boot application class:
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
In src/main/java/com/example/demo/model, create a file called Animal.java to hold the model POJO, here's the content, again its very standard, nothing special:
package com.example.demo.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class Animal {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
Long id;
String name;
public Animal(Long id, String name) {
this.id = id;
this.name = name;
}
public Animal() {}
public Long getId() {
return id;
}
public String getName() {
return name;
}
}
For the JPA repository, create a file called AnimalRepository.java in src/main/java/com/example/demo/repository with this content, again there's nothing special here:
package com.example.demo.repository;
import com.example.demo.model.Animal;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AnimalRepository extends JpaRepository<Animal, Long> {
}
And here's the REST Controller, this goes in a file called AnimalController.java in src/main/java/com/example/demo/controller and its also very standard:
package com.example.demo.controller;
import com.example.demo.model.Animal;
import com.example.demo.repository.AnimalRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
public class AnimalController {
AnimalRepository animalRepository;
AnimalController(AnimalRepository animalRepository) {
this.animalRepository = animalRepository;
}
@GetMapping("/animals")
public ResponseEntity<List<Animal>> getAnimals() {
return ResponseEntity.ok(animalRepository.findAll());
}
}
Finally, we need the JPA configuration in src/main/resources/application.yaml:
spring:
jpa:
hibernate:
ddl-auto: update
properties:
hibernate:
dialect: org.hibernate.dialect.OracleDialect
format_sql: true
show-sql: true
Now, we can run the test and see the magic happen!
I hope you agree that that is pretty awesome! Thanks for reading and happy testing!