CodeWiz Logo

    CodeWiz

    Java Unit Testing Practical Guide and Cheat Sheet with Real-World Examples

    Java Unit Testing Practical Guide and Cheat Sheet with Real-World Examples

    10/05/2025

    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

    AnnotationLibraryDescription
    @TestJUnitMarks a method as a test case.
    @BeforeEachJUnitRuns before each test method.
    @AfterEachJUnitRuns after each test method.
    @BeforeAllJUnitRuns once before all test methods in the class (static).
    @AfterAllJUnitRuns once after all test methods in the class (static).
    @DisabledJUnitDisables a test method or class.
    @NestedJUnitAllows grouping of related tests in inner classes.
    @ParameterizedTestJUnitMarks a method as a parameterized test.
    @CsvSourceJUnitProvides CSV input for parameterized tests.
    @DisplayNameJUnitSpecifies a custom display name for a test class or method.
    @TimeoutJUnitSets a timeout for a test method or all methods in a class.
    @MockMockitoCreates and injects a mock instance.
    @InjectMocksMockitoInjects mock fields into the tested object.
    @SpyMockitoCreates a spy of a real object.
    @CaptorMockitoAllows capturing arguments passed to mocks.
    @ExtendWithJUnitRegisters extensions (e.g., MockitoExtension for Mockito integration).
    @TempDirJUnitCreates 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 equal
    • assertTrue(condition): Checks if a condition is true
    • assertFalse(condition): Checks if a condition is false
    • assertNotNull(object): Verifies an object is not null
    • assertNotEquals(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:

    1. given: Set up the test environment, including creating mock objects and defining their behavior.
    2. when: Call the method under test.
    3. 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 mock PaymentGateway into it.
    • @Captor: Captures the argument passed to the charge method of the PaymentGateway mock.
    • when and thenReturn: Stubs the behavior of the mock to return true when the charge 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