Home Unit Testing
Post
Cancel
Unit Testing | SEG

Unit Testing

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:

PrincipleMeaningWhy It Matters
FastTests run quicklyEncourages running tests frequently
IndependentTests don’t depend on each otherCan run in any order, easier to debug
RepeatableSame results every timeBuilds confidence in test reliability
Self-validatingClear pass/fail resultNo manual verification needed
TimelyWritten with or before codeCatches 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

AspectUnit TestsIntegration Tests
ScopeSingle method/classMultiple components working together
SpeedVery fast (milliseconds)Slower (seconds to minutes)
DependenciesIsolated with mocksUses real dependencies
PurposeVerify logic correctnessVerify components interact correctly
QuantityMany (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 @Nested annotation 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:

  1. Valid strong password
  2. Password too short
  3. Null password
  4. Password without uppercase letter
  5. Password without lowercase letter
  6. Password without digit
  7. 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! 🧪

This post is licensed under CC BY 4.0 by the author.