
Java Unit Testing Practical Guide and Cheat Sheet with Real-World Examples
Introduction
Like most of the developers, writing unit tests is the least interesting part of the software development process for me. But it is an integral part because it helps us in the future on the below points:
- Confidence: Tests give you confidence that your code works as expected and allow us to refactor safely.
- Clarity of Requirements: Writing tests forces you to clarify the requirements and expected behavior of your code. Sometimes it helps to identify requirement gaps and ambiguities.
- Regression prevention: Tests help catch bugs introduced by changes
- Faster debugging: Tests help you debug issues quickly by running parts of your code in isolation
Now let us go through the basics of unit testing in Java, focusing on JUnit, Mockito and AssertJ. This guide will cover the essential concepts, best practices, and real-world examples to help you write effective unit tests.
Setting Up JUnit 5 and Mockito
Add these dependencies to your Maven or Gradle project:
Maven:
<dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.5</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-core</artifactId> <version>5.11.0</version> <scope>test</scope> </dependency>****
Gradle:
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.5' testImplementation 'org.mockito:mockito-core:5.11.0'
If you are using Spring Boot, you can use the
spring-boot-starter-test
dependency, which includes JUnit 5 and Mockito by default.
Key Annotations
Annotation | Library | Description |
---|---|---|
@Test | JUnit | Marks a method as a test case. |
@BeforeEach | JUnit | Runs before each test method. |
@AfterEach | JUnit | Runs after each test method. |
@BeforeAll | JUnit | Runs once before all test methods in the class (static). |
@AfterAll | JUnit | Runs once after all test methods in the class (static). |
@Disabled | JUnit | Disables a test method or class. |
@Nested | JUnit | Allows grouping of related tests in inner classes. |
@ParameterizedTest | JUnit | Marks a method as a parameterized test. |
@CsvSource | JUnit | Provides CSV input for parameterized tests. |
@DisplayName | JUnit | Specifies a custom display name for a test class or method. |
@Timeout | JUnit | Sets a timeout for a test method or all methods in a class. |
@Mock | Mockito | Creates and injects a mock instance. |
@InjectMocks | Mockito | Injects mock fields into the tested object. |
@Spy | Mockito | Creates a spy of a real object. |
@Captor | Mockito | Allows capturing arguments passed to mocks. |
@ExtendWith | JUnit | Registers extensions (e.g., MockitoExtension for Mockito integration). |
@TempDir | JUnit | Creates a temporary directory for tests. |
Now let's go through some practical examples to illustrate how to use these annotations effectively.
Basic Test Structure with @Test
, @BeforeEach
, @AfterEach
, @BeforeAll
, @AfterAll
import org.junit.jupiter.api.*; class CalculatorTest { static Calculator calculator; @BeforeAll // Runs once before all tests. You can do setup here common to all tests. static void initAll() { calculator = new Calculator(); } @BeforeEach // Runs before each test. We can do anything that need to be done before each test. void init() { calculator.reset(); } @Test void testAddition() { //Actual test method Assertions.assertEquals(5, calculator.add(2, 3)); } @AfterEach // Runs after each test. We can do anything that need to be done after each test. void tearDown() { } @AfterAll// Runs once after all tests. You can do cleanup here common to all tests. static void tearDownAll() { } }
Basic Assertions with Junit Assertions
class
@Test void testBasicAssertions() { // Call the actual method Calculator calculator = new Calculator(); int sum = calculator.add(5, 3); double squareRoot = calculator.squareRoot(16); boolean isEven = calculator.isEven(10); // Basic assertions assertEquals(8, sum, "5 + 3 should equal 8"); assertTrue(isEven, "10 should be even"); assertFalse(calculator.isEven(7), "7 should not be even"); assertEquals(4.0, squareRoot, "Square root of 16 should be 4"); assertNotEquals(0, sum, "Sum should not be zero"); assertNotNull(calculator, "Calculator should not be null"); }
JUnit provides various assertion methods to validate expected outcomes:
assertEquals(expected, actual)
: Verifies values are equalassertTrue(condition)
: Checks if a condition is trueassertFalse(condition)
: Checks if a condition is falseassertNotNull(object)
: Verifies an object is not nullassertNotEquals(unexpected, actual)
: Verifies values are not equal
The optional message parameter in each assertion helps clarify the test's purpose and makes failure messages more informative for debugging.
Disabling Tests with @Disabled
@Test @Disabled("Feature not implemented yet") void testSubtraction() { // This test will be skipped }
Grouping Tests with @Nested
-
Purpose:
- Organizes related test cases into inner classes, improving test structure and readability.
- Allows hierarchical grouping of tests to reflect logical relationships or scenarios.
- Enables encapsulation of setup/teardown logic specific to a group of tests using their own
@BeforeEach
,@AfterEach
, etc.
-
When to Use:
- When you want to group tests that share a common context or setup.
- When testing different behaviors or states of a class (e.g., "when account is active", "when account is closed").
class OrderServiceTest { OrderService orderService = new OrderService(); @BeforeEach void setUp() { // common setup logic for all tests @Nested class CreateOrderTests { @Test void testCreateOrderSuccess() { // test logic } @BeforeEach void setUp() { // setup logic for create order tests } } @Nested class CancelOrderTests { @Test void testCancelOrderSuccess() { // test logic } @BeforeEach void setUp() { // setup logic for cancel order tests } } }
Parameterized Tests
- Purpose:
- Allows running the same test with different sets of input data.
- Reduces code duplication by avoiding multiple test methods for similar logic.
- When to Use:
- When you have a method that behaves similarly for different inputs.
- When you want to test edge cases or boundary conditions with multiple values.
- When you want to validate the same logic with different configurations or parameters.
class StringUtilsTest { @ParameterizedTest @CsvSource({ "hello,HELLO", "world,WORLD" }) void testToUpper(String input, String expected) { Assertions.assertEquals(expected, StringUtils.toUpper(input)); } }
@CsvSource
allows you to provide multiple sets of parameters in a CSV format. Each line represents a different set of parameters for the test method.
Parameterized Tests with @MethodSource
and Java Records
For more complex test data or when you need structured objects, @MethodSource
is ideal, especially with Java records:
@ParameterizedTest @MethodSource("discountTestCases") void testDiscountCalculation(DiscountTestCase testCase) { OrderDiscountCalculator calculator = new OrderDiscountCalculator(); BigDecimal actualDiscount = calculator.calculateDiscount( testCase.amount(), testCase.customerType(), testCase.isHoliday() ); assertEquals(testCase.expectedDiscount(), actualDiscount); } // Using a record for clean, immutable test data record DiscountTestCase( BigDecimal amount, CustomerType customerType, boolean isHoliday,BigDecimal expectedDiscount ) {} // Method source providing test data static Stream<DiscountTestCase> discountTestCases() { return Stream.of( new DiscountTestCase( new BigDecimal("50.00"), CustomerType.REGULAR, false, new BigDecimal("0.00") ), new DiscountTestCase( new BigDecimal("120.00"), CustomerType.PREMIUM, false, new BigDecimal("12.00") ), new DiscountTestCase( new BigDecimal("100.00"), CustomerType.VIP, true, new BigDecimal("20.00") ) ); }
Similar to @CsvSource
and @MethodSource
, there are other parameterized test sources, some common ones are:
@ValueSource
: Provides a single value or array of values.@EnumSource
: Provides values from an enum.@ArgumentsSource
: Provides parameters from a custom source.@NullSource
: Provides null as a parameter.@EmptySource
: Provides an empty string as a parameter.@NullAndEmptySource
: Provides both null and empty string as parameters.@CsvFileSource
: Provides parameters from a file.@FieldSource
: Provides parameters from a field.
Using Mockito to mock dependencies
If the class you are testing has dependencies, you can use Mockito to create mocks of those dependencies. This allows you to isolate the unit under test and control its behavior.
Suppose you have a PaymentService
class that depends on a PaymentGateway
interface:
public class PaymentService { private final PaymentGateway paymentGateway; private final Logger logger; public PaymentService(PaymentGateway paymentGateway, Logger logger) { this.paymentGateway = paymentGateway; this.logger = logger; } public boolean processPayment(double amount) { boolean success = paymentGateway.charge(amount); if (success) { logger.log("Payment processed: " + amount); } return success; } }
We can write a unit test for PaymentService
using Mockito to mock the PaymentGateway
and Logger
dependencies
Basically we can write tests with the below steps:
- given: Set up the test environment, including creating mock objects and defining their behavior.
- when: Call the method under test.
- then: Verify the results and interactions with the mocks.
@ExtendWith(MockitoExtension.class) class PaymentServiceTest { @Mock PaymentGateway paymentGateway; @Spy Logger logger = new Logger(); @InjectMocks PaymentService paymentService; @Captor ArgumentCaptor<Double> amountCaptor; @Test void testProcessPayment_Success() { // Given when(paymentGateway.charge(anyDouble())).thenReturn(true); // When boolean result = paymentService.processPayment(100.0); // Then assertTrue(result); verify(paymentGateway).charge(amountCaptor.capture()); assertEquals(100.0, amountCaptor.getValue()); verify(logger).log("Payment processed: 100.0"); } }
- @ExtendWith(MockitoExtension): Integrates Mockito with JUnit 5, allowing you to use Mockito annotations like
@Mock
,@InjectMocks
, etc. - @Mock: Creates a mock instance of the
PaymentGateway
class. After that when we call any method invocation will be on the mock object. - @Spy: Creates a spy instance of the
Logger
class, allowing you to verify interactions with it. We can use@Spy
when we want to use the real object but still want to verify interactions with it. - @InjectMocks: Creates an instance of
PaymentService
and injects the mockPaymentGateway
into it. - @Captor: Captures the argument passed to the
charge
method of thePaymentGateway
mock. - when and thenReturn: Stubs the behavior of the mock to return
true
when thecharge
method is called with any double value. - verify: Verifies that the
charge
method was called with the captured amount and that the logger logged the expected message.
Multiple Return Values (Consecutive Calls)
@Test void testConsecutiveCalls() { // First call returns true, subsequent calls return false when(connectionPool.getConnection()) .thenReturn(new Connection()) // first call .thenReturn(new Connection()) // second call .thenThrow(new ConnectionException()); // third call assertNotNull(service.connect()); // first call works assertNotNull(service.connect()); // second call works assertThrows(ServiceException.class, () -> service.connect()); // third call fails }
Verifying No Interactions or Only Specific Interactions
@Test void testNoUnwantedInteractions() { paymentService.validateAmount(50.0); // Verify that only validation happened, no actual payment processing verify(validator).validate(50.0); verifyNoInteractions(paymentGateway, logger); // Or verify no more interactions beyond what we've already verified verifyNoMoreInteractions(validator); }
Making Tests More Readable with @DisplayName
@Test @DisplayName("Addition: 2 + 3 should equal 5") void testAddition() { Assertions.assertEquals(5, calculator.add(2, 3)); } @Nested @DisplayName("When dividing numbers") class DivisionTests { @Test @DisplayName("Should return correct result for valid inputs") void testValidDivision() { assertEquals(2, calculator.divide(10, 5)); } @Test @DisplayName("Should throw exception when dividing by zero") void testDivisionByZero() { assertThrows(ArithmeticException.class, () -> calculator.divide(10, 0)); } }
Using @DisplayName
makes your test reports more readable and descriptive, especially when tests fail. They appear in IDEs and test reports with the custom name instead of the method name.
Exception Testing
@Test void testThrowsException() { assertThrows(IllegalArgumentException.class, () -> { calculator.divide(10, 0); }); }
Capturing Arguments with @Captor
@Captor ArgumentCaptor<String> stringCaptor; @Test void testLoggerCalledWithCorrectMessage() { paymentService.pay(50.0); verify(logger).log(stringCaptor.capture()); assertEquals("Payment processed: 50.0", stringCaptor.getValue()); }
Using Assumptions for Conditional Test Execution
JUnit provides a way to conditionally execute tests based on certain conditions being met. Tests that don't meet the assumptions are skipped rather than failing.
@Test void testRunsOnlyInDevelopmentEnvironment() { // Skip this test if not in development environment assumeTrue("dev".equals(System.getenv("ENV")), "Test runs only in development environment"); // Test code here - only executes if the assumption is valid } @Test void testWithMultipleAssumptions() { // Skip if any assumption is not met assumingThat(System.getProperty("os.name").contains("Linux"), () -> { // This code runs only on Linux assertEquals(10, linuxSpecificOperation()); }); // This code runs regardless of OS assertEquals(42, platformAgnosticOperation()); }
Testing Collections
JUnit provides straightforward ways to test Java collections. Here are key approaches focusing on standard JUnit assertions:
Basic Collection Testing
@Test void testBasicCollectionOperations() { List<String> fruits = Arrays.asList("apple", "banana", "orange"); // Size and content verification assertEquals(3, fruits.size()); assertTrue(fruits.contains("apple")); assertFalse(fruits.contains("grape")); // Testing equality List<String> expected = Arrays.asList("apple", "banana", "orange"); assertEquals(expected, fruits); }
Testing Collection Properties
@Test void testCollectionOfObjects() { record Product(String name, double price, String category) {} List<Product> products = List.of( new Product("Laptop", 1200.0, "Electronics"), new Product("Book", 15.0, "Literature"), new Product("Monitor", 250.0, "Electronics") ); assertEquals(3, products.size()); // Count electronics products long electronicsCount = products.stream() .filter(p -> p.category().equals("Electronics")) .count(); assertEquals(2, electronicsCount); // Verify specific products exist boolean hasLaptop = products.stream() .anyMatch(p -> p.name().equals("Laptop")); assertTrue(hasLaptop); }
AssertJ provides more fluent and expressive assertions for collections, making it easier to read and understand the tests. We will cover this later in the article.
Testing Performance with @Timeout
JUnit provides built-in support for testing that methods complete within a specified time:
@Test @Timeout(value = 500, unit = TimeUnit.MILLISECONDS) void testMethodCompletesQuickly() { // This test will fail if it takes longer than 500ms service.performFastOperation(); } // You can also apply timeout to all methods in a class @Timeout(10) class PerformanceSensitiveTest { // All tests in this class will fail if they take longer than 10 seconds } // Or use the assertions API for more complex timeout scenarios @Test void testAsyncOperation() { // Test that an asynchronous operation completes within 5 seconds assertTimeoutPreemptively(Duration.ofSeconds(5), () -> { return service.asyncOperation(); }); }
Timeout testing is essential for:
- Catching performance regressions
- Ensuring response time requirements are met
- Preventing tests from hanging indefinitely
Modern Assertion Libraries
While JUnit provides basic assertions with Assertions
class, modern testing often benefits from more expressive and fluent assertion libraries like AssertJ and Hamcrest. Out of these two, AssertJ is the most popular one.
AssertJ Fluent Assertions
Add the below dependency to your Maven or Gradle project:
Maven:
<dependency> <groupId>org.assertj</groupId> <artifactId>assertj-core</artifactId> <version>3.27.3</version> <scope>test</scope> </dependency>
If you are using Spring Boot, you can use the
spring-boot-starter-test
dependency, which includes AssertJ by default.
AssertJ provides a more fluent API with better readability and error messages:
// String assertions assertThat("hello").startsWith("he").endsWith("lo").hasSize(5); // Collection assertions assertThat(List.of("apple", "banana", "orange")) .hasSize(3) .contains("apple") .doesNotContain("kiwi"); record Customer(String name, int age) {} var customerList = List.of(new Customer("John",25), new Customer("Jane",23), new Customer("Doe", 30)); assertThat(customerList) .extracting(Customer::name) .containsExactly("John", "Jane", "Doe"); // Exception assertions assertThatThrownBy(() -> divide(10, 0)) .isInstanceOf(ArithmeticException.class) .hasMessageContaining("zero"); // Object assertions with property extraction var customer = getCustomer(); assertThat(customer) .extracting(Customer::name, Customer::age) .containsExactly("John", 25); // Map assertions assertThat(map) .containsEntry("key1", "value1") .doesNotContainKey("key2") .hasSize(2); // Date assertions assertThat(date) .isAfter(LocalDate.of(2023, 1, 1)) .isBefore(LocalDate.of(2024, 1, 1)); // Regex assertions assertThat("abc123").matches("[a-z]{3}\\d{3}");
Static Mocking with Mockito
Static methods present a challenge for unit testing because they're globally accessible and not typically dependency-injected. Mockito provides the ability to mock static methods with its mockStatic()
API.
Imagine we have a utility class with static methods:
public class DateTimeUtils { public static LocalDate getCurrentDate() { return LocalDate.now(); } public static boolean isWeekend(LocalDate date) { DayOfWeek dayOfWeek = date.getDayOfWeek(); return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY; } }
And a service that uses it:
public class WorkdayService { public boolean isBusinessDay() { return !DateTimeUtils.isWeekend(DateTimeUtils.getCurrentDate()); } }
Here's how to test this with static mocking:
class WorkdayServiceTest { @Test void testIsBusinessDay() { // Static mocking done in a try-with-resources block so that mock is closed automatically try (MockedStatic<DateTimeUtils> mockedDateUtils = Mockito.mockStatic(DateTimeUtils.class)) { // Setup static mock to return Monday (business day) LocalDate monday = LocalDate.of(2023, 5, 15); // A Monday mockedDateUtils.when(DateTimeUtils::getCurrentDate).thenReturn(monday); WorkdayService service = new WorkdayService(); assertTrue(service.isBusinessDay()); // Verify the static method was called mockedDateUtils.verify(DateTimeUtils::getCurrentDate); // Now test for weekend LocalDate saturday = LocalDate.of(2023, 5, 20); // A Saturday mockedDateUtils.when(DateTimeUtils::getCurrentDate).thenReturn(saturday); assertFalse(service.isBusinessDay()); } } }
Soft Assertions
Soft assertions allow you to collect multiple failures in a single test run instead of stopping at the first failed assertion. This is particularly useful when validating complex objects with multiple properties or when performing multiple validations in a single test.
Using AutoCloseableSoftAssertions with AssertJ
The most elegant way I have seen is to use soft assertions with AssertJ is with AutoCloseableSoftAssertions
in a try-with-resources block:
class UserSoftAssertionTest { @Test void testUserWithSoftAssertions() { User user = new User("john", "john@example.com", 17); try (AutoCloseableSoftAssertions softly = new AutoCloseableSoftAssertions()) { softly.assertThat(user.getUsername()) .as("Username validation") .isNotEmpty() .startsWith("K") // This will fail but test continues .hasSize(4); softly.assertThat(user.getEmail()) .as("Email validation") .contains("@") .endsWith(".com"); softly.assertThat(user.getAge()) .as("Age validation") .isGreaterThan(18); } } }
JUnit 5 also provides a built-in mechanism for soft assertions using assertAll()
:
@Test void testUserWithJUnitGroupedAssertions() { User user = new User("john", "john@example.com", 17); assertAll("User validation", () -> assertEquals("john", user.getUsername(), "Username should match"), () -> assertTrue(user.getEmail().contains("@"), "Email should contain @"), () -> assertTrue(user.getAge() >= 18, "User should be an adult") // Fails but other assertions run ); }
When to Use Soft Assertions
Soft assertions are valuable when:
- Testing complex objects with multiple properties
- Checking multiple aspects of a response or result
- Performing extensive validation where seeing all failures at once saves debugging time
Test Coverage and Tools
- JaCoCo: Popular code coverage tool for Java. Integrates with Maven/Gradle and CI pipelines.
- IntelliJ IDEA / Eclipse: Both provide built-in test runners and coverage visualization.
Best Practices
- Name test methods clearly to describe the scenario and expected outcome.
- Test one behavior per test method.
- Use parameterized tests for repetitive logic.
- Avoid testing implementation details; focus on observable behavior.
- Use mocks to isolate the unit under test.
- Clean up resources in
@AfterEach
or@AfterAll
if needed. - Keep tests fast and independent.
- Run tests automatically in CI/CD pipelines.
Integration Tests
Unit tests are fast and focused, but integration tests are needed when:
- Testing interactions between multiple components (e.g., service and database)
- Verifying configuration, wiring, or external dependencies
- Ensuring end-to-end flows work as expected
For Spring Boot projects, see: Spring Boot Integration Testing
Conclusion
Unit testing is essential for building reliable applications. By leveraging JUnit, Mockito,AssertJ and best practices, you can write tests that are easy to maintain and provide confidence in your codebase. Start small, test often, and let your tests guide your design!
Additional Resources
To stay updated with the latest tutorials and tips on Java, Spring, and modern software development, follow me:
š Blog
š YouTube
š LinkedIn
š Medium
š Github
Related Posts
Master Integration Testing Spring Boot API using Testcontainers
Learn how to create efficient integration tests for your Spring Boot APIs using Testcontainers and the Rest Assured library. This blog will guide you through building fluent integration tests for an API that interacts with MongoDB and AWS S3.
Spring Data JPA: Complete Guide and Cheat Sheet
Comprehensive guide to Spring Data JPA with practical examples and best practices. Learn how to effectively use JPA in Spring Boot applications.