1. java
  2. /testing

Java Testing with JUnit, TestNG, and Mockito

Testing in Java

Testing is a crucial aspect of Java development that ensures code quality, reliability, and maintainability. Java has a rich ecosystem of testing frameworks and tools that support different testing strategies, from unit tests to integration tests.

Types of Testing

Unit Testing

Testing individual components (methods, classes) in isolation to verify they work correctly.

Integration Testing

Testing how different components work together, including database interactions, external services, and system integration.

End-to-End Testing

Testing complete user workflows from start to finish, simulating real user interactions.

Performance Testing

Testing application performance, load handling, and resource usage under various conditions.

Testing Frameworks

JUnit 5 (Jupiter)

The most popular Java testing framework, providing a foundation for modern testing on the JVM.

TestNG

Alternative testing framework with advanced features like parallel execution and flexible test configuration.

Mockito

Powerful mocking framework for creating test doubles and isolating units under test.

AssertJ

Fluent assertion library that makes test assertions more readable and expressive.

JUnit 5 Fundamentals

Basic Test Structure

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

class CalculatorTest {
    
    private Calculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new Calculator();
    }
    
    @Test
    @DisplayName("Should add two numbers correctly")
    void shouldAddTwoNumbers() {
        // Given
        int a = 5;
        int b = 3;
        
        // When
        int result = calculator.add(a, b);
        
        // Then
        assertEquals(8, result, "5 + 3 should equal 8");
    }
    
    @Test
    @DisplayName("Should throw exception when dividing by zero")
    void shouldThrowExceptionWhenDividingByZero() {
        // Given
        int dividend = 10;
        int divisor = 0;
        
        // When & Then
        ArithmeticException exception = assertThrows(
            ArithmeticException.class, 
            () -> calculator.divide(dividend, divisor),
            "Division by zero should throw ArithmeticException"
        );
        
        assertEquals("Cannot divide by zero", exception.getMessage());
    }
    
    @AfterEach
    void tearDown() {
        calculator = null;
    }
}

Parameterized Tests

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

class MathUtilsTest {
    
    @ParameterizedTest
    @DisplayName("Should check if numbers are even")
    @ValueSource(ints = {2, 4, 6, 8, 10})
    void shouldReturnTrueForEvenNumbers(int number) {
        assertTrue(MathUtils.isEven(number));
    }
    
    @ParameterizedTest
    @DisplayName("Should calculate area correctly")
    @CsvSource({
        "1, 1, 1",
        "2, 3, 6", 
        "5, 4, 20",
        "10, 10, 100"
    })
    void shouldCalculateArea(int length, int width, int expectedArea) {
        int actualArea = MathUtils.calculateArea(length, width);
        assertEquals(expectedArea, actualArea);
    }
    
    @ParameterizedTest
    @DisplayName("Should validate email addresses")
    @CsvFileSource(resources = "/email-test-data.csv", numLinesToSkip = 1)
    void shouldValidateEmailAddresses(String email, boolean expected) {
        boolean isValid = EmailValidator.isValid(email);
        assertEquals(expected, isValid, "Email validation failed for: " + email);
    }
    
    @ParameterizedTest
    @MethodSource("provideStringsForIsPalindrome")
    void shouldCheckPalindrome(String input, boolean expected) {
        boolean result = StringUtils.isPalindrome(input);
        assertEquals(expected, result);
    }
    
    static Stream<Arguments> provideStringsForIsPalindrome() {
        return Stream.of(
            Arguments.of("racecar", true),
            Arguments.of("radar", true),
            Arguments.of("hello", false),
            Arguments.of("A man a plan a canal Panama", true),
            Arguments.of("", true)
        );
    }
}

Nested Tests and Test Lifecycle

@DisplayName("User Service Tests")
class UserServiceTest {
    
    private UserService userService;
    private UserRepository userRepository;
    
    @BeforeAll
    static void setUpClass() {
        System.out.println("Setting up test class");
    }
    
    @BeforeEach
    void setUp() {
        userRepository = mock(UserRepository.class);
        userService = new UserService(userRepository);
    }
    
    @Nested
    @DisplayName("User Creation Tests")
    class UserCreationTests {
        
        @Test
        @DisplayName("Should create user with valid data")
        void shouldCreateUserWithValidData() {
            // Given
            CreateUserRequest request = new CreateUserRequest("[email protected]", "John Doe");
            User savedUser = new User(1L, "[email protected]", "John Doe");
            when(userRepository.save(any(User.class))).thenReturn(savedUser);
            
            // When
            User result = userService.createUser(request);
            
            // Then
            assertNotNull(result);
            assertEquals("[email protected]", result.getEmail());
            assertEquals("John Doe", result.getName());
            verify(userRepository).save(any(User.class));
        }
        
        @Test
        @DisplayName("Should throw exception for invalid email")
        void shouldThrowExceptionForInvalidEmail() {
            // Given
            CreateUserRequest request = new CreateUserRequest("invalid-email", "John Doe");
            
            // When & Then
            assertThrows(
                IllegalArgumentException.class,
                () -> userService.createUser(request),
                "Should throw exception for invalid email"
            );
        }
    }
    
    @Nested
    @DisplayName("User Retrieval Tests")
    class UserRetrievalTests {
        
        @Test
        @DisplayName("Should find user by id")
        void shouldFindUserById() {
            // Given
            Long userId = 1L;
            User user = new User(userId, "[email protected]", "John Doe");
            when(userRepository.findById(userId)).thenReturn(Optional.of(user));
            
            // When
            Optional<User> result = userService.findById(userId);
            
            // Then
            assertTrue(result.isPresent());
            assertEquals(user, result.get());
        }
        
        @Test
        @DisplayName("Should return empty when user not found")
        void shouldReturnEmptyWhenUserNotFound() {
            // Given
            Long userId = 999L;
            when(userRepository.findById(userId)).thenReturn(Optional.empty());
            
            // When
            Optional<User> result = userService.findById(userId);
            
            // Then
            assertTrue(result.isEmpty());
        }
    }
    
    @AfterEach
    void tearDown() {
        reset(userRepository);
    }
    
    @AfterAll
    static void tearDownClass() {
        System.out.println("Tearing down test class");
    }
}

Mockito for Mocking

Basic Mocking

import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;

class OrderServiceTest {
    
    @Mock
    private PaymentService paymentService;
    
    @Mock
    private EmailService emailService;
    
    @Mock
    private OrderRepository orderRepository;
    
    @InjectMocks
    private OrderService orderService;
    
    @BeforeEach
    void setUp() {
        MockitoAnnotations.openMocks(this);
    }
    
    @Test
    void shouldProcessOrderSuccessfully() {
        // Given
        Order order = new Order(1L, "ORD-001", BigDecimal.valueOf(100.00));
        PaymentResult paymentResult = new PaymentResult(true, "TXN-123");
        
        when(paymentService.processPayment(any(PaymentRequest.class)))
            .thenReturn(paymentResult);
        when(orderRepository.save(any(Order.class)))
            .thenReturn(order);
        
        // When
        OrderResult result = orderService.processOrder(order);
        
        // Then
        assertTrue(result.isSuccess());
        assertEquals("ORD-001", result.getOrderNumber());
        
        // Verify interactions
        verify(paymentService).processPayment(any(PaymentRequest.class));
        verify(emailService).sendOrderConfirmation(eq(order));
        verify(orderRepository).save(order);
    }
    
    @Test
    void shouldHandlePaymentFailure() {
        // Given
        Order order = new Order(1L, "ORD-002", BigDecimal.valueOf(50.00));
        PaymentResult failureResult = new PaymentResult(false, "Payment declined");
        
        when(paymentService.processPayment(any(PaymentRequest.class)))
            .thenReturn(failureResult);
        
        // When
        OrderResult result = orderService.processOrder(order);
        
        // Then
        assertFalse(result.isSuccess());
        assertEquals("Payment failed: Payment declined", result.getErrorMessage());
        
        // Verify email service was not called for failed payment
        verify(emailService, never()).sendOrderConfirmation(any(Order.class));
        verify(orderRepository, never()).save(any(Order.class));
    }
}

Advanced Mocking Techniques

class ProductServiceTest {
    
    @Test
    void shouldTestWithArgumentCaptor() {
        // Given
        ProductRepository repository = mock(ProductRepository.class);
        ProductService service = new ProductService(repository);
        ArgumentCaptor<Product> productCaptor = ArgumentCaptor.forClass(Product.class);
        
        // When
        service.createProduct("Test Product", BigDecimal.valueOf(99.99));
        
        // Then
        verify(repository).save(productCaptor.capture());
        Product capturedProduct = productCaptor.getValue();
        assertEquals("Test Product", capturedProduct.getName());
        assertEquals(BigDecimal.valueOf(99.99), capturedProduct.getPrice());
    }
    
    @Test
    void shouldTestWithCustomMatchers() {
        // Given
        ProductRepository repository = mock(ProductRepository.class);
        ProductService service = new ProductService(repository);
        
        // When
        service.findExpensiveProducts();
        
        // Then
        verify(repository).findByPriceGreaterThan(argThat(price -> 
            price.compareTo(BigDecimal.valueOf(100)) >= 0));
    }
    
    @Test
    void shouldTestWithAnswer() {
        // Given
        ProductRepository repository = mock(ProductRepository.class);
        when(repository.save(any(Product.class))).thenAnswer(invocation -> {
            Product product = invocation.getArgument(0);
            product.setId(123L); // Simulate database ID assignment
            return product;
        });
        
        ProductService service = new ProductService(repository);
        Product product = new Product("Test", BigDecimal.valueOf(50));
        
        // When
        Product saved = service.createProduct(product);
        
        // Then
        assertEquals(123L, saved.getId());
    }
}

Integration Testing

Spring Boot Test

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(locations = "classpath:test.properties")
@Transactional
class UserControllerIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @Test
    void shouldCreateAndRetrieveUser() {
        // Given
        CreateUserRequest request = new CreateUserRequest("[email protected]", "Test User");
        
        // When - Create user
        ResponseEntity<User> createResponse = restTemplate.postForEntity(
            "/api/users", request, User.class);
        
        // Then - Verify creation
        assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
        assertNotNull(createResponse.getBody());
        Long userId = createResponse.getBody().getId();
        
        // When - Retrieve user
        ResponseEntity<User> getResponse = restTemplate.getForEntity(
            "/api/users/" + userId, User.class);
        
        // Then - Verify retrieval
        assertEquals(HttpStatus.OK, getResponse.getStatusCode());
        User retrievedUser = getResponse.getBody();
        assertNotNull(retrievedUser);
        assertEquals("[email protected]", retrievedUser.getEmail());
        assertEquals("Test User", retrievedUser.getName());
    }
    
    @Test
    void shouldReturnNotFoundForNonExistentUser() {
        // When
        ResponseEntity<String> response = restTemplate.getForEntity(
            "/api/users/999", String.class);
        
        // Then
        assertEquals(HttpStatus.NOT_FOUND, response.getStatusCode());
    }
}

Database Testing with Testcontainers

@SpringBootTest
@Testcontainers
class ProductRepositoryIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:13")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
    
    @Autowired
    private ProductRepository productRepository;
    
    @Test
    @Transactional
    @Rollback
    void shouldSaveAndFindProduct() {
        // Given
        Product product = new Product("Test Product", BigDecimal.valueOf(99.99), Category.ELECTRONICS);
        
        // When
        Product saved = productRepository.save(product);
        Optional<Product> found = productRepository.findById(saved.getId());
        
        // Then
        assertTrue(found.isPresent());
        assertEquals("Test Product", found.get().getName());
    }
    
    @Test
    void shouldFindProductsByCategory() {
        // Given
        productRepository.save(new Product("Laptop", BigDecimal.valueOf(999), Category.ELECTRONICS));
        productRepository.save(new Product("Phone", BigDecimal.valueOf(599), Category.ELECTRONICS));
        productRepository.save(new Product("Book", BigDecimal.valueOf(29), Category.BOOKS));
        
        // When
        List<Product> electronics = productRepository.findByCategory(Category.ELECTRONICS);
        
        // Then
        assertEquals(2, electronics.size());
        assertTrue(electronics.stream().allMatch(p -> p.getCategory() == Category.ELECTRONICS));
    }
}

Test-Driven Development (TDD)

TDD Cycle: Red-Green-Refactor

// 1. RED: Write failing test first
class StringCalculatorTest {
    
    private StringCalculator calculator;
    
    @BeforeEach
    void setUp() {
        calculator = new StringCalculator();
    }
    
    @Test
    void shouldReturnZeroForEmptyString() {
        // Given
        String input = "";
        
        // When
        int result = calculator.add(input);
        
        // Then
        assertEquals(0, result);
    }
    
    @Test
    void shouldReturnNumberForSingleDigit() {
        // Given
        String input = "5";
        
        // When
        int result = calculator.add(input);
        
        // Then
        assertEquals(5, result);
    }
    
    @Test
    void shouldAddTwoNumbers() {
        // Given
        String input = "1,2";
        
        // When
        int result = calculator.add(input);
        
        // Then
        assertEquals(3, result);
    }
}

// 2. GREEN: Write minimal code to pass
public class StringCalculator {
    
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        
        if (!numbers.contains(",")) {
            return Integer.parseInt(numbers);
        }
        
        String[] nums = numbers.split(",");
        return Integer.parseInt(nums[0]) + Integer.parseInt(nums[1]);
    }
}

// 3. REFACTOR: Improve the code
public class StringCalculator {
    
    public int add(String numbers) {
        if (numbers.isEmpty()) {
            return 0;
        }
        
        return Arrays.stream(numbers.split(","))
            .mapToInt(Integer::parseInt)
            .sum();
    }
}

Performance Testing

JMH (Java Microbenchmark Harness)

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
public class StringConcatenationBenchmark {
    
    private static final int ITERATIONS = 1000;
    
    @Benchmark
    public String stringConcatenation() {
        String result = "";
        for (int i = 0; i < ITERATIONS; i++) {
            result += "test" + i;
        }
        return result;
    }
    
    @Benchmark
    public String stringBuilderConcatenation() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < ITERATIONS; i++) {
            sb.append("test").append(i);
        }
        return sb.toString();
    }
    
    @Benchmark
    public String stringJoinerConcatenation() {
        StringJoiner joiner = new StringJoiner("");
        for (int i = 0; i < ITERATIONS; i++) {
            joiner.add("test" + i);
        }
        return joiner.toString();
    }
}

Testing Best Practices

Test Structure (AAA Pattern)

@Test
void shouldCalculateDiscountCorrectly() {
    // Arrange (Given)
    Customer customer = new Customer("premium");
    Order order = new Order(BigDecimal.valueOf(100));
    DiscountService discountService = new DiscountService();
    
    // Act (When)
    BigDecimal discount = discountService.calculateDiscount(customer, order);
    
    // Assert (Then)
    assertEquals(BigDecimal.valueOf(10), discount);
}

Test Data Builders

public class UserTestDataBuilder {
    private String name = "Default Name";
    private String email = "[email protected]";
    private UserRole role = UserRole.USER;
    private boolean active = true;
    
    public static UserTestDataBuilder aUser() {
        return new UserTestDataBuilder();
    }
    
    public UserTestDataBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public UserTestDataBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public UserTestDataBuilder withRole(UserRole role) {
        this.role = role;
        return this;
    }
    
    public UserTestDataBuilder inactive() {
        this.active = false;
        return this;
    }
    
    public User build() {
        return new User(name, email, role, active);
    }
}

// Usage in tests
@Test
void shouldDeactivateInactiveUsers() {
    // Given
    User activeUser = aUser().withName("Active User").build();
    User inactiveUser = aUser().withName("Inactive User").inactive().build();
    
    // When & Then...
}

Custom Assertions

public class UserAssertions {
    
    public static UserAssertion assertThat(User actual) {
        return new UserAssertion(actual);
    }
    
    public static class UserAssertion extends AbstractAssert<UserAssertion, User> {
        
        public UserAssertion(User actual) {
            super(actual, UserAssertion.class);
        }
        
        public UserAssertion hasName(String expectedName) {
            isNotNull();
            if (!Objects.equals(actual.getName(), expectedName)) {
                failWithMessage("Expected user name to be <%s> but was <%s>", 
                    expectedName, actual.getName());
            }
            return this;
        }
        
        public UserAssertion isActive() {
            isNotNull();
            if (!actual.isActive()) {
                failWithMessage("Expected user to be active but was inactive");
            }
            return this;
        }
        
        public UserAssertion hasRole(UserRole expectedRole) {
            isNotNull();
            if (actual.getRole() != expectedRole) {
                failWithMessage("Expected user role to be <%s> but was <%s>", 
                    expectedRole, actual.getRole());
            }
            return this;
        }
    }
}

// Usage
@Test
void shouldCreateActiveUser() {
    User user = userService.createUser("John", "[email protected]");
    
    assertThat(user)
        .hasName("John")
        .isActive()
        .hasRole(UserRole.USER);
}

Test Organization and Naming

Test Class Organization

@DisplayName("ProductService")
class ProductServiceTest {
    
    @Nested
    @DisplayName("when creating products")
    class WhenCreatingProducts {
        
        @Test
        @DisplayName("should create product with valid data")
        void shouldCreateProductWithValidData() { /* ... */ }
        
        @Test
        @DisplayName("should throw exception for invalid price")
        void shouldThrowExceptionForInvalidPrice() { /* ... */ }
    }
    
    @Nested
    @DisplayName("when searching products")
    class WhenSearchingProducts {
        
        @Test
        @DisplayName("should find products by category")
        void shouldFindProductsByCategory() { /* ... */ }
        
        @Test
        @DisplayName("should return empty list when no products found")
        void shouldReturnEmptyListWhenNoProductsFound() { /* ... */ }
    }
}

Common Testing Anti-Patterns

❌ Avoid These Patterns

// 1. Testing implementation details
@Test
void shouldCallRepositorySaveMethod() {
    userService.createUser("John", "[email protected]");
    verify(userRepository).save(any()); // Testing implementation, not behavior
}

// 2. Brittle tests with magic numbers
@Test
void shouldCalculateTotal() {
    assertEquals(42.37, calculator.calculate()); // What is 42.37?
}

// 3. Tests that depend on external state
@Test
void shouldFindUser() {
    User user = userService.findById(1L); // Assumes user with ID 1 exists
    assertNotNull(user);
}

// 4. Overly complex test setup
@Test
void shouldProcessOrder() {
    // 50 lines of setup code...
    // Test becomes hard to understand
}

✅ Better Approaches

// 1. Test behavior, not implementation
@Test
void shouldCreateUserWithCorrectData() {
    User user = userService.createUser("John", "[email protected]");
    assertEquals("John", user.getName());
    assertEquals("[email protected]", user.getEmail());
}

// 2. Use meaningful constants
@Test
void shouldCalculateDiscountedTotal() {
    BigDecimal originalPrice = BigDecimal.valueOf(100);
    BigDecimal expectedDiscountedPrice = BigDecimal.valueOf(90);
    
    BigDecimal result = calculator.applyDiscount(originalPrice, TEN_PERCENT_DISCOUNT);
    assertEquals(expectedDiscountedPrice, result);
}

// 3. Create test data explicitly
@Test
void shouldFindUserById() {
    User savedUser = userRepository.save(new User("John", "[email protected]"));
    
    Optional<User> found = userService.findById(savedUser.getId());
    assertTrue(found.isPresent());
}

Next Steps

Explore specific testing topics:

Testing is an investment in code quality that pays dividends in reduced bugs, easier refactoring, and increased confidence in your codebase. Start with unit tests, embrace TDD practices, and gradually build up your testing skills.