DbUnit Java Database Testing: Dataset Management and Spring Boot Integration
Java applications that talk to a database face a persistent testing problem: how do you test database interactions without making tests slow, fragile, or dependent on production data? DbUnit solves this by providing a framework for managing test datasets — populating tables before tests and verifying their state afterward.
This guide covers DbUnit fundamentals, the modern Database Rider extension, and integrating database tests into a Spring Boot project.
What Is DbUnit?
DbUnit is a JUnit extension (works with JUnit 4 and 5) focused on database-driven tests. Its core capabilities:
- Dataset management — define test data in XML, JSON, CSV, or Excel files
- Database state setup — populate tables before each test from a dataset
- Database state assertion — compare actual table contents against an expected dataset
- Clean insert operations — truncate and repopulate tables between tests for isolation
DbUnit doesn't replace your JPA or JDBC layer — it works alongside them to control the database state around your tests.
Adding DbUnit to Your Project
Maven
<dependencies>
<!-- DbUnit core -->
<dependency>
<groupId>org.dbunit</groupId>
<artifactId>dbunit</artifactId>
<version>2.7.3</version>
<scope>test</scope>
</dependency>
<!-- Database Rider (modern DbUnit wrapper) -->
<dependency>
<groupId>com.github.database-rider</groupId>
<artifactId>rider-junit5</artifactId>
<version>1.42.0</version>
<scope>test</scope>
</dependency>
<!-- Spring Boot integration -->
<dependency>
<groupId>com.github.database-rider</groupId>
<artifactId>rider-spring</artifactId>
<version>1.42.0</version>
<scope>test</scope>
</dependency>
</dependencies>Gradle
testImplementation 'org.dbunit:dbunit:2.7.3'
testImplementation 'com.github.database-rider:rider-junit5:1.42.0'
testImplementation 'com.github.database-rider:rider-spring:1.42.0'Defining Datasets
Datasets describe the initial state of your database tables. DbUnit supports several formats.
XML Dataset (Flat Format)
The most common format — each row is an XML element:
<!-- src/test/resources/datasets/users.xml -->
<?xml version='1.0' encoding='UTF-8'?>
<dataset>
<users id="1" email="alice@example.com" name="Alice Smith" status="ACTIVE" />
<users id="2" email="bob@example.com" name="Bob Jones" status="ACTIVE" />
<users id="3" email="carol@example.com" name="Carol White" status="INACTIVE" />
<orders id="1" user_id="1" total_amount="150.00" status="COMPLETED" />
<orders id="2" user_id="1" total_amount="75.50" status="PENDING" />
<orders id="3" user_id="2" total_amount="200.00" status="CANCELLED" />
</dataset>JSON Dataset (Database Rider)
Database Rider supports JSON datasets, which many teams find more readable:
{
"users": [
{"id": 1, "email": "alice@example.com", "name": "Alice Smith", "status": "ACTIVE"},
{"id": 2, "email": "bob@example.com", "name": "Bob Jones", "status": "ACTIVE"}
],
"orders": [
{"id": 1, "user_id": 1, "total_amount": 150.00, "status": "COMPLETED"},
{"id": 2, "user_id": 1, "total_amount": 75.50, "status": "PENDING"}
]
}YAML Dataset
users:
- id: 1
email: alice@example.com
name: Alice Smith
status: ACTIVE
- id: 2
email: bob@example.com
name: Bob Jones
status: ACTIVE
orders:
- id: 1
user_id: 1
total_amount: 150.00
status: COMPLETEDBasic DbUnit Tests
Here's a raw DbUnit test without any wrapper frameworks:
import org.dbunit.DatabaseTestCase;
import org.dbunit.database.DatabaseConnection;
import org.dbunit.database.IDatabaseConnection;
import org.dbunit.dataset.IDataSet;
import org.dbunit.dataset.xml.FlatXmlDataSetBuilder;
import org.dbunit.operation.DatabaseOperation;
public class UserRepositoryTest extends DatabaseTestCase {
@Override
protected IDatabaseConnection getConnection() throws Exception {
Connection conn = DriverManager.getConnection(
"jdbc:h2:mem:testdb", "sa", ""
);
return new DatabaseConnection(conn);
}
@Override
protected IDataSet getDataSet() throws Exception {
return new FlatXmlDataSetBuilder()
.build(getClass().getResourceAsStream("/datasets/users.xml"));
}
public void testFindActiveUsers() throws Exception {
// Database is already populated from users.xml
UserRepository repo = new UserRepository(getConnection().getConnection());
List<User> activeUsers = repo.findByStatus("ACTIVE");
assertEquals(2, activeUsers.size());
}
}Database Rider (Modern Approach)
Raw DbUnit is verbose. Database Rider is a modern wrapper that integrates with JUnit 5 annotations and dramatically reduces boilerplate.
Basic Database Rider Test
import com.github.database.rider.core.api.connection.ConnectionHolder;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.junit5.api.DBRider;
import org.junit.jupiter.api.Test;
@DBRider
class UserRepositoryTest {
@ConnectionHolder
static ConnectionHolder connectionHolder = () ->
DriverManager.getConnection("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1", "sa", "");
@Test
@DataSet("datasets/users.yml")
void findActiveUsers() {
UserRepository repo = new UserRepository(connectionHolder.getConnection());
List<User> users = repo.findByStatus("ACTIVE");
assertThat(users).hasSize(2);
assertThat(users).extracting(User::getEmail)
.containsExactlyInAnyOrder("alice@example.com", "bob@example.com");
}
@Test
@DataSet("datasets/users.yml")
@ExpectedDataSet("datasets/expected_users_after_deactivation.yml")
void deactivateUser() {
UserRepository repo = new UserRepository(connectionHolder.getConnection());
repo.deactivate(1L);
// @ExpectedDataSet verifies table state automatically after the test
}
}Dataset Operations
Control how DbUnit loads data with the cleanBefore and strategy options:
@DataSet(
value = "datasets/orders.yml",
cleanBefore = true, // truncate affected tables before loading
strategy = SeedStrategy.CLEAN_INSERT // DELETE all rows, then INSERT
)
void testOrderProcessing() {
// ...
}Available strategies:
CLEAN_INSERT— delete all rows from affected tables, then insert (default)INSERT— insert without deleting (fails if rows conflict)REFRESH— update existing rows, insert new onesTRUNCATE_INSERT— use TRUNCATE instead of DELETE (faster but not transaction-safe)DELETE— delete dataset rows (for teardown)DELETE_ALL— delete all rows from affected tables
Spring Boot Integration
Configuration
// src/test/java/config/DatabaseRiderConfig.java
@Configuration
@Profile("test")
public class DatabaseRiderConfig {
@Bean
public ConnectionHolder connectionHolder(DataSource dataSource) throws SQLException {
return dataSource::getConnection;
}
}Test with Spring Boot + Database Rider
@SpringBootTest
@DBRider
@ActiveProfiles("test")
@Transactional
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Autowired
private ConnectionHolder connectionHolder;
@Test
@DataSet("datasets/orders/pending_orders.yml")
void processAllPendingOrders_shouldUpdateStatusToCompleted() {
orderService.processAllPending();
// Verify via repository
List<Order> processed = orderService.findByStatus(OrderStatus.COMPLETED);
assertThat(processed).hasSize(2);
}
@Test
@DataSet("datasets/orders/empty.yml")
void processAllPendingOrders_whenNoOrders_shouldNotThrow() {
assertDoesNotThrow(() -> orderService.processAllPending());
}
@Test
@DataSet("datasets/users/single_user.yml")
@ExpectedDataSet("datasets/users/expected_after_update.yml")
void updateUserProfile_shouldPersistChanges() {
UserProfileRequest request = new UserProfileRequest("New Name", "new@email.com");
userService.updateProfile(1L, request);
}
}H2 Test Database Setup
For fast, in-memory testing with Spring Boot:
# src/test/resources/application-test.properties
spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;MODE=MySQL
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=trueExpected Datasets and Assertions
@ExpectedDataSet compares actual table contents to an expected dataset after the test completes:
# datasets/expected_order_after_cancellation.yml
orders:
- id: 1
status: CANCELLED
cancelled_at: "[ignore]" # ignore this column in comparisonUse [ignore] for columns with dynamic values like timestamps. Database Rider also supports column replacement expressions for partial matching.
Manual Assertions
When @ExpectedDataSet isn't flexible enough:
@Test
@DataSet("datasets/users.yml")
void createUser_shouldIncrementCount(ConnectionHolder connectionHolder) throws Exception {
IDatabaseConnection dbConn = new DatabaseConnection(connectionHolder.getConnection());
// Get initial count
ITable beforeTable = dbConn.createTable("users");
int initialCount = beforeTable.getRowCount();
// Act
userService.createUser("newuser@example.com", "New User");
// Assert
ITable afterTable = dbConn.createTable("users");
assertEquals(initialCount + 1, afterTable.getRowCount());
// Find the new row
String newEmail = (String) afterTable.getValue(initialCount, "email");
assertEquals("newuser@example.com", newEmail);
}Handling Foreign Key Constraints
When loading datasets with foreign key relationships, order matters. DbUnit handles this with @DataSet(executeStatementsBefore = "..."):
@Test
@DataSet(
value = "datasets/orders_with_users.yml",
executeStatementsBefore = "SET FOREIGN_KEY_CHECKS=0",
executeStatementsAfter = "SET FOREIGN_KEY_CHECKS=1"
)
void testOrderWithForeignKeys() {
// ...
}Or disable foreign key checks for the entire test class:
@DBUnit(disableConstraints = true)
@DBRider
class OrderTest {
// Foreign key constraints disabled for all tests in this class
}Dataset Builders for Dynamic Data
When static dataset files aren't flexible enough, build datasets programmatically:
@Test
@DataSet // Empty dataset - we build it in code
void testWithDynamicData() throws Exception {
DataSetBuilder builder = new DataSetBuilder();
IDataSet dataSet = builder
.table("users")
.row().column("id", 1).column("email", "alice@test.com").column("status", "ACTIVE")
.row().column("id", 2).column("email", "bob@test.com").column("status", "INACTIVE")
.table("orders")
.row().column("id", 1).column("user_id", 1).column("total_amount", 100.00)
.build();
DatabaseOperation.CLEAN_INSERT.execute(connection, dataSet);
// Run your test...
}Best Practices
Keep datasets small. A dataset with 3-5 rows per table is easier to understand and maintain than one with 50 rows. Tests should describe exactly what they need.
Name datasets descriptively. datasets/orders/two_pending_orders.yml is better than datasets/test_data.xml. The name should describe the scenario, not just the content.
One dataset per test scenario. Reusing the same large dataset across many tests creates hidden coupling — when you add a row for one test, you may break another that assumed a specific row count.
Use @DataSet(cleanBefore = true) for tests that care about exact row counts. Without it, leftover data from previous tests can cause false failures.
Test both happy paths and edge cases. An empty dataset, a dataset with the maximum allowed values, and a dataset with special characters are all worth testing. Don't only test the "normal" case.
DbUnit and Database Rider remove the main obstacle to database testing in Java: the difficulty of controlling state. With datasets defined in files and loaded automatically, you can write precise, isolated database tests as easily as any other unit test.