This post introduces unit testing fundamentals. Understanding basic programming concepts and object-oriented principles will help you get the most out of this guide. Check our post on Object-Oriented Programming if you need a refresher!
What Is Unit Testing?
Unit testing is the practice of testing individual components (units) of your code in isolation to verify they work correctly. A “unit” is typically the smallest testable part of an application, usually a single method or function.
Think of unit testing like quality control in a car factory. Before assembling the entire vehicle, each component (engine, brakes, transmission) is tested individually. If a brake fails inspection, you catch it early rather than discovering the problem when the car is complete and on the road.
Why Unit Testing Matters for Junior Developers
As a junior developer, learning unit testing early in your career provides several critical benefits:
- Catch bugs early: Find issues before they reach production
- Document behavior: Tests serve as living documentation of how code should work
- Enable refactoring: Confidently improve code knowing tests will catch regressions
- Design better code: Writing testable code naturally leads to better design
- Build confidence: Deploy changes without fear of breaking existing functionality
Pro Tip: Writing tests might feel like extra work initially, but it saves time in the long run by reducing debugging and fixing issues in production.
Core Principles of Unit Testing
The Three Pillars
Effective unit tests follow three fundamental principles:
Isolation
Tests should run independently without external dependencies.
Each test should be self-contained and not depend on other tests, databases, file systems, or network resources. This ensures tests are fast, reliable, and can run in any order.
Example: Instead of connecting to a real database, use mock objects or in-memory data structures.
Repeatability
Tests should produce the same results every time they run.
A test that sometimes passes and sometimes fails (a “flaky” test) is worse than no test at all. Tests must be deterministic and consistent.
Example: Avoid using random numbers or current timestamps without controlling them in your tests.
Fast Execution
Unit tests should run quickly to encourage frequent execution.
Since you’ll run unit tests frequently (often hundreds or thousands at a time), each test should complete in milliseconds. This enables rapid feedback during development.
Example: A good unit test suite with 1000 tests should complete in under 10 seconds.
Test Characteristics - The FIRST Principles
Quality unit tests follow the FIRST acronym:
| Principle | Meaning | Why It Matters |
|---|---|---|
| Fast | Tests run quickly | Encourages running tests frequently |
| Independent | Tests don’t depend on each other | Can run in any order, easier to debug |
| Repeatable | Same results every time | Builds confidence in test reliability |
| Self-validating | Clear pass/fail result | No manual verification needed |
| Timely | Written with or before code | Catches issues early in development |
Understanding Test Types
Before diving deeper into unit testing, it’s important to understand where it fits in the testing landscape:
flowchart TB
subgraph Testing["Software Testing Pyramid"]
direction TB
E2E["End-to-End Tests<br/>(Slowest, Fewest)"]
INT["Integration Tests<br/>(Medium Speed & Quantity)"]
UNIT["Unit Tests<br/>(Fastest, Most Numerous)"]
E2E --> INT
INT --> UNIT
end
style UNIT fill:#4CAF50,stroke:#2E7D32,color:#fff
style INT fill:#FF9800,stroke:#E65100,color:#fff
style E2E fill:#F44336,stroke:#C62828,color:#fff
Unit Tests vs Integration Tests
| Aspect | Unit Tests | Integration Tests |
|---|---|---|
| Scope | Single method/class | Multiple components working together |
| Speed | Very fast (milliseconds) | Slower (seconds to minutes) |
| Dependencies | Isolated with mocks | Uses real dependencies |
| Purpose | Verify logic correctness | Verify components interact correctly |
| Quantity | Many (100s to 1000s) | Fewer (10s to 100s) |
Your test suite should have many unit tests forming a solid foundation, fewer integration tests, and even fewer end-to-end tests. This is called the “testing pyramid.”
The AAA Pattern - Arrange, Act, Assert
The AAA pattern is the most common structure for writing clear, readable unit tests:
flowchart LR
A["Arrange<br/>Set up test data<br/>and preconditions"] --> B["Act<br/>Execute the method<br/>being tested"]
B --> C["Assert<br/>Verify the expected<br/>outcome"]
style A fill:#E3F2FD,stroke:#1976D2
style B fill:#FFF3E0,stroke:#F57C00
style C fill:#E8F5E9,stroke:#388E3C
Let’s see this in action with a practical example.
Writing Your First Unit Test
Setting Up JUnit
First, ensure you have JUnit 5 in your project. If using Maven, add this dependency to your pom.xml:
1
2
3
4
5
6
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
Example: Testing a Calculator Class
Let’s create a simple Calculator class and write unit tests for it.
Production Code (Calculator.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Calculator {
/**
* Adds two integers and returns the result.
*/
public int add(int a, int b) {
return a + b;
}
/**
* Divides two integers and returns the result.
* @throws IllegalArgumentException if divisor is zero
*/
public double divide(int dividend, int divisor) {
if (divisor == 0) {
throw new IllegalArgumentException("Cannot divide by zero");
}
return (double) dividend / divisor;
}
/**
* Checks if a number is even.
*/
public boolean isEven(int number) {
return number % 2 == 0;
}
}
Test Code (CalculatorTest.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Calculator Tests")
public class CalculatorTest {
private Calculator calculator;
// BeforeEach runs before each test method
@BeforeEach
void setUp() {
calculator = new Calculator();
}
@Test
@DisplayName("Should add two positive numbers correctly")
void testAddPositiveNumbers() {
// Arrange - Set up test data
int a = 5;
int b = 3;
int expectedSum = 8;
// Act - Execute the method being tested
int actualSum = calculator.add(a, b);
// Assert - Verify the result
assertEquals(expectedSum, actualSum,
"5 + 3 should equal 8");
}
@Test
@DisplayName("Should handle negative numbers in addition")
void testAddNegativeNumbers() {
// Arrange
int a = -5;
int b = 3;
int expectedSum = -2;
// Act
int actualSum = calculator.add(a, b);
// Assert
assertEquals(expectedSum, actualSum);
}
@Test
@DisplayName("Should divide two numbers correctly")
void testDivideValidNumbers() {
// Arrange
int dividend = 10;
int divisor = 2;
double expectedResult = 5.0;
// Act
double actualResult = calculator.divide(dividend, divisor);
// Assert
assertEquals(expectedResult, actualResult, 0.001);
}
@Test
@DisplayName("Should throw exception when dividing by zero")
void testDivideByZero() {
// Arrange
int dividend = 10;
int divisor = 0;
// Act & Assert - Verify exception is thrown
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.divide(dividend, divisor)
);
// Optionally verify the exception message
assertEquals("Cannot divide by zero", exception.getMessage());
}
@Test
@DisplayName("Should correctly identify even numbers")
void testIsEven() {
// Act & Assert - Multiple assertions in one test
assertTrue(calculator.isEven(2), "2 should be even");
assertTrue(calculator.isEven(0), "0 should be even");
assertTrue(calculator.isEven(-4), "-4 should be even");
assertFalse(calculator.isEven(3), "3 should not be even");
assertFalse(calculator.isEven(-5), "-5 should not be even");
}
}
Common JUnit Assertions
JUnit provides various assertion methods to verify different conditions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import static org.junit.jupiter.api.Assertions.*;
// Equality assertions
assertEquals(expected, actual); // Values are equal
assertNotEquals(unexpected, actual); // Values are not equal
// Boolean assertions
assertTrue(condition); // Condition is true
assertFalse(condition); // Condition is false
// Null checks
assertNull(object); // Object is null
assertNotNull(object); // Object is not null
// Reference checks
assertSame(expected, actual); // Same object reference
assertNotSame(unexpected, actual); // Different object references
// Exception assertions
assertThrows(ExceptionType.class, () -> {
// Code that should throw exception
});
// Array assertions
assertArrayEquals(expectedArray, actualArray);
// Multiple assertions (all execute even if one fails)
assertAll(
() -> assertEquals(expected1, actual1),
() -> assertEquals(expected2, actual2),
() -> assertTrue(condition)
);
Testing More Complex Scenarios
Example: Testing a User Service
Let’s look at a more realistic example with dependencies.
Production Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// User model
public class User {
private String username;
private String email;
private boolean active;
public User(String username, String email) {
this.username = username;
this.email = email;
this.active = false;
}
// Getters and setters
public String getUsername() { return username; }
public String getEmail() { return email; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
}
// UserValidator - validates user data
public class UserValidator {
public boolean isValidEmail(String email) {
// Simplified email validation
return email != null && email.contains("@") && email.contains(".");
}
public boolean isValidUsername(String username) {
// Username must be 3-20 characters, alphanumeric
return username != null
&& username.length() >= 3
&& username.length() <= 20
&& username.matches("[a-zA-Z0-9]+");
}
}
// UserService - business logic
public class UserService {
private UserValidator validator;
public UserService(UserValidator validator) {
this.validator = validator;
}
public User createUser(String username, String email) {
// Validate input
if (!validator.isValidUsername(username)) {
throw new IllegalArgumentException("Invalid username");
}
if (!validator.isValidEmail(email)) {
throw new IllegalArgumentException("Invalid email");
}
// Create and return user
return new User(username, email);
}
public void activateUser(User user) {
if (user == null) {
throw new IllegalArgumentException("User cannot be null");
}
user.setActive(true);
}
}
Test Code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("UserService Tests")
public class UserServiceTest {
private UserValidator validator;
private UserService userService;
@BeforeEach
void setUp() {
validator = new UserValidator();
userService = new UserService(validator);
}
@Nested
@DisplayName("User Creation Tests")
class UserCreationTests {
@Test
@DisplayName("Should create user with valid data")
void testCreateUserWithValidData() {
// Arrange
String username = "johndoe";
String email = "john@example.com";
// Act
User user = userService.createUser(username, email);
// Assert
assertAll(
() -> assertNotNull(user, "User should not be null"),
() -> assertEquals(username, user.getUsername()),
() -> assertEquals(email, user.getEmail()),
() -> assertFalse(user.isActive(), "New user should be inactive")
);
}
@Test
@DisplayName("Should reject invalid username")
void testCreateUserWithInvalidUsername() {
// Arrange
String invalidUsername = "ab"; // Too short
String email = "john@example.com";
// Act & Assert
assertThrows(IllegalArgumentException.class,
() -> userService.createUser(invalidUsername, email));
}
@Test
@DisplayName("Should reject invalid email")
void testCreateUserWithInvalidEmail() {
// Arrange
String username = "johndoe";
String invalidEmail = "invalid-email"; // No @ or .
// Act & Assert
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser(username, invalidEmail)
);
assertEquals("Invalid email", exception.getMessage());
}
}
@Nested
@DisplayName("User Activation Tests")
class UserActivationTests {
@Test
@DisplayName("Should activate an inactive user")
void testActivateUser() {
// Arrange
User user = new User("johndoe", "john@example.com");
assertFalse(user.isActive(), "Precondition: user should be inactive");
// Act
userService.activateUser(user);
// Assert
assertTrue(user.isActive(), "User should be active after activation");
}
@Test
@DisplayName("Should throw exception when activating null user")
void testActivateNullUser() {
// Act & Assert
assertThrows(IllegalArgumentException.class,
() -> userService.activateUser(null));
}
}
}
The
@Nestedannotation helps organize related tests into groups, making test reports more readable and maintainable.
Test-Driven Development (TDD)
Test-Driven Development is a practice where you write tests before writing the actual code. The workflow follows a simple cycle:
flowchart LR
A["🔴 Red<br/>Write a<br/>failing test"] --> B["🟢 Green<br/>Write minimal code<br/>to pass the test"]
B --> C["🔵 Refactor<br/>Improve code<br/>while keeping tests green"]
C --> A
style A fill:#FFCDD2,stroke:#C62828
style B fill:#C8E6C9,stroke:#2E7D32
style C fill:#BBDEFB,stroke:#1565C0
TDD Example
Let’s write a StringUtils class using TDD:
Step 1: Write the test (Red)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
@DisplayName("Should reverse a string")
void testReverseString() {
// Arrange
StringUtils utils = new StringUtils();
String input = "hello";
String expected = "olleh";
// Act
String actual = utils.reverse(input);
// Assert
assertEquals(expected, actual);
}
At this point, the code doesn’t compile because StringUtils and reverse() don’t exist yet.
Step 2: Write minimal code to pass (Green)
1
2
3
4
5
6
7
8
public class StringUtils {
public String reverse(String input) {
if (input == null) {
return null;
}
return new StringBuilder(input).reverse().toString();
}
}
Run the test, it passes! ✅
Step 3: Refactor (if needed)
The code is simple and clear, so no refactoring is needed. Now add more tests for edge cases:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
@DisplayName("Should handle null input")
void testReverseNull() {
StringUtils utils = new StringUtils();
assertNull(utils.reverse(null));
}
@Test
@DisplayName("Should handle empty string")
void testReverseEmptyString() {
StringUtils utils = new StringUtils();
assertEquals("", utils.reverse(""));
}
When to Write Unit Tests
What to Test
Focus your testing efforts on:
- ✅ Business logic: Core algorithms and calculations
- ✅ Public methods: The API of your classes
- ✅ Edge cases: Boundary conditions, null values, empty collections
- ✅ Error handling: Exception scenarios
- ✅ Complex conditionals: Multiple if-else branches
What NOT to Test
Don’t waste time testing:
- ❌ Getters and setters: Unless they contain logic
- ❌ Framework code: Libraries are already tested
- ❌ Private methods: Test through public methods that use them
- ❌ Configuration files: These aren’t code logic
- ❌ Database queries directly: Use integration tests instead
Focus on testing behavior, not implementation details. Your tests should verify “what” the code does, not “how” it does it.
Common Pitfalls and Best Practices
Pitfall 1: Testing Implementation Instead of Behavior
❌ Bad Example:
1
2
3
4
5
6
@Test
void testUserServiceUsesValidator() {
// Testing that a private method is called
userService.createUser("john", "john@example.com");
verify(validator).isValidUsername("john"); // Too coupled to implementation
}
✅ Good Example:
1
2
3
4
5
6
@Test
void testUserServiceRejectsInvalidUsername() {
// Testing the behavior/outcome
assertThrows(IllegalArgumentException.class,
() -> userService.createUser("ab", "john@example.com"));
}
Pitfall 2: Multiple Assertions on Unrelated Things
❌ Bad Example:
1
2
3
4
5
6
7
@Test
void testEverything() {
// Testing too many unrelated things
assertEquals(8, calculator.add(5, 3));
assertEquals(5.0, calculator.divide(10, 2));
assertTrue(calculator.isEven(4));
}
✅ Good Example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testAddition() {
assertEquals(8, calculator.add(5, 3));
}
@Test
void testDivision() {
assertEquals(5.0, calculator.divide(10, 2));
}
@Test
void testIsEven() {
assertTrue(calculator.isEven(4));
}
Pitfall 3: Ignoring Test Failures
Never ignore a failing test. Either fix the code, fix the test, or if the test is truly obsolete, delete it. A failing test that’s ignored becomes technical debt.
Best Practices Checklist
- ✅ One concept per test: Each test should verify one specific behavior
- ✅ Clear test names: Use descriptive names that explain what is being tested
- ✅ Independent tests: Tests should not depend on execution order
- ✅ Use @BeforeEach: Set up common test data to avoid duplication
- ✅ Test edge cases: Don’t just test the happy path
- ✅ Keep tests simple: Tests should be easier to understand than production code
- ✅ Fast execution: Avoid slow operations like network calls or file I/O
- ✅ Meaningful assertions: Include messages explaining what should happen
Advanced Testing Concepts (Preview)
As you grow as a developer, you’ll encounter more advanced testing concepts:
Test Doubles
Objects that replace real dependencies in tests.
Types of test doubles:
- Mock: Simulates behavior and verifies interactions
- Stub: Returns predefined responses
- Fake: Working implementation but simplified (e.g., in-memory database)
- Spy: Wraps real object and records interactions
Example with Mockito:
1
2
UserRepository mockRepo = mock(UserRepository.class);
when(mockRepo.findById(1)).thenReturn(testUser);
Parameterized Tests
Run the same test with multiple inputs.
Example:
1
2
3
4
5
6
7
8
9
10
@ParameterizedTest
@CsvSource({
"2, true",
"3, false",
"0, true",
"-4, true"
})
void testIsEven(int number, boolean expected) {
assertEquals(expected, calculator.isEven(number));
}
Test Coverage
Measuring how much code is exercised by tests.
Coverage tools like JaCoCo show which lines of code are tested. While high coverage is good, 100% coverage doesn’t guarantee quality tests.
Aim for:
- 70-80% coverage for most projects
- 90%+ for critical business logic
- Focus on meaningful tests, not just coverage percentage
Practical Exercise
Try writing unit tests for this PasswordValidator class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class PasswordValidator {
public boolean isStrong(String password) {
if (password == null || password.length() < 8) {
return false;
}
boolean hasUpperCase = !password.equals(password.toLowerCase());
boolean hasLowerCase = !password.equals(password.toUpperCase());
boolean hasDigit = password.matches(".*\\d.*");
return hasUpperCase && hasLowerCase && hasDigit;
}
}
Test cases to implement:
- Valid strong password
- Password too short
- Null password
- Password without uppercase letter
- Password without lowercase letter
- Password without digit
- Empty string
Conclusion
Unit testing is a fundamental skill that separates professional developers from beginners. By writing tests, you:
- ✅ Catch bugs before they reach production
- ✅ Document how your code should behave
- ✅ Enable safe refactoring and improvements
- ✅ Build confidence in your codebase
- ✅ Develop better software design skills
Start small: write tests for new code you create, and gradually add tests for existing code. Over time, testing becomes second nature, and you’ll wonder how you ever developed without it.
Remember: The best time to start writing tests was when you began coding. The second-best time is now!
Next Steps
- Practice with the JUnit framework on your current projects
- Explore mocking frameworks like Mockito for handling dependencies
- Learn about integration testing to complement your unit tests
- Study Test-Driven Development (TDD) methodology
- Set up code coverage tools to track your testing progress
Happy testing! 🧪
