1. java
  2. /testing
  3. /unit-testing

Complete Guide to Unit Testing in Java

Unit Testing in Java

Unit testing is the practice of testing individual components or modules of software in isolation. In Java, JUnit is the most popular framework for writing and running unit tests, providing annotations, assertions, and tools for comprehensive testing.

Table of Contents

Testing Fundamentals

What is Unit Testing?

Unit testing involves testing individual units of code (typically methods or classes) in isolation to ensure they behave as expected. Key principles include:

  • Fast execution: Tests should run quickly
  • Independent: Tests should not depend on each other
  • Repeatable: Tests should produce the same results every time
  • Self-validating: Tests should clearly pass or fail
  • Timely: Tests should be written close to when the code is written

Testing Pyramid

    /\
   /  \
  / UI \     ← Few, slow, expensive
 /______\
/        \
| Integration |  ← Some, medium speed
|____________|
|            |
|    Unit    |  ← Many, fast, cheap
|____________|

JUnit 5 Overview

Maven Dependencies

<dependencies>
    <!-- JUnit 5 -->
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- AssertJ for fluent assertions -->
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.24.2</version>
        <scope>test</scope>
    </dependency>
    
    <!-- Mockito for mocking -->
    <dependency>
        <groupId>org.mockito</groupId>
        <artifactId>mockito-core</artifactId>
        <version>5.5.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-surefire-plugin</artifactId>
            <version>3.1.2</version>
        </plugin>
    </plugins>
</build>

JUnit 5 Architecture

// JUnit 5 consists of three modules:
// 1. JUnit Platform - Foundation for launching testing frameworks
// 2. JUnit Jupiter - New programming and extension model 
// 3. JUnit Vintage - Support for JUnit 3 and 4

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

// Basic test class structure
class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("Should add two positive numbers")
    void shouldAddTwoPositiveNumbers() {
        // Given
        int a = 5;
        int b = 3;
        
        // When
        int result = calculator.add(a, b);
        
        // Then
        Assertions.assertEquals(8, result);
    }
}

Writing Basic Tests

Simple Calculator Example

// Calculator class to test
public class Calculator {
    
    public int add(int a, int b) {
        return a + b;
    }
    
    public int subtract(int a, int b) {
        return a - b;
    }
    
    public int multiply(int a, int b) {
        return a * b;
    }
    
    public double divide(int a, int b) {
        if (b == 0) {
            throw new IllegalArgumentException("Division by zero");
        }
        return (double) a / b;
    }
    
    public boolean isEven(int number) {
        return number % 2 == 0;
    }
    
    public int factorial(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("Factorial of negative number");
        }
        if (n <= 1) {
            return 1;
        }
        return n * factorial(n - 1);
    }
}

Comprehensive Test Class

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;

@DisplayName("Calculator Tests")
class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Nested
    @DisplayName("Addition Tests")
    class AdditionTests {
        
        @Test
        @DisplayName("Should add two positive numbers")
        void shouldAddTwoPositiveNumbers() {
            // Arrange
            int a = 5;
            int b = 3;
            
            // Act
            int result = calculator.add(a, b);
            
            // Assert
            assertEquals(8, result, "5 + 3 should equal 8");
        }
        
        @Test
        @DisplayName("Should add positive and negative numbers")
        void shouldAddPositiveAndNegativeNumbers() {
            assertEquals(2, calculator.add(5, -3));
            assertEquals(-2, calculator.add(-5, 3));
        }
        
        @Test
        @DisplayName("Should handle zero")
        void shouldHandleZero() {
            assertEquals(5, calculator.add(5, 0));
            assertEquals(5, calculator.add(0, 5));
            assertEquals(0, calculator.add(0, 0));
        }
    }
    
    @Nested
    @DisplayName("Division Tests")
    class DivisionTests {
        
        @Test
        @DisplayName("Should divide two positive numbers")
        void shouldDivideTwoPositiveNumbers() {
            double result = calculator.divide(10, 2);
            assertEquals(5.0, result, 0.001);
        }
        
        @Test
        @DisplayName("Should handle decimal results")
        void shouldHandleDecimalResults() {
            double result = calculator.divide(10, 3);
            assertEquals(3.333, result, 0.001);
        }
        
        @Test
        @DisplayName("Should throw exception when dividing by zero")
        void shouldThrowExceptionWhenDividingByZero() {
            IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.divide(10, 0),
                "Division by zero should throw IllegalArgumentException"
            );
            
            assertEquals("Division by zero", exception.getMessage());
        }
    }
    
    @Nested
    @DisplayName("Factorial Tests")
    class FactorialTests {
        
        @Test
        @DisplayName("Should calculate factorial of positive numbers")
        void shouldCalculateFactorialOfPositiveNumbers() {
            assertEquals(1, calculator.factorial(0));
            assertEquals(1, calculator.factorial(1));
            assertEquals(2, calculator.factorial(2));
            assertEquals(6, calculator.factorial(3));
            assertEquals(24, calculator.factorial(4));
            assertEquals(120, calculator.factorial(5));
        }
        
        @Test
        @DisplayName("Should throw exception for negative numbers")
        void shouldThrowExceptionForNegativeNumbers() {
            IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.factorial(-1)
            );
            
            assertEquals("Factorial of negative number", exception.getMessage());
        }
        
        @ParameterizedTest
        @ValueSource(ints = {-5, -2, -1})
        @DisplayName("Should throw exception for any negative number")
        void shouldThrowExceptionForAnyNegativeNumber(int number) {
            assertThrows(
                IllegalArgumentException.class,
                () -> calculator.factorial(number)
            );
        }
    }
}

Assertions and Matchers

JUnit 5 Assertions

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

class AssertionExamplesTest {
    
    @Test
    void basicAssertions() {
        // Basic equality
        assertEquals(4, 2 + 2);
        assertEquals("Hello", "Hello");
        
        // Null checks
        assertNull(null);
        assertNotNull("not null");
        
        // Boolean assertions
        assertTrue(true);
        assertFalse(false);
        
        // Array and collection assertions
        assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
        assertIterableEquals(List.of(1, 2, 3), List.of(1, 2, 3));
        
        // Custom error messages
        assertEquals(4, 2 + 2, "2 + 2 should equal 4");
        assertEquals(4, 2 + 2, () -> "2 + 2 should equal 4");
    }
    
    @Test
    void assertAllExample() {
        Person person = new Person("John", "Doe", 30);
        
        // Group multiple assertions
        assertAll("person",
            () -> assertEquals("John", person.getFirstName()),
            () -> assertEquals("Doe", person.getLastName()),
            () -> assertEquals(30, person.getAge())
        );
    }
    
    @Test
    void exceptionAssertions() {
        // Assert that exception is thrown
        Exception exception = assertThrows(
            IllegalArgumentException.class,
            () -> Integer.parseInt("not a number")
        );
        
        // Assert exception message
        assertTrue(exception.getMessage().contains("For input string"));
        
        // Assert no exception is thrown
        assertDoesNotThrow(() -> Integer.parseInt("123"));
    }
    
    @Test
    void timeoutAssertions() {
        // Assert that operation completes within timeout
        assertTimeout(Duration.ofSeconds(2), () -> {
            // Simulated operation
            Thread.sleep(1000);
            return "result";
        });
        
        // Preemptively timeout (stops execution after timeout)
        assertTimeoutPreemptively(Duration.ofMillis(100), () -> {
            Thread.sleep(50);
            return "result";
        });
    }
}

AssertJ Fluent Assertions

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

class AssertJExamplesTest {
    
    @Test
    void fluentAssertions() {
        String name = "John Doe";
        
        // String assertions
        assertThat(name)
            .isNotNull()
            .isNotEmpty()
            .contains("John")
            .startsWith("John")
            .endsWith("Doe")
            .hasSize(8);
        
        // Number assertions
        assertThat(42)
            .isPositive()
            .isGreaterThan(40)
            .isLessThan(50)
            .isBetween(40, 50);
        
        // Collection assertions
        List<String> names = Arrays.asList("John", "Jane", "Bob");
        assertThat(names)
            .hasSize(3)
            .contains("John", "Jane")
            .doesNotContain("Alice")
            .startsWith("John")
            .endsWith("Bob");
        
        // Exception assertions
        assertThatThrownBy(() -> {
            throw new IllegalArgumentException("Invalid argument");
        })
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("Invalid argument")
        .hasMessageContaining("Invalid");
        
        // No exception assertion
        assertThatCode(() -> {
            // Some operation that shouldn't throw
        }).doesNotThrowAnyException();
    }
    
    @Test
    void objectAssertions() {
        Person person = new Person("John", "Doe", 30);
        
        assertThat(person)
            .isNotNull()
            .hasFieldOrPropertyWithValue("firstName", "John")
            .hasFieldOrPropertyWithValue("age", 30);
        
        // Custom assertion
        assertThat(person)
            .satisfies(p -> {
                assertThat(p.getFirstName()).isEqualTo("John");
                assertThat(p.getAge()).isGreaterThan(18);
            });
    }
    
    @Test
    void softAssertions() {
        // Collect all assertion errors instead of failing on first
        SoftAssertions softly = new SoftAssertions();
        
        Person person = new Person("John", "Doe", 30);
        
        softly.assertThat(person.getFirstName()).isEqualTo("John");
        softly.assertThat(person.getLastName()).isEqualTo("Doe");
        softly.assertThat(person.getAge()).isEqualTo(30);
        
        softly.assertAll(); // Triggers all collected assertions
    }
}

Test Lifecycle

Lifecycle Annotations

class LifecycleTest {
    
    @BeforeAll
    static void beforeAll() {
        // Executed once before all test methods
        System.out.println("@BeforeAll - executed once before all tests");
    }
    
    @BeforeEach
    void beforeEach() {
        // Executed before each test method
        System.out.println("@BeforeEach - executed before each test");
    }
    
    @Test
    void testOne() {
        System.out.println("Test 1");
    }
    
    @Test
    void testTwo() {
        System.out.println("Test 2");
    }
    
    @AfterEach
    void afterEach() {
        // Executed after each test method
        System.out.println("@AfterEach - executed after each test");
    }
    
    @AfterAll
    static void afterAll() {
        // Executed once after all test methods
        System.out.println("@AfterAll - executed once after all tests");
    }
}

Test Instance Lifecycle

import org.junit.jupiter.api.TestInstance;

// Default: PER_METHOD (new instance for each test)
// Alternative: PER_CLASS (single instance for all tests)
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class TestInstanceExample {
    
    private int counter = 0;
    
    @BeforeAll
    void beforeAll() {
        // Non-static when using PER_CLASS
        counter = 100;
    }
    
    @Test
    void testOne() {
        counter++;
        assertEquals(101, counter);
    }
    
    @Test
    void testTwo() {
        counter++;
        // With PER_CLASS, counter retains value from previous test
        assertEquals(102, counter);
    }
}

Parameterized Tests

Value Source Parameterized Tests

class ParameterizedTestExamples {
    
    @ParameterizedTest
    @ValueSource(ints = {1, 2, 3, 5, 8, 13})
    @DisplayName("Should return true for positive numbers")
    void shouldReturnTrueForPositiveNumbers(int number) {
        assertTrue(number > 0);
    }
    
    @ParameterizedTest
    @ValueSource(strings = {"", "  ", "\t", "\n"})
    @DisplayName("Should return true for blank strings")
    void shouldReturnTrueForBlankStrings(String input) {
        assertTrue(input == null || input.trim().isEmpty());
    }
    
    @ParameterizedTest
    @ValueSource(doubles = {0.1, 0.5, 0.99, 1.0})
    @DisplayName("Should return true for valid percentages")
    void shouldReturnTrueForValidPercentages(double percentage) {
        assertTrue(percentage >= 0.0 && percentage <= 1.0);
    }
}

CSV Source Parameterized Tests

class CsvParameterizedTests {
    
    @ParameterizedTest
    @CsvSource({
        "1, 1, 2",
        "2, 3, 5",
        "5, 7, 12",
        "-1, 1, 0"
    })
    @DisplayName("Should add two numbers correctly")
    void shouldAddTwoNumbersCorrectly(int a, int b, int expected) {
        Calculator calculator = new Calculator();
        assertEquals(expected, calculator.add(a, b));
    }
    
    @ParameterizedTest
    @CsvSource({
        "apple, APPLE",
        "hello world, HELLO WORLD",
        "'', ''",
        "MiXeD cAsE, MIXED CASE"
    })
    @DisplayName("Should convert strings to uppercase")
    void shouldConvertStringsToUppercase(String input, String expected) {
        assertEquals(expected, input.toUpperCase());
    }
    
    @ParameterizedTest
    @CsvFileSource(resources = "/test-data.csv", numLinesToSkip = 1)
    @DisplayName("Should process CSV file data")
    void shouldProcessCsvFileData(String name, int age, String city) {
        assertThat(name).isNotBlank();
        assertThat(age).isPositive();
        assertThat(city).isNotBlank();
    }
}

Method Source Parameterized Tests

class MethodSourceTests {
    
    @ParameterizedTest
    @MethodSource("provideStringsForIsBlank")
    @DisplayName("Should identify blank strings")
    void shouldIdentifyBlankStrings(String input, boolean expected) {
        assertEquals(expected, input == null || input.trim().isEmpty());
    }
    
    static Stream<Arguments> provideStringsForIsBlank() {
        return Stream.of(
            Arguments.of(null, true),
            Arguments.of("", true),
            Arguments.of("  ", true),
            Arguments.of("\t", true),
            Arguments.of("hello", false),
            Arguments.of(" hello ", false)
        );
    }
    
    @ParameterizedTest
    @MethodSource("providePeopleForValidation")
    @DisplayName("Should validate person objects")
    void shouldValidatePersonObjects(Person person, boolean expectedValid) {
        PersonValidator validator = new PersonValidator();
        assertEquals(expectedValid, validator.isValid(person));
    }
    
    static Stream<Arguments> providePeopleForValidation() {
        return Stream.of(
            Arguments.of(new Person("John", "Doe", 25), true),
            Arguments.of(new Person("", "Doe", 25), false),
            Arguments.of(new Person("John", "", 25), false),
            Arguments.of(new Person("John", "Doe", -1), false),
            Arguments.of(new Person("John", "Doe", 200), false)
        );
    }
}

Enum Source and Custom Argument Providers

class AdvancedParameterizedTests {
    
    enum Status {
        ACTIVE, INACTIVE, PENDING, SUSPENDED
    }
    
    @ParameterizedTest
    @EnumSource(Status.class)
    @DisplayName("Should handle all status values")
    void shouldHandleAllStatusValues(Status status) {
        assertNotNull(status);
        assertTrue(status.name().length() > 0);
    }
    
    @ParameterizedTest
    @EnumSource(value = Status.class, names = {"ACTIVE", "PENDING"})
    @DisplayName("Should handle active statuses")
    void shouldHandleActiveStatuses(Status status) {
        assertTrue(Set.of(Status.ACTIVE, Status.PENDING).contains(status));
    }
    
    // Custom argument provider
    @ParameterizedTest
    @ArgumentsSource(CustomArgumentProvider.class)
    @DisplayName("Should handle custom arguments")
    void shouldHandleCustomArguments(String input, int expectedLength) {
        assertEquals(expectedLength, input.length());
    }
    
    static class CustomArgumentProvider implements ArgumentsProvider {
        @Override
        public Stream<? extends Arguments> provideArguments(ExtensionContext context) {
            return Stream.of(
                Arguments.of("hello", 5),
                Arguments.of("world", 5),
                Arguments.of("test", 4)
            );
        }
    }
}

Conditional Tests

Operating System Conditions

class ConditionalTests {
    
    @Test
    @EnabledOnOs(OS.WINDOWS)
    @DisplayName("Should run only on Windows")
    void shouldRunOnlyOnWindows() {
        // Test specific to Windows
        assertTrue(System.getProperty("os.name").toLowerCase().contains("windows"));
    }
    
    @Test
    @EnabledOnOs({OS.LINUX, OS.MAC})
    @DisplayName("Should run on Unix-like systems")
    void shouldRunOnUnixLikeSystems() {
        String os = System.getProperty("os.name").toLowerCase();
        assertTrue(os.contains("linux") || os.contains("mac"));
    }
    
    @Test
    @DisabledOnOs(OS.WINDOWS)
    @DisplayName("Should not run on Windows")
    void shouldNotRunOnWindows() {
        // Test that should be skipped on Windows
        assertFalse(System.getProperty("os.name").toLowerCase().contains("windows"));
    }
}

Java Version Conditions

class JavaVersionTests {
    
    @Test
    @EnabledOnJre(JRE.JAVA_8)
    @DisplayName("Should run only on Java 8")
    void shouldRunOnlyOnJava8() {
        assertTrue(System.getProperty("java.version").startsWith("1.8"));
    }
    
    @Test
    @EnabledOnJre({JRE.JAVA_11, JRE.JAVA_17})
    @DisplayName("Should run on Java 11 or 17")
    void shouldRunOnJava11Or17() {
        String version = System.getProperty("java.version");
        assertTrue(version.startsWith("11") || version.startsWith("17"));
    }
    
    @Test
    @EnabledForJreRange(min = JRE.JAVA_11)
    @DisplayName("Should run on Java 11 or newer")
    void shouldRunOnJava11OrNewer() {
        // Test for Java 11+ features
        assertDoesNotThrow(() -> List.of("test"));
    }
}

System Property and Environment Conditions

class SystemPropertyTests {
    
    @Test
    @EnabledIfSystemProperty(named = "env", matches = "dev")
    @DisplayName("Should run only in development environment")
    void shouldRunOnlyInDevelopment() {
        assertEquals("dev", System.getProperty("env"));
    }
    
    @Test
    @DisabledIfSystemProperty(named = "ci", matches = "true")
    @DisplayName("Should not run in CI environment")
    void shouldNotRunInCI() {
        assertNotEquals("true", System.getProperty("ci"));
    }
    
    @Test
    @EnabledIfEnvironmentVariable(named = "TEST_ENV", matches = "integration")
    @DisplayName("Should run only for integration tests")
    void shouldRunOnlyForIntegrationTests() {
        assertEquals("integration", System.getenv("TEST_ENV"));
    }
}

Custom Conditions

class CustomConditionalTests {
    
    @Test
    @EnabledIf("customCondition")
    @DisplayName("Should run when custom condition is met")
    void shouldRunWhenCustomConditionIsMet() {
        // Test that runs when customCondition() returns true
        assertTrue(true);
    }
    
    boolean customCondition() {
        // Custom logic to determine if test should run
        return LocalDateTime.now().getHour() < 18; // Run only during work hours
    }
    
    @Test
    @DisabledIf("isWeekend")
    @DisplayName("Should not run on weekends")
    void shouldNotRunOnWeekends() {
        DayOfWeek today = LocalDateTime.now().getDayOfWeek();
        assertTrue(today != DayOfWeek.SATURDAY && today != DayOfWeek.SUNDAY);
    }
    
    boolean isWeekend() {
        DayOfWeek today = LocalDateTime.now().getDayOfWeek();
        return today == DayOfWeek.SATURDAY || today == DayOfWeek.SUNDAY;
    }
}

Test Organization

Test Suites

import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.SelectPackages;
import org.junit.platform.suite.api.Suite;

@Suite
@SelectClasses({
    CalculatorTest.class,
    PersonTest.class,
    ValidationTest.class
})
class AllUnitTests {
    // Test suite that runs specific test classes
}

@Suite
@SelectPackages("com.example.service")
class ServiceTests {
    // Test suite that runs all tests in a package
}

@Suite
@SelectPackages(value = "com.example", includeSubpackages = true)
class AllTests {
    // Test suite that runs all tests in package and subpackages
}

Test Categories with Tags

class TaggedTests {
    
    @Test
    @Tag("fast")
    @DisplayName("Fast unit test")
    void fastTest() {
        // Quick test
        assertTrue(true);
    }
    
    @Test
    @Tag("slow")
    @DisplayName("Slow integration test")
    void slowTest() throws InterruptedException {
        // Slower test
        Thread.sleep(1000);
        assertTrue(true);
    }
    
    @Test
    @Tag("database")
    @Tag("integration")
    @DisplayName("Database integration test")
    void databaseTest() {
        // Test that requires database
        assertTrue(true);
    }
}

// Run specific tags using Maven:
// mvn test -Dgroups="fast"
// mvn test -Dgroups="fast & !slow"
// mvn test -Dexcludedgroups="slow"

Custom Annotations

// Custom annotation for repeated test patterns
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("integration")
public @interface IntegrationTest {
}

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Test
@Tag("slow")
@Timeout(value = 30, unit = TimeUnit.SECONDS)
public @interface SlowTest {
}

// Usage
class CustomAnnotationTests {
    
    @IntegrationTest
    @DisplayName("Should perform integration test")
    void shouldPerformIntegrationTest() {
        // Integration test logic
        assertTrue(true);
    }
    
    @SlowTest
    @DisplayName("Should complete slow operation")
    void shouldCompleteSlowOperation() throws InterruptedException {
        Thread.sleep(5000);
        assertTrue(true);
    }
}

Best Practices

Test Naming and Structure

class UserServiceTest {
    
    // Good test names describe what is being tested
    @Test
    @DisplayName("Should create user when valid data is provided")
    void shouldCreateUserWhenValidDataIsProvided() {
        // Arrange (Given)
        UserService userService = new UserService();
        CreateUserRequest request = new CreateUserRequest("[email protected]", "John", "Doe");
        
        // Act (When)
        User createdUser = userService.createUser(request);
        
        // Assert (Then)
        assertThat(createdUser)
            .isNotNull()
            .satisfies(user -> {
                assertThat(user.getEmail()).isEqualTo("[email protected]");
                assertThat(user.getFirstName()).isEqualTo("John");
                assertThat(user.getLastName()).isEqualTo("Doe");
                assertThat(user.getId()).isNotNull();
            });
    }
    
    @Test
    @DisplayName("Should throw exception when creating user with invalid email")
    void shouldThrowExceptionWhenCreatingUserWithInvalidEmail() {
        // Arrange
        UserService userService = new UserService();
        CreateUserRequest request = new CreateUserRequest("invalid-email", "John", "Doe");
        
        // Act & Assert
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("Invalid email format");
    }
    
    // Test edge cases and boundary conditions
    @ParameterizedTest
    @ValueSource(strings = {"", "  ", "\t", "\n"})
    @DisplayName("Should throw exception for blank first name")
    void shouldThrowExceptionForBlankFirstName(String firstName) {
        UserService userService = new UserService();
        CreateUserRequest request = new CreateUserRequest("[email protected]", firstName, "Doe");
        
        assertThatThrownBy(() -> userService.createUser(request))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("First name cannot be blank");
    }
}

Test Data Builders

// Test Data Builder pattern for complex objects
class PersonTestDataBuilder {
    private String firstName = "John";
    private String lastName = "Doe";
    private int age = 30;
    private String email = "[email protected]";
    private List<String> hobbies = new ArrayList<>();
    
    public static PersonTestDataBuilder aPerson() {
        return new PersonTestDataBuilder();
    }
    
    public PersonTestDataBuilder withFirstName(String firstName) {
        this.firstName = firstName;
        return this;
    }
    
    public PersonTestDataBuilder withLastName(String lastName) {
        this.lastName = lastName;
        return this;
    }
    
    public PersonTestDataBuilder withAge(int age) {
        this.age = age;
        return this;
    }
    
    public PersonTestDataBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public PersonTestDataBuilder withHobby(String hobby) {
        this.hobbies.add(hobby);
        return this;
    }
    
    public Person build() {
        Person person = new Person(firstName, lastName, age);
        person.setEmail(email);
        person.setHobbies(hobbies);
        return person;
    }
}

// Usage in tests
class PersonTestDataBuilderTest {
    
    @Test
    @DisplayName("Should create valid person with builder")
    void shouldCreateValidPersonWithBuilder() {
        Person person = PersonTestDataBuilder.aPerson()
            .withFirstName("Jane")
            .withAge(25)
            .withHobby("Reading")
            .withHobby("Swimming")
            .build();
        
        assertThat(person.getFirstName()).isEqualTo("Jane");
        assertThat(person.getAge()).isEqualTo(25);
        assertThat(person.getHobbies()).containsExactly("Reading", "Swimming");
    }
}

Test Performance and Maintainability

class PerformanceAndMaintainabilityTest {
    
    // Use @BeforeEach for common setup
    private Calculator calculator;
    private final TestDataBuilder testData = new TestDataBuilder();
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    // Keep tests focused and independent
    @Test
    @DisplayName("Should calculate compound interest correctly")
    void shouldCalculateCompoundInterestCorrectly() {
        // Single responsibility: test compound interest calculation
        double principal = 1000.0;
        double rate = 0.05;
        int years = 10;
        
        double result = calculator.calculateCompoundInterest(principal, rate, years);
        
        assertThat(result).isCloseTo(1628.89, within(0.01));
    }
    
    // Use descriptive assertions
    @Test
    @DisplayName("Should validate loan eligibility based on credit score")
    void shouldValidateLoanEligibilityBasedOnCreditScore() {
        LoanEligibilityService service = new LoanEligibilityService();
        
        // Arrange test data with clear intent
        LoanApplication excellentCreditApp = testData.loanApplication()
            .withCreditScore(800)
            .withIncome(100000)
            .build();
        
        LoanApplication poorCreditApp = testData.loanApplication()
            .withCreditScore(500)
            .withIncome(100000)
            .build();
        
        // Assert with clear error messages
        assertThat(service.isEligible(excellentCreditApp))
            .as("Application with excellent credit score should be eligible")
            .isTrue();
        
        assertThat(service.isEligible(poorCreditApp))
            .as("Application with poor credit score should not be eligible")
            .isFalse();
    }
    
    // Test one thing at a time
    @ParameterizedTest
    @CsvSource({
        "100, 0.05, 1, 105.00",
        "100, 0.05, 2, 110.25", 
        "100, 0.10, 1, 110.00"
    })
    @DisplayName("Should calculate simple interest for various inputs")
    void shouldCalculateSimpleInterestForVariousInputs(
            double principal, double rate, int years, double expected) {
        
        double result = calculator.calculateSimpleInterest(principal, rate, years);
        
        assertThat(result)
            .as("Simple interest calculation for P=%.2f, R=%.2f, T=%d", principal, rate, years)
            .isCloseTo(expected, within(0.01));
    }
}

Summary

Unit testing is essential for building reliable Java applications:

Key Benefits:

  • Early bug detection and prevention
  • Improved code design and modularity
  • Confidence in refactoring and changes
  • Living documentation of expected behavior

JUnit 5 Features:

  • Modern annotation-based API (@Test, @BeforeEach, etc.)
  • Parameterized tests for data-driven testing
  • Conditional test execution
  • Nested test organization
  • Custom extensions and annotations

Best Practices:

  • Use descriptive test names and display names
  • Follow AAA pattern (Arrange, Act, Assert)
  • Keep tests independent and focused
  • Use test data builders for complex objects
  • Organize tests with nested classes and tags
  • Write tests that are fast, reliable, and maintainable

Testing Guidelines:

  • Test public behavior, not implementation details
  • Cover edge cases and boundary conditions
  • Use parameterized tests for multiple inputs
  • Handle exceptions appropriately
  • Maintain high test coverage for critical code

Unit testing with JUnit 5 provides a solid foundation for ensuring code quality and enabling confident development practices in Java applications.