Why Unit Testing? The Safety Net for Your Code
Imagine you're a chef preparing a complex dish. Would you wait until the entire meal is ready to taste it, or would you taste each component as you go? Smart chefs taste along the way - and smart developers test their code the same way.
Unit testing is like having a quality inspector for every small piece of your code. Each "unit" (usually a method or class) gets its own test to ensure it works correctly. When you make changes later, these tests catch any bugs you might accidentally introduce.
Catch Bugs Early
Finding a bug during development costs minutes. Finding it in production costs hours (or days) and damages user trust. Tests catch issues before they become problems.
Confidence to Refactor
Want to improve your code structure? With good tests, you can refactor fearlessly. If something breaks, your tests will tell you immediately.
Living Documentation
Tests show exactly how your code is supposed to work. New team members can read tests to understand what each method does.
Required in Enterprise
Professional teams require tests before code can be merged. Learning testing is essential for your career as a Java developer.
JUnit 5: Your Testing Framework
JUnit is the most popular testing framework for Java. Think of it as a special tool that runs your tests automatically and tells you if they pass or fail.
Setting Up JUnit 5
First, add JUnit 5 to your project. If you're using Maven, add this to your pom.xml:
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
Your First Test
Let's say you have a simple Calculator class:
// src/main/java/Calculator.java
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 int divide(int a, int b) {
if (b == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return a / b;
}
}
Here's how to test it:
// src/test/java/CalculatorTest.java
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
class CalculatorTest {
private Calculator calculator = new Calculator();
@Test
@DisplayName("Adding two positive numbers")
void testAddition() {
// Given: two numbers
int a = 5;
int b = 3;
// When: we add them
int result = calculator.add(a, b);
// Then: we get the sum
assertEquals(8, result);
}
@Test
@DisplayName("Subtracting gives correct difference")
void testSubtraction() {
assertEquals(7, calculator.subtract(10, 3));
}
@Test
@DisplayName("Multiplying two numbers")
void testMultiplication() {
assertEquals(15, calculator.multiply(3, 5));
assertEquals(0, calculator.multiply(5, 0));
assertEquals(-6, calculator.multiply(-2, 3));
}
@Test
@DisplayName("Division by zero throws exception")
void testDivisionByZero() {
// This test expects an exception to be thrown
assertThrows(IllegalArgumentException.class, () -> {
calculator.divide(10, 0);
});
}
}
Understanding the Test Structure
- @Test - Marks a method as a test case
- @DisplayName - Gives your test a readable name
- assertEquals(expected, actual) - Checks if two values are equal
- assertThrows - Verifies that code throws an expected exception
Common Assertions: Your Testing Toolkit
Assertions are the "checks" in your tests. They compare what your code produces against what you expect. Here are the most useful ones:
import static org.junit.jupiter.api.Assertions.*;
class AssertionExamplesTest {
@Test
void basicAssertions() {
// Check equality
assertEquals(4, 2 + 2);
assertEquals("hello", "hello");
// Check not equal
assertNotEquals(5, 2 + 2);
// Check true/false
assertTrue(5 > 3);
assertFalse(3 > 5);
// Check null
String name = null;
assertNull(name);
name = "John";
assertNotNull(name);
}
@Test
void objectAssertions() {
// Check same object reference
String s1 = "hello";
String s2 = s1;
assertSame(s1, s2);
// Check different object references
String s3 = new String("hello");
assertNotSame(s1, s3);
// But they're still equal in value!
assertEquals(s1, s3);
}
@Test
void arrayAssertions() {
int[] expected = {1, 2, 3};
int[] actual = {1, 2, 3};
// Check arrays are equal
assertArrayEquals(expected, actual);
}
@Test
void exceptionAssertions() {
// Check that exception is thrown
Exception exception = assertThrows(
IllegalArgumentException.class,
() -> {
throw new IllegalArgumentException("Invalid input");
}
);
// You can also check the exception message
assertEquals("Invalid input", exception.getMessage());
}
@Test
void groupedAssertions() {
User user = new User("John", 25, "john@email.com");
// Check multiple things at once
// All assertions run even if one fails
assertAll("User properties",
() -> assertEquals("John", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertTrue(user.getEmail().contains("@"))
);
}
}
Test Lifecycle: Setup and Cleanup
Sometimes you need to prepare something before tests run (like creating objects) or clean up afterward (like closing connections). JUnit provides lifecycle annotations for this:
import org.junit.jupiter.api.*;
class UserServiceTest {
private UserService userService;
private Database database;
@BeforeAll
static void setupAll() {
// Runs ONCE before all tests in this class
System.out.println("Starting test suite...");
// Good for: expensive setup like starting a test server
}
@BeforeEach
void setup() {
// Runs before EACH test method
database = new Database();
userService = new UserService(database);
// Good for: creating fresh objects for each test
}
@Test
void testCreateUser() {
User user = userService.createUser("John", "john@email.com");
assertNotNull(user.getId());
}
@Test
void testFindUser() {
userService.createUser("Jane", "jane@email.com");
User found = userService.findByEmail("jane@email.com");
assertEquals("Jane", found.getName());
}
@AfterEach
void cleanup() {
// Runs after EACH test method
database.clear();
// Good for: cleaning up data created during test
}
@AfterAll
static void cleanupAll() {
// Runs ONCE after all tests in this class
System.out.println("Test suite completed!");
// Good for: closing connections, stopping servers
}
}
When to Use Each Annotation
- @BeforeEach - Reset state before each test (most common)
- @AfterEach - Clean up resources after each test
- @BeforeAll - One-time expensive setup (must be static)
- @AfterAll - One-time cleanup (must be static)
Mockito: Testing in Isolation
Here's a common problem: your class depends on other classes. For example, a UserService might depend on a Database. How do you test UserService without actually connecting to a database?
The answer is mocking. A mock is a fake object that pretends to be the real thing. With Mockito, you can create fake versions of dependencies and control exactly what they do.
Setting Up Mockito
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.7.0</version>
<scope>test</scope>
</dependency>
Basic Mocking Example
Let's say you have a UserService that uses a UserRepository to access the database:
// The class we want to test
public class UserService {
private UserRepository userRepository; // Database access
private EmailService emailService; // Sends emails
public UserService(UserRepository repo, EmailService email) {
this.userRepository = repo;
this.emailService = email;
}
public User registerUser(String name, String email) {
// Check if email already exists
if (userRepository.findByEmail(email) != null) {
throw new IllegalArgumentException("Email already registered");
}
// Save user
User user = new User(name, email);
User saved = userRepository.save(user);
// Send welcome email
emailService.sendWelcomeEmail(email);
return saved;
}
}
Now let's test it with mocks:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.mockito.Mockito.*;
import static org.junit.jupiter.api.Assertions.*;
@ExtendWith(MockitoExtension.class) // Enable Mockito
class UserServiceTest {
@Mock // Create a fake UserRepository
private UserRepository userRepository;
@Mock // Create a fake EmailService
private EmailService emailService;
@InjectMocks // Create UserService with mocks injected
private UserService userService;
@Test
void registerUser_Success() {
// Given: email doesn't exist yet
when(userRepository.findByEmail("john@email.com"))
.thenReturn(null);
// And: save returns the user with an ID
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(1L);
return user;
});
// When: we register a new user
User result = userService.registerUser("John", "john@email.com");
// Then: user is created with ID
assertNotNull(result);
assertEquals(1L, result.getId());
assertEquals("John", result.getName());
// And: welcome email was sent
verify(emailService).sendWelcomeEmail("john@email.com");
}
@Test
void registerUser_EmailAlreadyExists_ThrowsException() {
// Given: email already exists
User existingUser = new User("Jane", "john@email.com");
when(userRepository.findByEmail("john@email.com"))
.thenReturn(existingUser);
// When/Then: registration fails
assertThrows(IllegalArgumentException.class, () -> {
userService.registerUser("John", "john@email.com");
});
// And: no user was saved
verify(userRepository, never()).save(any());
// And: no email was sent
verify(emailService, never()).sendWelcomeEmail(any());
}
}
Key Mockito Concepts
- @Mock - Creates a fake version of a class
- @InjectMocks - Creates the class under test with mocks injected
- when().thenReturn() - Define what the mock should return
- verify() - Check that a method was called
- any() - Match any argument
- never() - Verify method was never called
Test-Driven Development (TDD)
TDD is a development approach where you write tests BEFORE writing the actual code. It sounds backwards, but it leads to better-designed, more reliable code.
The TDD Cycle: Red-Green-Refactor
1. Red: Write a Failing Test
First, write a test for functionality that doesn't exist yet. Run it - it should fail (red). This proves your test actually tests something.
2. Green: Make It Pass
Write the minimum code needed to make the test pass. Don't worry about perfect code yet - just make it work.
3. Refactor: Improve the Code
Now clean up your code. Remove duplication, improve naming, optimize. Your tests ensure you don't break anything.
TDD Example: Building a Password Validator
// Step 1: RED - Write the test first (code doesn't exist yet!)
class PasswordValidatorTest {
private PasswordValidator validator = new PasswordValidator();
@Test
void password_TooShort_Invalid() {
assertFalse(validator.isValid("abc"));
}
@Test
void password_NoNumbers_Invalid() {
assertFalse(validator.isValid("abcdefgh"));
}
@Test
void password_Valid() {
assertTrue(validator.isValid("password123"));
}
}
// This won't even compile yet! That's okay in TDD.
// Step 2: GREEN - Write minimum code to pass
public class PasswordValidator {
public boolean isValid(String password) {
// Minimum 8 characters
if (password.length() < 8) {
return false;
}
// Must contain at least one number
boolean hasNumber = false;
for (char c : password.toCharArray()) {
if (Character.isDigit(c)) {
hasNumber = true;
break;
}
}
return hasNumber;
}
}
// Run tests - they should all pass now!
// Step 3: REFACTOR - Clean up the code
public class PasswordValidator {
private static final int MIN_LENGTH = 8;
public boolean isValid(String password) {
return hasMinimumLength(password) && containsNumber(password);
}
private boolean hasMinimumLength(String password) {
return password.length() >= MIN_LENGTH;
}
private boolean containsNumber(String password) {
return password.chars().anyMatch(Character::isDigit);
}
}
// Run tests again - still passing!
Testing Best Practices
1. One Assertion Per Concept
Each test should verify one specific behavior. This makes failures easy to understand.
// BAD: Testing too many things
@Test
void testUser() {
User user = new User("John", 25);
assertEquals("John", user.getName());
assertEquals(25, user.getAge());
assertTrue(user.isAdult());
assertNotNull(user.getId());
}
// GOOD: Separate tests for each behavior
@Test void user_HasCorrectName() { ... }
@Test void user_HasCorrectAge() { ... }
@Test void user_IsAdult_WhenOver18() { ... }
2. Use Descriptive Test Names
Test names should describe what's being tested and expected outcome.
// BAD
@Test void test1() { ... }
@Test void testLogin() { ... }
// GOOD
@Test void login_WithValidCredentials_ReturnsUser() { ... }
@Test void login_WithWrongPassword_ThrowsException() { ... }
3. Follow the AAA Pattern
Arrange-Act-Assert (or Given-When-Then) makes tests readable:
@Test
void withdraw_SufficientBalance_DecreasesBalance() {
// Arrange (Given)
BankAccount account = new BankAccount(100.0);
// Act (When)
account.withdraw(30.0);
// Assert (Then)
assertEquals(70.0, account.getBalance());
}
4. Test Edge Cases
Don't just test the happy path. Think about boundaries and errors:
@Test void divide_ByZero_ThrowsException() { ... }
@Test void list_WhenEmpty_ReturnsEmptyList() { ... }
@Test void search_NullInput_ThrowsException() { ... }
@Test void age_AtExactly18_IsAdult() { ... }
5. Keep Tests Independent
Tests should not depend on each other. Each test should set up its own data.