Popular Searches
Popular Course Categories
Popular Courses

Testing JDBC and Database-Driven Applications

Testing JDBC and Database-Driven Applications

Advanced Java Application Testing and Deployment

Testing JDBC and Database-Driven Applications

Course: Advanced Java Training

Topic: Testing JDBC and Database-Driven Applications

Course URL: https://www.justacademy.co/course-detail/advance-java-training

Demo Registration: https://www.justacademy.co/register-for-course-demo

JustAcademy Website: https://www.justacademy.co/


1. Introduction to Testing JDBC and Database-Driven Applications

Database-driven applications rely on databases to store, retrieve, update, and delete information. Java applications commonly use JDBC (Java Database Connectivity) to communicate with relational databases such as MySQL, PostgreSQL, Oracle, and SQL Server.

Testing JDBC and database-driven applications ensures that SQL statements execute correctly, Java objects map properly to database records, transactions maintain data consistency, and errors are handled safely.

For example, an employee management application may insert employee records, retrieve employee details, update salaries, and delete records. Tests should verify that each operation produces the expected result without creating duplicate data or corrupting existing records.

Why database testing is important

  • Verifies SQL statements and database query results.
  • Checks Create, Read, Update, and Delete operations.
  • Detects incorrect parameter binding and data mapping.
  • Validates database constraints and data integrity.
  • Tests transaction commit and rollback behavior.
  • Identifies connection leaks and resource-management issues.
  • Helps detect SQL injection vulnerabilities.
  • Reduces database-related production defects.

2. Learning Objectives

After completing this topic, learners should be able to:

  • Understand JDBC components and their roles.
  • Differentiate unit testing from integration testing.
  • Write automated tests using JUnit 5.
  • Use Mockito to isolate business logic from database dependencies.
  • Test JDBC repository and DAO classes against a test database.
  • Verify CRUD operations and SQL query results.
  • Test constraints, transactions, rollback, and exception handling.
  • Manage test data and database cleanup.
  • Apply testing practices to JDBC and Spring JDBC applications.

3. Understanding JDBC Components

JDBC provides Java interfaces and classes for connecting to databases, executing SQL statements, and processing returned data.

Component Purpose Testing Consideration
DriverManager Obtains database connections using a JDBC URL and credentials. Verify connection configuration where appropriate.
DataSource Provides connections and commonly supports connection pooling. Configure a dedicated test data source.
Connection Represents a database connection and supports transactions. Verify transaction behavior and cleanup.
PreparedStatement Executes parameterized SQL statements. Check parameter binding and query results.
ResultSet Provides access to rows returned by a query. Verify column mappings and empty results.
SQLException Represents errors during database operations. Verify exception handling.

Example: Executing a JDBC query

String sql = "SELECT id, name, email FROM employees WHERE id = ?";

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {

    statement.setLong(1, 101L);

    try (ResultSet resultSet = statement.executeQuery()) {
        if (resultSet.next()) {
            String name = resultSet.getString("name");
            String email = resultSet.getString("email");
            System.out.println(name + " - " + email);
        }
    }
}

The example uses a parameterized query and try-with-resources. Parameterized queries help prevent SQL injection when used correctly, while try-with-resources automatically closes JDBC resources.

4. Types of Database Testing

Testing Type Description Common Tools
Unit Testing Tests individual classes or methods in isolation. JUnit, Mockito
Integration Testing Verifies that Java code works correctly with a database. JUnit, H2, Testcontainers
Repository Testing Validates SQL execution, CRUD operations, and result mapping. JUnit and a test database
Transaction Testing Checks commit, rollback, and consistency behavior. JUnit and a transactional database
Performance Testing Measures query response time, throughput, and resource usage. JMeter and database monitoring tools
Security Testing Checks SQL injection risks and database access controls. Automated tests and security tools

5. Unit Testing vs. Integration Testing

Unit testing and integration testing serve different purposes. Unit tests focus on isolated application logic, while integration tests verify that components work together correctly, including actual SQL execution and database behavior.

Unit testing

  • Tests a small component independently.
  • Uses mocks or stubs to isolate external dependencies.
  • Runs quickly and helps identify business logic errors.
  • Does not prove that actual SQL statements work against a database.

Integration testing

  • Connects to a dedicated test database.
  • Executes actual SQL statements.
  • Validates schema, constraints, mappings, and persisted values.
  • Can reveal differences between the test database and production database.

Best practice: Use unit tests for isolated business logic and integration tests for actual database behavior. Mocking a repository does not verify the SQL implemented by that repository.

6. Setting Up the Testing Environment

A Java database testing environment typically includes a build tool, testing framework, JDBC driver, and optionally a mocking library or test database.

Tool Purpose
JUnit 5 Creates and executes automated tests.
Mockito Creates mock objects for isolated unit tests.
H2 Database Provides a lightweight relational database for suitable tests.
Testcontainers Runs disposable database containers for integration tests.
Maven or Gradle Manages dependencies and executes tests.

Example Maven dependencies

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.13.1</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <version>2.3.232</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-junit-jupiter</artifactId>
        <version>5.18.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

These versions are examples. Use versions compatible with your Java version and build configuration. Spring Boot projects should generally use dependency versions managed by their Spring Boot release.

7. Creating a Database Schema for Testing

A database schema defines the tables, columns, data types, keys, and constraints used by the application. A test schema should represent the structures that repository methods depend on.

Example employee table

CREATE TABLE employees (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    salary DECIMAL(12, 2) NOT NULL
);

The primary key prevents duplicate employee IDs, while the unique constraint prevents duplicate email addresses. The not-null constraints ensure that required fields contain values.

Example Java model

import java.math.BigDecimal;

public class Employee {
    private final long id;
    private final String name;
    private final String email;
    private final BigDecimal salary;

    public Employee(long id, String name, String email, BigDecimal salary) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.salary = salary;
    }

    public long getId() { return id; }
    public String getName() { return name; }
    public String getEmail() { return email; }
    public BigDecimal getSalary() { return salary; }
}

Using BigDecimal for monetary values avoids many floating-point precision issues. Tests should verify that values are mapped correctly between database columns and Java fields.

8. Designing a Testable Repository or DAO

A Data Access Object (DAO) or repository encapsulates database operations and separates SQL code from business logic. This separation improves maintainability and makes testing easier.

Example repository

import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Optional;

public class EmployeeRepository {
    private final DataSource dataSource;

    public EmployeeRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    public Optional<Employee> findById(long id) throws SQLException {
        String sql =
            "SELECT id, name, email, salary FROM employees WHERE id = ?";

        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(sql)) {

            statement.setLong(1, id);

            try (ResultSet resultSet = statement.executeQuery()) {
                if (!resultSet.next()) {
                    return Optional.empty();
                }

                Employee employee = new Employee(
                    resultSet.getLong("id"),
                    resultSet.getString("name"),
                    resultSet.getString("email"),
                    resultSet.getBigDecimal("salary")
                );

                return Optional.of(employee);
            }
        }
    }

    public int insert(Employee employee) throws SQLException {
        String sql =
            "INSERT INTO employees (id, name, email, salary) VALUES (?, ?, ?, ?)";

        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement = connection.prepareStatement(sql)) {

            statement.setLong(1, employee.getId());
            statement.setString(2, employee.getName());
            statement.setString(3, employee.getEmail());
            statement.setBigDecimal(4, employee.getSalary());

            return statement.executeUpdate();
        }
    }
}

Injecting a DataSource allows the repository to use a test database without changing its implementation. Try-with-resources ensures that connections, statements, and result sets are closed after use.

9. Writing Automated Tests with JUnit 5

JUnit 5 provides annotations and assertion methods for creating automated tests. Assertions compare actual results with expected results and cause a test to fail when behavior does not match requirements.

Annotation Purpose
@Test Marks a method as a test.
@BeforeEach Runs setup before every test method.
@AfterEach Runs cleanup after every test method.
@BeforeAll Runs setup once before all tests in the class.
@AfterAll Runs cleanup once after all tests in the class.
@ParameterizedTest Runs a test with multiple sets of input values.
@DisplayName Defines a readable test name.

Example: Testing an employee model

import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;

class EmployeeTest {
    @Test
    void shouldPreserveEmployeeDetails() {
        Employee employee = new Employee(
            1L, "Asha Sharma", "[email protected]",
            new BigDecimal("50000.00")
        );

        assertEquals("Asha Sharma", employee.getName());
        assertEquals("[email protected]", employee.getEmail());
        assertEquals(0,
            new BigDecimal("50000.00").compareTo(employee.getSalary()));
    }
}

This is a unit test for the model. It does not execute SQL. Repository integration tests must connect to a test database and verify real database operations.

10. Testing with an H2 In-Memory Database

H2 is a lightweight relational database that can run in memory. It can be useful for fast integration tests, but it may behave differently from a production database for SQL syntax, data types, locking, and vendor-specific features.

Example database setup

import org.h2.jdbcx.JdbcDataSource;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;

public class TestDatabaseFactory {
    public static DataSource createDataSource() throws SQLException {
        JdbcDataSource dataSource = new JdbcDataSource();
        dataSource.setURL("jdbc:h2:mem:employee_test");
        dataSource.setUser("sa");
        dataSource.setPassword("");

        try (Connection connection = dataSource.getConnection();
             Statement statement = connection.createStatement()) {
            statement.execute("""
                CREATE TABLE employees (
                    id BIGINT PRIMARY KEY,
                    name VARCHAR(100) NOT NULL,
                    email VARCHAR(150) NOT NULL UNIQUE,
                    salary DECIMAL(12, 2) NOT NULL
                )
                """);
        }

        return dataSource;
    }
}

Use a unique database name per test context or initialize the schema only once. Repeatedly creating the same table in a shared in-memory database can cause tests to fail because the table already exists.

Example repository integration test

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;

class EmployeeRepositoryIntegrationTest {
    private DataSource dataSource;
    private EmployeeRepository repository;

    @BeforeEach
    void setUp() throws SQLException {
        dataSource = TestDatabaseFactory.createDataSource();
        repository = new EmployeeRepository(dataSource);

        try (Connection connection = dataSource.getConnection();
             PreparedStatement statement =
                 connection.prepareStatement("DELETE FROM employees")) {
            statement.executeUpdate();
        }
    }

    @Test
    void shouldInsertAndRetrieveEmployee() throws SQLException {
        Employee employee = new Employee(
            1L, "Asha Sharma", "[email protected]",
            new BigDecimal("55000.00")
        );

        int inserted = repository.insert(employee);
        Optional<Employee> result = repository.findById(1L);

        assertEquals(1, inserted);
        assertTrue(result.isPresent());
        assertEquals("Asha Sharma", result.get().getName());
        assertEquals("[email protected]", result.get().getEmail());
        assertEquals(0,
            new BigDecimal("55000.00").compareTo(result.get().getSalary()));
    }

    @Test
    void shouldReturnEmptyWhenEmployeeDoesNotExist() throws SQLException {
        assertTrue(repository.findById(999L).isEmpty());
    }
}

This example illustrates repository integration testing. For a fully repeatable test suite, adjust the factory so that schema creation happens once per isolated database. Then reset test records before each test.

11. Testing CRUD Operations

CRUD means Create, Read, Update, and Delete. Each operation should be tested for its return value and its effect on persisted data.

Operation Test Scenario Expected Result
Create Insert a valid employee. One row is inserted and can be retrieved.
Read Find an existing employee by ID. The correct record is returned.
Read missing record Search for a nonexistent ID. An empty result or defined not-found outcome is returned.
Update Change an employee's salary. The new salary is persisted.
Delete Delete an existing employee. The record is removed.
Duplicate insert Insert a duplicate ID or email. The database rejects the duplicate.

Example: Testing an update

String sql = "UPDATE employees SET salary = ? WHERE id = ?";

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {

    statement.setBigDecimal(1, new BigDecimal("60000.00"));
    statement.setLong(2, 1L);

    int updated = statement.executeUpdate();
    assertEquals(1, updated);
}

For a complete integration test, retrieve the employee after the update and assert that the new salary is stored correctly.

12. Testing SQL Queries and Result Mapping

A query can execute successfully and still return incorrect data. Tests should verify filtering, sorting, joins, aggregation, pagination, and conversions between SQL data types and Java objects.

Important query scenarios

  • Filter employees by ID, email, or status.
  • Sort records by name, salary, or creation date.
  • Test pagination and result limits.
  • Verify queries returning zero, one, or multiple rows.
  • Test aggregate functions such as COUNT, SUM, and AVG.
  • Verify joins between related tables.
  • Check handling of nullable database columns.
  • Validate date, timestamp, decimal, and Boolean mappings.

Example: Testing a row count

String sql = "SELECT COUNT(*) FROM employees";

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql);
     ResultSet resultSet = statement.executeQuery()) {

    assertTrue(resultSet.next());
    assertEquals(3, resultSet.getInt(1));
}

This test assumes exactly three employee records exist. Insert a known set of records before the assertion so that the test does not depend on leftover database data.

13. Testing Prepared Statements and SQL Injection Protection

SQL injection occurs when untrusted input changes the intended structure of an SQL statement. Parameterized queries separate SQL structure from input values and are an important security measure.

Unsafe example

// Avoid concatenating user input into SQL.
String sql = "SELECT * FROM employees WHERE email = '" + email + "'";

Safer example

String sql = "SELECT * FROM employees WHERE email = ?";

try (Connection connection = dataSource.getConnection();
     PreparedStatement statement = connection.prepareStatement(sql)) {

    statement.setString(1, email);

    try (ResultSet resultSet = statement.executeQuery()) {
        while (resultSet.next()) {
            System.out.println(resultSet.getString("email"));
        }
    }
}

A security test can use a value such as ' OR '1'='1 and verify that it is treated as a literal value rather than changing the query's logic. SQL identifiers such as table names and sort columns generally cannot be parameterized in the same way as ordinary values; validate any dynamically selected identifiers against an allowlist.

14. Testing Constraints and Invalid Data

Database constraints preserve data integrity even when application-level validation is bypassed. Tests should verify all important constraints defined in the schema.

  • Primary key: rejects duplicate identifiers.
  • Unique constraint: rejects duplicate email addresses.
  • Not-null constraint: rejects null values in required columns.
  • Foreign key: prevents references to nonexistent parent records.
  • Check constraint: rejects values outside a permitted range.

Example: Duplicate email test

import static org.junit.jupiter.api.Assertions.assertThrows;

@Test
void shouldRejectDuplicateEmail() throws SQLException {
    Employee first = new Employee(
        1L, "Asha", "[email protected]", new BigDecimal("40000.00")
    );

    Employee second = new Employee(
        2L, "Ravi", "[email protected]", new BigDecimal("45000.00")
    );

    repository.insert(first);

    assertThrows(SQLException.class, () -> repository.insert(second));
}

This test assumes that the database enforces a unique email constraint. In production applications, database exceptions are often translated into meaningful application exceptions.

15. Testing Transactions, Commit, and Rollback

A transaction groups multiple database operations into one logical unit. If an operation fails, rollback can prevent incomplete changes from being persisted.

  • Commit: makes transaction changes permanent.
  • Rollback: discards uncommitted changes.
  • Atomicity: treats transaction operations as one unit.
  • Isolation: controls how concurrent transactions interact.
  • Consistency: helps preserve database rules and constraints.

Example: Testing rollback

try (Connection connection = dataSource.getConnection()) {
    connection.setAutoCommit(false);

    try {
        try (PreparedStatement statement = connection.prepareStatement(
            "INSERT INTO employees (id, name, email, salary) VALUES (?, ?, ?, ?)")) {

            statement.setLong(1, 500L);
            statement.setString(2, "Test Employee");
            statement.setString(3, "[email protected]");
            statement.setBigDecimal(4, new BigDecimal("30000.00"));
            statement.executeUpdate();
        }

        connection.rollback();
    } finally {
        if (!connection.isClosed()) {
            connection.setAutoCommit(true);
        }
    }
}

To complete the test, query for employee ID 500 using a separate connection and assert that the record does not exist. In production code, transaction ownership should be clear so that a repository does not unexpectedly commit or roll back a transaction managed by a service layer.

16. Testing Exception Handling and Database Failures

Database operations may fail because of invalid SQL, constraint violations, connection interruptions, authentication problems, or unavailable database servers. Tests should verify that failures are handled consistently.

Scenarios to test

  • The database connection cannot be established.
  • A query violates a constraint.
  • A statement references an invalid table or column.
  • A database operation times out.
  • An update affects zero rows when one row was expected.
  • Resources are released after an exception.

Use meaningful exception types and messages. Avoid exposing database credentials, sensitive record contents, or internal infrastructure details in user-facing error responses.

Example: Handling a missing employee

public Employee requireEmployee(long id) throws SQLException {
    return findById(id).orElseThrow(
        () -> new IllegalArgumentException("Employee not found: " + id)
    );
}

Tests should cover both the successful path and the not-found path. A larger application may define a dedicated exception class for missing records.

17. Using Mockito for Database-Dependent Business Logic

Mockito helps test business logic without connecting to a real database. It can replace a repository with a mock and return controlled responses to the service being tested.

Example service class

public class EmployeeService {
    private final EmployeeRepository repository;

    public EmployeeService(EmployeeRepository repository) {
        this.repository = repository;
    }

    public String getEmployeeEmail(long id) throws SQLException {
        return repository.findById(id)
            .map(Employee::getEmail)
            .orElseThrow(() ->
                new IllegalArgumentException("Employee not found"));
    }
}

Example Mockito test

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.math.BigDecimal;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
class EmployeeServiceTest {
    @Mock
    private EmployeeRepository repository;

    @InjectMocks
    private EmployeeService service;

    @Test
    void shouldReturnEmailForExistingEmployee() throws Exception {
        Employee employee = new Employee(
            1L, "Asha", "[email protected]", new BigDecimal("50000.00")
        );

        when(repository.findById(1L)).thenReturn(Optional.of(employee));

        assertEquals("[email protected]", service.getEmployeeEmail(1L));
    }
}

This is a unit test of the service. It does not verify the repository's SQL statements. The repository should have separate integration tests.

18. Testing Spring JDBC Applications

Spring applications commonly use JdbcTemplate to simplify JDBC operations. It manages routine resource handling and translates many database exceptions into Spring data-access exceptions.

Example repository using JdbcTemplate

import org.springframework.jdbc.core.JdbcTemplate;
import java.math.BigDecimal;
import java.util.List;

public class EmployeeJdbcRepository {
    private final JdbcTemplate jdbcTemplate;

    public EmployeeJdbcRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<String> findNames() {
        return jdbcTemplate.query(
            "SELECT name FROM employees ORDER BY id",
            (resultSet, rowNum) -> resultSet.getString("name")
        );
    }

    public int updateSalary(long id, BigDecimal salary) {
        return jdbcTemplate.update(
            "UPDATE employees SET salary = ? WHERE id = ?",
            salary,
            id
        );
    }
}

Spring testing options

  • Use @JdbcTest for suitable JDBC-focused test slices.
  • Use @SpringBootTest when the full application context is required.
  • Use @Sql to run setup and cleanup scripts.
  • Configure a dedicated test profile and test database.
  • Verify persisted database state rather than only checking method calls.

19. Testing with Testcontainers

Testcontainers can launch disposable database instances in containers for integration testing. This is especially useful when the application relies on database-specific SQL, data types, constraints, or transaction behavior that an in-memory database may not reproduce accurately.

Advantages

  • Tests run against the same database engine family used in production.
  • SQL dialect and constraint behavior can be tested more realistically.
  • Database environments are repeatable across developers and CI systems.
  • Tests are less dependent on a manually maintained shared database.

Illustrative JDBC URL

jdbc:tc:postgresql:16-alpine:///employee_test

This URL requires the appropriate Testcontainers JDBC module, a compatible PostgreSQL driver, and a supported container runtime. Pin database image versions and initialize the schema through SQL scripts or the application's migration process.

20. Test Data Management and Cleanup

Tests should begin with a known database state. If a test depends on records created by another test, the results can become unpredictable when test order changes.

Common strategies

  • SQL fixtures: scripts create known tables and records.
  • Programmatic fixtures: helper methods insert required records.
  • Transaction rollback: reverses changes when supported by the test configuration.
  • Fresh database per test: provides strong isolation but may take longer.
  • Cleanup statements: remove test records after execution.

Best practices

  • Use synthetic test data instead of production customer records.
  • Never run automated tests against production databases.
  • Delete child records before parent records when foreign keys require it.
  • Avoid sharing mutable records between parallel tests.
  • Keep database credentials out of source control.
  • Ensure cleanup happens even when a test fails.

21. Testing Connection Management and Resource Safety

Database connections, statements, and result sets are limited resources. If they are not released correctly, the application may experience connection leaks, thread blocking, or connection pool exhaustion.

  • Verify that connections are returned to the pool after operations finish.
  • Ensure statements and result sets are closed.
  • Check that exceptions do not prevent cleanup.
  • Test connection acquisition failures.
  • Avoid unnecessarily long-running transactions.
  • Monitor connection pool usage and acquisition timeouts.

Use try-with-resources to manage JDBC resources. For pooled data sources, closing a connection normally returns it to the pool rather than physically closing the database connection.

22. Database Performance Testing

Functional tests verify correctness, while performance tests evaluate database behavior under realistic data volumes and concurrent workloads.

Area What to Measure
Query performance Execution time, query plans, and rows scanned.
Throughput Queries or transactions completed per second.
Connection pool Active connections, waiting requests, and acquisition time.
Concurrency Lock waits, deadlocks, and contention.
Large result sets Memory use, fetch behavior, and processing time.
Failure recovery Behavior during slowdowns or temporary outages.

Performance tests should use representative data and workloads. Establish thresholds based on documented requirements and a known test environment.

23. Common Mistakes in JDBC Testing

Mistake Problem Recommended Solution
Using only mocks Actual SQL remains untested. Add repository integration tests.
Using production data Creates privacy and accidental modification risks. Use isolated synthetic data.
Sharing database state Tests influence one another. Reset data or isolate databases.
Ignoring database differences Tests may pass on H2 but fail in production. Test against the production database engine when necessary.
Ignoring update counts Unexpected zero-row updates may go unnoticed. Assert affected row counts where appropriate.
Leaving resources open Connections can leak. Use try-with-resources.
Testing only successful paths Failure handling remains unverified. Add negative and boundary tests.
Depending on test order Results become unpredictable. Make tests independently repeatable.

24. Practical Project: Employee Management Database Testing

Build a Java application that manages employee records using JDBC and create an automated test suite to validate its database behavior.

Project requirements

  • Create an employee table with ID, name, email, and salary columns.
  • Implement insert, find-by-ID, list, update, and delete methods.
  • Use parameterized SQL statements.
  • Write JUnit tests for all CRUD operations.
  • Test missing records, duplicate email addresses, and invalid data.
  • Verify rollback behavior for a multi-step operation.
  • Use a dedicated test database.
  • Ensure test data is isolated and cleaned up.
  • Optionally run integration tests against a containerized database.

Suggested project structure

src/
  main/
    java/
      model/
        Employee.java
      repository/
        EmployeeRepository.java
      service/
        EmployeeService.java
    resources/
      schema.sql
  test/
    java/
      repository/
        EmployeeRepositoryIntegrationTest.java
      service/
        EmployeeServiceTest.java
    resources/
      test-data.sql

Expected outcome

The finished project should demonstrate reliable database persistence, predictable error handling, transaction safety, proper resource management, and repeatable automated tests.

25. Interview Questions and Answers

Q1. What is JDBC?

JDBC is a Java API that enables applications to connect to relational databases, execute SQL statements, and process query results.

Q2. What is the difference between unit testing and integration testing?

Unit testing isolates a component, often using mocks. Integration testing verifies that components work together, such as a repository executing SQL against a database.

Q3. Why should JDBC code use PreparedStatement?

It supports parameter binding and helps prevent SQL injection when used correctly.

Q4. What is a database fixture?

A fixture is predefined test data and setup used to establish a known database state.

Q5. Why is test data cleanup important?

It prevents one test from affecting another and improves repeatability.

Q6. How do you test a repository class?

Run integration tests against a dedicated database, invoke actual repository methods, and assert both returned results and persisted data.

Q7. Can Mockito verify that SQL works correctly?

No. Mockito verifies interactions with mock objects, but it does not execute SQL against a database.

Q8. What is the role of H2 in testing?

H2 is a lightweight relational database useful for fast tests, although it may differ from the production database.

Q9. What is Testcontainers?

Testcontainers can launch disposable containerized dependencies, including databases, for integration testing.

Q10. How do you test transaction rollback?

Perform changes in a transaction, roll it back, and verify through a subsequent query that the changes were not persisted.

Q11. Why test database constraints?

Constraints enforce data integrity even when application-level validation is bypassed.

Q12. How can connection leaks be prevented?

Use try-with-resources, configure connection pools correctly, and test failure paths.

Q13. What is the difference between commit and rollback?

Commit makes transaction changes permanent, while rollback discards uncommitted changes.

Q14. Why should tests be independent?

Independent tests can run in different orders or in parallel without unpredictable interference.

Q15. What should a database integration test verify?

It should verify SQL behavior, returned values, persisted changes, constraints, and transaction outcomes relevant to the operation.

26. Hands-On Exercises

  1. Insert an employee and retrieve the same employee by ID.
  2. Verify that a nonexistent employee ID returns an empty result.
  3. Test that duplicate email addresses violate the unique constraint.
  4. Update an employee's salary and verify the stored value.
  5. Delete an employee and confirm that the row no longer exists.
  6. Insert a record inside a transaction, roll it back, and verify the final state.
  7. Test parameter binding with unusual input strings.
  8. Configure a test database separate from development and production.
  9. Write a Mockito-based service unit test and a repository integration test.
  10. Run the test suite repeatedly to verify that it does not depend on test order.

27. Quick Revision Checklist

  • Understand JDBC connections, statements, result sets, and exceptions.
  • Separate business logic from database access code.
  • Use JUnit for automated tests and Mockito for suitable unit tests.
  • Use integration tests to verify actual SQL and persistence behavior.
  • Test CRUD operations, constraints, missing records, and invalid inputs.
  • Verify commit, rollback, and resource cleanup.
  • Keep test data isolated and repeatable.
  • Use Testcontainers when realistic database behavior is important.
  • Test security, concurrency, and performance when required.
  • Run tests automatically as part of the build and continuous integration process.

28. Conclusion

Testing JDBC and database-driven applications ensures that Java applications store, retrieve, and modify data correctly. A reliable testing strategy combines unit tests for business logic with integration tests that execute actual SQL against a dedicated database. By testing CRUD operations, database constraints, transactions, exception handling, and resource management, developers can detect defects early and build applications that are easier to maintain.

Explore the Advanced Java Training course, visit the JustAcademy website, or register for a course demo.

Previous Next
whatsapp