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
- JUnit 5 Overview
- Writing Basic Tests
- Assertions and Matchers
- Test Lifecycle
- Parameterized Tests
- Conditional Tests
- Test Organization
- Best Practices
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.