Introduction to Data-Driven Testing With JUnit 5: A Guide to Efficient and Scalable Testing
Use JUnit 5’s @ParameterizedTest with @EnumSource and @MethodSource to run tests with multiple data inputs, improve test coverage, and efficiency for robust applications.
Join the DZone community and get the full member experience.
Join For FreeWhen discussing the history of software development, we can observe an increase in software complexity, characterized by more rules and conditions. When it comes to modern applications that rely heavily on databases, testing how the application interacts with its data becomes equally important. It is where data-driven testing plays a crucial role.
Data-driven testing helps increase software quality by enabling tests with multiple data sets, which means the same test runs multiple times with different data inputs. Automating these tests also ensures scalability and repeatability across your test suite, reducing human error, boosting productivity, saving time, and guaranteeing that the same mistake doesn't happen twice.
Modern applications often depend on databases to store and manipulate critical data; indeed, the data is the soul of any modern application. Thus, it's essential to validate that these operations function correctly across a range of scenarios. Writing traditional unit tests often falls short because they don't account for the variability of data that real-world applications encounter. This is where data-driven testing shines.
When we talk about data-driven tests, it gives you the capability to automate those tests with different inputs, including several cases, to check if your application keeps up. Exploring this approach ensures that your application handles data consistently and reliably, helping you avoid bugs that may only appear with specific data types, formats, or combinations of data.
Data-driven testing is a strategy where the same test is run multiple times with different sets of input data. Rather than writing separate test cases for each data variation, you use one test method and provide other data sets to test against. Exploring more of the data-driven testing goes beyond reducing redundancy in your test code and also improves test coverage by ensuring the system behaves as expected across all types of data.

In this article, we will explore this capability with Java and Jupiter.
Live Session: Implementing Data-Driven Testing With Jakarta NoSQL and Jakarta Data
In this section, we will walk through a live example using Java SE, Jakarta NoSQL, and Jakarta Data to demonstrate data-driven testing in action. For our example, we will build a simple hotel management system that tracks room status and integrates with Oracle NoSQL as the database.
Prerequisites
Before diving into the code, ensure you have Oracle NoSQL running either on the cloud or locally using Docker. You can quickly start Oracle NoSQL by running the following command:
docker run -d --name oracle-instance -p 8080:8080 ghcr.io/oracle/nosql:latest-ce
Once the database is up and running, we're ready to start building the project.
You can also find the full project on GitHub: Data-Driven Test with Oracle NoSQL
Step 1: Structure the Entity
We begin by defining the Room entity, which represents a hotel room in our system. This entity is mapped to the database using the @Entity annotation, and each field corresponds to a column in the database:
@Entity
public class Room {
@Id
private String id;
@Column
private int number;
@Column
private RoomType type;
@Column
private RoomStatus status;
@Column
private CleanStatus cleanStatus;
@Column
private boolean smokingAllowed;
@Column
private boolean underMaintenance;
}
Step 2: Room Repository
Next, we create the RoomRepository interface, which uses Jakarta Data and NoSQL annotations to define queries for various room-related operations:
@Repository
public interface RoomRepository {
@Query("WHERE type = 'VIP_SUITE' AND status = 'AVAILABLE' AND underMaintenance = false")
List<Room> findVipRoomsReadyForGuests();
@Query("WHERE type <> 'VIP_SUITE' AND status = 'AVAILABLE' AND cleanStatus = 'CLEAN'")
List<Room> findAvailableStandardRooms();
@Query("WHERE cleanStatus <> 'CLEAN' AND status <> 'OUT_OF_SERVICE'")
List<Room> findRoomsNeedingCleaning();
@Query("WHERE smokingAllowed = true AND status = 'AVAILABLE'")
List<Room> findAvailableSmokingRooms();
@Save
void save(List<Room> rooms);
@Save
Room newRoom(Room room);
void deleteBy();
@Query("WHERE type = :type")
List<Room> findByType(@Param("type") String type);
}
In this repository, we define several queries to retrieve rooms based on different conditions, such as finding available rooms, rooms that need cleaning, or rooms that allow smoking. We also include methods for saving, deleting, and querying rooms by type.
To test our repository, we want to ensure that we are using a test container instead of a production environment. For this, we set up a DatabaseContainer singleton that starts the Oracle NoSQL container for testing purposes:
public enum DatabaseContainer {
INSTANCE;
private final GenericContainer<?> container = new GenericContainer<>
(DockerImageName.parse("ghcr.io/oracle/nosql:latest-ce"))
.withExposedPorts(8080);
{
container.start();
}
public DatabaseManager get(String database) {
DatabaseManagerFactory factory = managerFactory();
return factory.apply(database);
}
public DatabaseManagerFactory managerFactory() {
var configuration = DatabaseConfiguration.getConfiguration();
Settings settings = Settings.builder()
.put(OracleNoSQLConfigurations.HOST, host())
.build();
return configuration.apply(settings);
}
public String host() {
return "http://" + container.getHost() + ":" + container.getFirstMappedPort();
}
}
This container ensures that we’re using the Oracle NoSQL database, which is running inside a Docker container, thereby mimicking a production-like environment while remaining fully isolated for testing purposes.
Step 4: Injecting the DatabaseManager
We need to inject the DatabaseManager into our CDI context. For this, we create a ManagerSupplier class that ensures the DatabaseManager is available to our application:
@ApplicationScoped
@Alternative
@Priority(Interceptor.Priority.APPLICATION)
public class ManagerSupplier implements Supplier<DatabaseManager> {
@Produces
@Database(DatabaseType.DOCUMENT)
@Default
public DatabaseManager get() {
return DatabaseContainer.INSTANCE.get("hotel");
}
}
Step 5: Writing Data-Driven Tests With @ParameterizedTest in JUnit 5
In this step, we focus on how to write data-driven tests using JUnit 5's @ParameterizedTest annotation, and specifically dive into the types used in the RoomServiceTest. We’ll explore the @EnumSource and @MethodSource annotations, all of which help run the same test method multiple times with different sets of input data.
Let’s look at the types used in the RoomServiceTest class in detail:
@ParameterizedTest(name = "should find rooms by type {0}")
@EnumSource(RoomType.class)
void shouldFindRoomByType(RoomType type) {
List<Room> rooms = this.repository.findByType(type.name());
SoftAssertions.assertSoftly(softly -> softly.assertThat(rooms).allMatch(room -> room.getType().equals(type)));
}
The @EnumSource(RoomType.class) annotation is used to automatically provide each enum constant from the RoomType enum to the test method. In this case, the RoomType enum contains values like VIP_SUITE, STANDARD, SUITE, etc.
This annotation causes the test method to run once for each value in the RoomType enum. Each time the test runs, the type parameter is assigned one of the enum values, and the test checks that all rooms returned by the repository match the RoomType provided.
This is especially useful when you want to run the same test logic for all possible values of an enum. It ensures that your code works consistently across all variants of the enum type, minimizing redundant test cases.
@ParameterizedTest
@MethodSource("room")
void shouldSaveRoom(Room room) {
Room updateRoom = this.repository.newRoom(room);
SoftAssertions.assertSoftly(softly -> {
softly.assertThat(updateRoom).isNotNull();
softly.assertThat(updateRoom.getId()).isNotNull();
softly.assertThat(updateRoom.getNumber()).isEqualTo(room.getNumber());
softly.assertThat(updateRoom.getType()).isEqualTo(room.getType());
softly.assertThat(updateRoom.getStatus()).isEqualTo(room.getStatus());
softly.assertThat(updateRoom.getCleanStatus()).isEqualTo(room.getCleanStatus());
softly.assertThat(updateRoom.isSmokingAllowed()).isEqualTo(room.isSmokingAllowed());
});
}
The @MethodSource("room") annotation specifies that the test method should be run with data provided by the room() method. This method returns a stream of Arguments containing different Room objects.
The room() method generates random room data using Faker and assigns random values to room attributes like roomNumber, type, status, etc. These randomly generated rooms are passed to the test method one at a time.
The test checks that the room saved in the repository matches the original room’s attributes, ensuring that the save operation works as expected.
@MethodSource is a great choice when you need to provide complex or custom test data. In this case, we use random data generation to simulate different room configurations, ensuring our code can handle a wide range of inputs without redundancy.
Conclusion
In this article, we've explored the importance of data-driven testing and how to implement it effectively using JUnit 5 (Jupiter). We demonstrated how to leverage parameterized tests to run the same test multiple times with different inputs, making our testing process more efficient, comprehensive, and scalable. By using annotations like @EnumSource, @MethodSource, and @ArgumentsSource, we can easily pass multiple sets of data to our test methods, ensuring that our application works as expected across a wide range of input conditions.
We focused on @EnumSource iterating over enum constants and @MethodSource generating custom data for our tests. These tools, alongside JUnit 5’s rich variety of parameterized test sources, such as @ValueSource, @CsvSource, and @ArgumentsSource, give us the flexibility to design tests that cover a broader spectrum of data variations.
By incorporating these techniques, we ensure that our repository methods (and other components) are robust, adaptable, and thoroughly tested with diverse real-world data. This approach significantly improves software quality, reduces test code duplication, and accelerates the testing process.
Data-driven testing isn’t just about automating tests; it’s about making those tests more meaningful by accounting for the variety of real-world conditions your software might face. It’s a valuable strategy for building resilient applications, and with JUnit 5, the possibilities for enhancing test coverage are vast and customizable.
Opinions expressed by DZone contributors are their own.
Comments