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:
- JUnit 5 - Modern testing framework fundamentals
- TestNG - Alternative testing framework
- Mockito - Advanced mocking techniques
- Integration Testing - Testing component interactions
- Test-Driven Development - TDD practices and techniques
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.