Testing Exceptions and Error Handling in JUnit

Illustration for Testing Exceptions and Error Handling in JUnit
By Last updated:

In Java applications, exceptions are inevitable — whether it’s handling invalid inputs, failed database connections, or service timeouts. Testing exception handling is crucial to ensure your application fails gracefully and communicates errors effectively. JUnit 5 provides powerful tools like assertThrows and assertDoesNotThrow to validate exception scenarios.

In this tutorial, we’ll explore testing exceptions and error handling in JUnit 5, integrate Mockito for mocking error cases, and even use Testcontainers for real-world database failure scenarios.


Why Test Exceptions?

  • Reliability: Ensure applications don’t crash unexpectedly.
  • Maintainability: Verify correct exception types and messages.
  • Security: Validate defensive coding against invalid inputs.
  • CI/CD Pipelines: Prevent regressions in error handling.
  • User Experience: Guarantee meaningful error responses in APIs.

Think of exception testing like a safety net in a circus — if something goes wrong, it should catch and handle the failure properly.


Testing Exceptions with assertThrows

The assertThrows method verifies that a block of code throws the expected exception.

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

class ExceptionTestExample {

    @Test
    void shouldThrowIllegalArgumentException() {
        assertThrows(IllegalArgumentException.class, () -> {
            throw new IllegalArgumentException("Invalid input");
        });
    }
}

If no exception or a different type is thrown, the test fails.


Verifying Exception Messages

You can capture the exception and validate its message.

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

class ExceptionMessageTest {

    @Test
    void shouldValidateExceptionMessage() {
        Exception exception = assertThrows(RuntimeException.class, () -> {
            throw new RuntimeException("Something went wrong");
        });

        assertEquals("Something went wrong", exception.getMessage());
    }
}

Ensuring Code Does Not Throw Exceptions

Use assertDoesNotThrow when you expect normal execution.

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

class NoExceptionTest {

    @Test
    void shouldNotThrowException() {
        assertDoesNotThrow(() -> {
            int result = 10 / 2;
        });
    }
}

Testing Custom Exceptions

Applications often define custom exceptions. JUnit tests can verify them.

class CustomException extends Exception {
    public CustomException(String message) {
        super(message);
    }
}

class CustomExceptionTest {

    @Test
    void shouldThrowCustomException() {
        assertThrows(CustomException.class, () -> {
            throw new CustomException("Custom error");
        });
    }
}

Exception Testing with Mockito

Mockito can simulate exceptions when dependencies fail.

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.mockito.Mockito.*;

class ServiceTest {

    interface ExternalService {
        String fetchData();
    }

    @Test
    void shouldThrowExceptionFromMock() {
        ExternalService service = mock(ExternalService.class);
        when(service.fetchData()).thenThrow(new RuntimeException("Service unavailable"));

        assertThrows(RuntimeException.class, service::fetchData);
    }
}

This is useful for testing error handling in services without relying on real dependencies.


Exception Testing with Testcontainers

You can simulate real-world failures, such as database startup issues.

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Assertions;
import org.testcontainers.containers.PostgreSQLContainer;

class DatabaseExceptionTest {

    @Test
    void shouldFailToConnectToStoppedContainer() {
        PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
        postgres.start();
        postgres.stop();

        Assertions.assertThrows(Exception.class, () -> {
            // Simulating connection attempt after container is stopped
            String url = postgres.getJdbcUrl();
            System.out.println("Attempting to connect: " + url);
            throw new Exception("Database not reachable");
        });
    }
}

Sample output:

Creating container for image: postgres:15
Container started in 12.1s
Attempting to connect: jdbc:postgresql://localhost:32789/test
Exception: Database not reachable

Real-World Use Cases

  1. Spring Boot APIs: Ensure controllers throw proper exceptions for bad requests.
  2. Microservices: Validate resilience logic for unavailable downstream services.
  3. Financial Applications: Catch invalid transaction scenarios with custom exceptions.
  4. Legacy Codebases: Refactor unsafe error handling into predictable exceptions.

Best Practices

  • Always validate both exception type and message.
  • Don’t overuse generic exceptions — test specific custom ones.
  • Combine with Mockito for service failure simulations.
  • Test both expected failure and happy path scenarios.
  • Document exception contracts in APIs and enforce with tests.

Version Tracker

  • JUnit 4 → JUnit 5: Shifted from @Test(expected=…) to assertThrows for flexibility.
  • Mockito Updates: Added support for mocking static/final methods, useful for exception testing.
  • Testcontainers Growth: Simulating database and broker errors became easier with container lifecycle management.

Conclusion & Key Takeaways

Exception testing in JUnit 5 ensures applications handle errors gracefully, predictably, and securely. With tools like assertThrows, Mockito mocks, and Testcontainers, developers can build robust test suites that catch issues before they hit production.

Key Takeaways:

  • Use assertThrows for validating exceptions.
  • Verify exception messages for clarity.
  • Leverage Mockito and Testcontainers for realistic failure scenarios.
  • Exception testing strengthens reliability in CI/CD pipelines.

FAQ

1. How is exception testing different in JUnit 4 and 5?
JUnit 4 used @Test(expected=…), while JUnit 5 uses assertThrows for more flexibility.

2. Can I test checked exceptions in JUnit 5?
Yes, assertThrows supports both checked and unchecked exceptions.

3. What’s the difference between assertThrows and assertDoesNotThrow?
assertThrows validates an exception, while assertDoesNotThrow ensures none occur.

4. Can I validate exception messages?
Yes, capture the exception and use getMessage().

5. How do I test exceptions in asynchronous code?
Wrap async calls in assertThrows or use CompletableFuture.exceptionally.

6. Can Mockito mock methods to throw exceptions?
Yes, with when(...).thenThrow(...) or doThrow(...).when(...).

7. Do exceptions work with parameterized tests?
Yes, exception assertions apply per test iteration.

8. Can Testcontainers simulate database failures?
Yes, stop containers mid-test to simulate unavailable databases.

9. Should I always test for exceptions?
Yes, especially for critical paths like APIs, DB queries, and security validations.

10. Do IDEs support exception testing?
Yes, all major IDEs support exception reporting in JUnit 5.