1. Overview
Apache Cassandra is an open-source distributed NoSQL database. It was designed to handle large amounts of data with fast read-write performance and with no single point of failure.
In this tutorial, we'll look at testing a Spring Boot application that uses a Cassandra database. We'll explain how to set up integration tests using a Cassandra container from the Testcontainers library. In addition, we'll make use of the Spring Data repository abstraction to work with Cassandra's data layer.
Finally, we'll show how to reuse a shared Cassandra container instance across multiple integration tests.
2. Test Containers
Testcontainers is a Java library that provides lightweight, throwaway instances of Docker containers. Hence, we commonly use it in Spring for integration testing of applications that use databases. Testcontainers enables us to test on a real database instance without requiring us to install and manage the database on our local machine.
2.1. Maven Dependencies
Cassandra containers are available in the Cassandra Testcontainers module. This enables the usage of containerized Cassandra instances.
Unlike the cassandra-unit library, the Testcontainers library is fully compatible with JUnit 5. Let's start by listing the required Maven dependencies:
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>cassandra</artifactId>
<version>1.15.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.15.3</version>
<scope>test</scope>
<dependency>
2.2. Cassandra Container
Containerized database instances are commonly used for integration testing. As well as ensuring that our data access layer code is fully compatible with the specific database version.
To begin with, we'll need to annotate our test class with both @SpringBootTest and @Testcontainers:
@SpringBootTest
@Testcontainers
class CassandraSimpleIntegrationTest {}
Then, we can define a Cassandra container and expose its specific port:
@Container
public static final CassandraContainer cassandra
= (CassandraContainer) new CassandraContainer("cassandra:3.11.2").withExposedPorts(9042);
Here we're exposing the container port 9042. We should note, however, that Testcontainers will link it to a random host port, which we can get later on.
Using the above, the Testcontainers library automatically takes care of starting a dockerized Cassandra container instance for us aligned with the lifecycle of the test class:
@Test
void givenCassandraContainer_whenSpringContextIsBootstrapped_thenContainerIsRunningWithNoExceptions() {
assertThat(cassandra.isRunning()).isTrue();
}
Now we have a running Cassandra container. However, the Spring application does not yet know about it.
2.3. Overriding Test Properties
In order for Spring Data to be able to establish a connection with the Cassandra container, we'll need to provide a few connection properties. We'll override the default Cassandra connection properties by defining system properties via the java.lang.System class:
@BeforeAll
static void setupCassandraConnectionProperties() {
System.setProperty("spring.data.cassandra.keyspace-name", KEYSPACE_NAME);
System.setProperty("spring.data.cassandra.contact-points", cassandra.getContainerIpAddress());
System.setProperty("spring.data.cassandra.port", String.valueOf(cassandra.getMappedPort(9042)));
}
Now we configured Spring Data to connect with our Cassandra container. However, we'll still need to create a keyspace.
2.4. Creating a Keyspace
As the last step before creating any tables in Cassandra, we'll need to create a keyspace:
private static void createKeyspace(Cluster cluster) {
try (Session session = cluster.connect()) {
session.execute("CREATE KEYSPACE IF NOT EXISTS " + KEYSPACE_NAME +
" WITH replication = \n" +
"{'class':'SimpleStrategy','replication_factor':'1'};");
}
}
A keyspace in Cassandra is very similar to a database in an RDBMS. It defines how data replicates on nodes in a Cassandra cluster.
3. Spring Data for Cassandra
Spring Data for Apache Cassandra applies core Spring concepts to the development of applications using Cassandra. It provides repositories, query builders, and simple annotations for rich object mapping. Thus, it offers a familiar interface to Spring developers working with different databases.
3.1. Data Access Object
Let's start by preparing a simple DAO class which we'll be using later in our integration tests:
@Table
public class Car {
@PrimaryKey
private UUID id;
private String make;
private String model;
private int year;
public Car(UUID id, String make, String model, int year) {
this.id = id;
this.make = make;
this.model = model;
this.year = year;
}
//getters, setters, equals and hashcode
}
The key here is to annotate the class with @Table annotation from the org.springframework.data.cassandra.core.mapping package. In fact, this annotation enables automatic domain object mapping.
3.2. Cassandra Repository
Spring Data makes it very simple to create a repository for our DAO. To begin with, we'll need to enable Cassandra repositories in our Spring Boot main class:
@SpringBootApplication
@EnableCassandraRepositories(basePackages = "org.baeldung.springcassandra.repository")
public class SpringCassandraApplication {}
Then, we simply need to create an interface that extends the CassandraRepository:
@Repository
public interface CarRepository extends CassandraRepository<Car, UUID> {}
Before starting with the integration tests, we'll need to define two additional properties:
spring.data.cassandra.local-datacenter=datacenter1
spring.data.cassandra.schema-action=create_if_not_exists
The first property defines the default local data center name. The second one will ensure that Spring Data automatically creates the required database tables for us. We should note that this setting shouldn't be used in production systems.
Since we are using Testcontainers, we don't need to worry about dropping the tables once the tests are finished. A new container will be started for us every time we run our tests.
4. Integration Tests
Now that have our Cassandra container, a simple DAO class, and a Spring Data repository set up, we are ready to start writing integration tests.
4.1. Saving Record Test
Let's start by testing the insertion of a new record into the Cassandra database:
@Test
void givenValidCarRecord_whenSavingIt_thenRecordIsSaved() {
UUID carId = UUIDs.timeBased();
Car newCar = new Car(carId, "Nissan", "Qashqai", 2018);
carRepository.save(newCar);
List<Car> savedCars = carRepository.findAllById(List.of(carId));
assertThat(savedCars.get(0)).isEqualTo(newCar);
}
4.2. Updating Record Test
Then, we can write a similar test for updating an existing database record:
@Test
void givenExistingCarRecord_whenUpdatingIt_thenRecordIsUpdated() {
UUID carId = UUIDs.timeBased();
Car existingCar = carRepository.save(new Car(carId, "Nissan", "Qashqai", 2018));
existingCar.setModel("X-Trail");
carRepository.save(existingCar);
List<Car> savedCars = carRepository.findAllById(List.of(carId));
assertThat(savedCars.get(0).getModel()).isEqualTo("X-Trail");
}
4.3. Deleting Record Test
Finally, let's write a test for deleting an existing database record:
@Test
void givenExistingCarRecord_whenDeletingIt_thenRecordIsDeleted() {
UUID carId = UUIDs.timeBased();
Car existingCar = carRepository.save(new Car(carId, "Nissan", "Qashqai", 2018));
carRepository.delete(existingCar);
List<Car> savedCars = carRepository.findAllById(List.of(carId));
assertThat(savedCars.isEmpty()).isTrue();
}
5. Shared Container Instance
Most of the time, when working with integration tests, we would like to reuse the same database instance across multiple tests. We can share the same container instance by making use of multiple nested test classes:
@Testcontainers
@SpringBootTest
class CassandraNestedIntegrationTest {
private static final String KEYSPACE_NAME = "test";
@Container
private static final CassandraContainer cassandra
= (CassandraContainer) new CassandraContainer("cassandra:3.11.2").withExposedPorts(9042);
// Set connection properties and create keyspace
@Nested
class ApplicationContextIntegrationTest {
@Test
void givenCassandraContainer_whenSpringContextIsBootstrapped_thenContainerIsRunningWithNoExceptions() {
assertThat(cassandra.isRunning()).isTrue();
}
}
@Nested
class CarRepositoryIntegrationTest {
@Autowired
private CarRepository carRepository;
@Test
void givenValidCarRecord_whenSavingIt_thenRecordIsSaved() {
UUID carId = UUIDs.timeBased();
Car newCar = new Car(carId, "Nissan", "Qashqai", 2018);
carRepository.save(newCar);
List<Car> savedCars = carRepository.findAllById(List.of(carId));
assertThat(savedCars.get(0)).isEqualTo(newCar);
}
// Tests for update and delete
}
}
Since Docker containers take time to start up, a shared container instance between multiple nested test classes will ensure faster execution. We should note, however, that this shared instance won't be automatically cleared between tests.
6. Conclusion
In this article, we explored using a Cassandra container for testing a Spring Boot application that uses a Cassandra database.
In the examples, we covered setting up a dockerized Cassandra container instance, overriding test properties, creating a keyspace, a DAO class, and a Cassandra repository interface.
We saw how to write integration tests that make use of a Cassandra container. Thus our example tests required no mocking. Finally, we saw how to reuse the same container instance across multiple nested tests classes.
As always, the source code is available over on GitHub.