Unit testing is the bedrock of robust software. When it comes to testing your Spring Boot applications that interact with a database, spinning up a full-fledged database instance for every test can be time-consuming and resource-intensive. This is where in-memory databases like H3 (HyperSQL Database Engine) shine.

H3 is a lightweight, pure Java SQL database that runs entirely in memory. This makes it incredibly fast for unit tests, as there’s no disk I/O involved. It supports standard SQL and integrates seamlessly with Spring Boot and Spring Data JPA, making it an excellent choice for isolating your data access logic during unit testing.

This article will guide you through using H3 for your Spring Boot unit tests, covering initialization, data setup, and teardown.

Why Choose H3 for Unit Testing?

  • Blazing Fast: Runs in memory, significantly reducing test execution time.
  • Lightweight: Minimal overhead and easy to set up.
  • Pure Java: Platform-independent and requires no external dependencies beyond its JAR.
  • Standard SQL Support: Familiar SQL syntax makes it easy to work with.
  • Spring Boot Integration: Effortless configuration with Spring Boot’s testing features.
  • Isolation: Each test can have its own isolated database instance, preventing data contamination between tests.

1. Adding the H3 Dependency

First, you need to include the H3 database dependency in your pom.xml (for Maven) or build.gradle (for Gradle) file:

Maven (pom.xml):

<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

Gradle (build.gradle):

testImplementation 'com.h2database:h2'

The test scope ensures that the H3 dependency is only included during testing and not in your production build.

2. Configuring H3 for Unit Tests in Spring Boot

Spring Boot automatically configures an embedded in-memory database if it finds one on the classpath and no explicit database configuration is provided. By default, it might choose H2 or Derby if both are present. To ensure H3 is used, you can explicitly configure it in your application.properties or application.yml within your src/test/resources directory.

application.properties:

spring.datasource.url=jdbc:h2:mem:unittestdb;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop

application.yml:

spring:
  datasource:
    url: jdbc:h2:mem:unittestdb;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop

Explanation of the Configuration:

  • spring.datasource.url=jdbc:h2:mem:unittestdb;DB_CLOSE_DELAY=-1: This is the JDBC URL for an in-memory H3 database named unittestdb.
    • mem:: Specifies an in-memory database.
    • unittestdb: The name you choose for your test database. Each test can potentially use a different name for isolation.
    • DB_CLOSE_DELAY=-1: This crucial setting prevents the in-memory database from being closed when the last connection is closed, ensuring it remains available for the duration of your test context.
  • spring.datasource.driver-class-name=org.h2.Driver: Specifies the JDBC driver class for H3.
  • spring.jpa.database-platform=org.hibernate.dialect.H2Dialect: Tells Hibernate (if you’re using Spring Data JPA) to use the H3-specific SQL dialect.
  • spring.jpa.hibernate.ddl-auto=create-drop: This Hibernate property automatically creates the database schema based on your JPA entities when the application context starts and drops it when the context closes. This is very convenient for unit tests as it ensures a clean database for each test run.

3. Setting Up Data for Your Tests

You have several options for setting up data in your H3 database for unit testing:

a) Using @Sql Annotation (Spring Test):

The @Sql annotation from Spring Test allows you to execute SQL scripts before and/or after your test methods or classes. This is a clean and declarative way to populate your database with test data.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.jdbc.Sql;

import com.example.model.Product;
import com.example.repository.ProductRepository;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
@Sql(scripts = "/import.sql") // Executes import.sql before the test class
public class ProductRepositoryH3SqlAnnotationTest {

    @Autowired
    private ProductRepository productRepository;

    @Test
    @Sql(scripts = "/insert_product.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
    void testFindByName() {
        List<Product> products = productRepository.findByName("Test Product");
        assertThat(products).hasSize(1);
        assertThat(products.get(0).getPrice()).isEqualTo(25.0);
    }

    @Test
    void testFindByPriceGreaterThan() {
        List<Product> products = productRepository.findByPriceGreaterThan(20.0);
        assertThat(products).hasSize(2); // Assuming import.sql inserts more than one product
    }
}
  • Create SQL script files (e.g., import.sql, insert_product.sql) in your src/test/resources directory containing your data setup SQL statements (INSERT statements).
  • Use @Sql(scripts = "/path/to/your/script.sql") at the class or method level to specify the script to execute.
  • executionPhase allows you to control when the script is executed (e.g., BEFORE_TEST_METHOD, AFTER_TEST_METHOD).

b) Using @BeforeEach with Repository save() Methods:

You can programmatically set up data within your test methods or using @BeforeEach to execute before each test. This is useful for more dynamic or test-specific data.

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import com.example.model.Product;
import com.example.repository.ProductRepository;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
public class ProductRepositoryH3BeforeEachTest {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.save(new Product("Product A", 20.0));
        productRepository.save(new Product("Product B", 30.0));
        productRepository.save(new Product("Test Product", 25.0));
    }

    @Test
    void testFindByName() {
        List<Product> products = productRepository.findByName("Test Product");
        assertThat(products).hasSize(1);
        assertThat(products.get(0).getPrice()).isEqualTo(25.0);
    }

    @Test
    void testFindByPriceGreaterThan() {
        List<Product> products = productRepository.findByPriceGreaterThan(20.0);
        assertThat(products).hasSize(2);
    }
}

c) Using Spring Data JPA’s saveAll():

For inserting multiple records at once, you can use the saveAll() method in your repository within a @BeforeEach block.

@BeforeEach
void setUp() {
    productRepository.saveAll(List.of(
            new Product("Product A", 20.0),
            new Product("Product B", 30.0),
            new Product("Test Product", 25.0)
    ));
}

4. Teardown and Clean Up

With H3 and the spring.jpa.hibernate.ddl-auto=create-drop configuration, the database schema is automatically dropped after your test context closes. This provides a clean slate for each test run without requiring explicit teardown in most cases.

However, if you need more granular control or want to clean up specific data after a test method, you can use:

  • @Sql(scripts = "/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD): Create a cleanup.sql script with DELETE statements.
  • @AfterEach with Repository deleteAll() or specific delete() methods: Programmatically delete data after each test.
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import com.example.model.Product;
import com.example.repository.ProductRepository;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
public class ProductRepositoryH3TeardownTest {

    @Autowired
    private ProductRepository productRepository;

    @BeforeEach
    void setUp() {
        productRepository.save(new Product("Test Product", 25.0));
    }

    @Test
    void testFindByName() {
        List<Product> products = productRepository.findByName("Test Product");
        assertThat(products).hasSize(1);
    }

    @AfterEach
    void tearDown() {
        productRepository.deleteAll(); // Clears all data after each test
    }
}

Best Practices for Unit Testing with H3:

  • Keep Tests Focused: Each unit test should verify a specific behavior of your data access logic.
  • Use Meaningful Data: Create test data that clearly demonstrates the scenario you are testing.
  • Ensure Test Isolation: While create-drop helps, be mindful of potential shared state if you reuse the same database name across tests without proper setup/teardown. Consider using unique database names or cleaning up data explicitly.
  • Test Edge Cases: Don’t just test the happy path. Include tests for empty results, null values, and boundary conditions.
  • Verify State: Focus on asserting the state of your entities after repository operations.

Conclusion:

H3 is a powerful ally in your Spring Boot unit testing strategy. Its speed and ease of integration make it ideal for isolating and verifying your data access layer. By following the steps outlined in this article for initialization, data setup using @Sql or programmatic methods, and understanding the automatic teardown provided by Spring Boot and H3, you can write faster, more reliable, and more maintainable unit tests for your Spring Data REST applications. Embrace the speed of in-memory testing and elevate the quality of your code!


Discover more from GhostProgrammer - Jeff Miller

Subscribe to get the latest posts sent to your email.

By Jeffery Miller

I am known for being able to quickly decipher difficult problems to assist development teams in producing a solution. I have been called upon to be the Team Lead for multiple large-scale projects. I have a keen interest in learning new technologies, always ready for a new challenge.