Skip to Content
Course content

123: Unit Testing with JUnit 5

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was lead on a project for a fintech client. We had a developer—smart guy, but he had a habit of "quick fixes." He pushed a one-line change to the interest calculation logic to handle a weird rounding edge case for a single client. He checked it manually with one test case, saw it worked, and merged it. Two hours later, the CFO called me because the system had accidentally wiped out the interest accruals for every corporate account in the database. He had fixed the edge case but broke the core logic for everyone else. That's the "regression" nightmare, and it's exactly why we don't trust manual testing for core business logic.

Unit testing is your safety net. Instead of clicking through the UI or manually running the app and typing in numbers, you write a separate piece of code whose only job is to verify that a specific method in your application behaves exactly as expected. JUnit 5 is the industry standard for this in Java. It allows you to run hundreds of these checks in seconds, ensuring that when you fix a bug today, you aren't creating three more for tomorrow.

Defining Expectations with Assertions

At its heart, a unit test is just a method annotated with @Test that calls a piece of logic and then asks: "Is the result what I expected?" If the answer is yes, the test passes. If no, it fails and tells you exactly why.

Let's say we're building a DiscountService that calculates a final price based on a customer's membership tier. You don't want to manually test every tier every time you change a line of code. Here is how you'd approach it with JUnit 5:

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

class DiscountServiceTest {

    @Test
    void shouldApplyGoldTierDiscount() {
        DiscountService service = new DiscountService();
        double result = service.calculatePrice(100.0, "GOLD");
        
        // We expect a 20% discount, so 100.0 becomes 80.0
        assertEquals(80.0, result, "Gold tier should receive a 20% discount");
    }

    @Test
    void shouldApplyNoDiscountForStandardTier() {
        DiscountService service = new DiscountService();
        double result = service.calculatePrice(100.0, "STANDARD");
        
        assertEquals(100.0, result);
    }
}

Notice the assertEquals method. I always recommend adding a custom message (the third argument) to your assertions. When a build fails in a CI/CD pipeline at 3 AM, seeing "Gold tier should receive a 20% discount" is infinitely more helpful than seeing "Expected 80.0 but was 100.0".

Testing for the "Sad Path"

Beginners often only test the "Happy Path"—the scenarios where everything goes right. But as a professional, you spend a lot of your time testing the "Sad Path." What happens if the user passes a null tier? What happens if the price is negative? Your code should fail predictably, and your tests should prove that it does.

JUnit 5 provides assertThrows for this. If your DiscountService is designed to throw an IllegalArgumentException when a negative price is provided, you test it like this:

@Test
void shouldThrowExceptionForNegativePrice() {
    DiscountService service = new DiscountService();
    
    assertThrows(IllegalArgumentException.class, () -> {
        service.calculatePrice(-10.0, "GOLD");
    });
}

I can't stress this enough: testing your exceptions is just as important as testing your return values. It's the difference between a system that crashes with a cryptic NullPointerException and one that tells the user exactly what they did wrong.

Cleaning Up the Test Noise

As you write more tests, you'll notice you're doing the same setup over and over—like instantiating the DiscountService in every single method. It makes the tests cluttered and harder to maintain. If you change the constructor of your service, you'll have to update twenty different tests. That's a waste of your time.

To fix this, we use @BeforeEach. This tells JUnit to run a specific method before every single @Test method in the class. It keeps your individual tests lean and focused on the actual logic being verified.

class DiscountServiceTest {
    private DiscountService service;

    @BeforeEach
    void setUp() {
        // This runs before every test, giving us a fresh instance
        service = new DiscountService();
    }

    @Test
    void testGoldDiscount() {
        assertEquals(80.0, service.calculatePrice(100.0, "GOLD"));
    }

    @Test
    void testSilverDiscount() {
        assertEquals(90.0, service.calculatePrice(100.0, "SILVER"));
    }
}



📋 Practical Task

Fixing the Broken LoyaltyPointCalculator

You've inherited a LoyaltyPointCalculator class from a developer who left the company. The business logic is supposed to be:

  • Users earn 1 point for every $10 spent.
  • "Premium" members earn 1.5 points for every $10 spent.
  • Spending less than $0 should throw an IllegalArgumentException.

However, the current implementation is buggy, and the provided tests are failing. Your task is to fix the calculatePoints method so that all the JUnit 5 tests pass.

// --- The Buggy Implementation ---
public class LoyaltyPointCalculator {
    public double calculatePoints(double amount, String memberType) {
        if (amount < 0) {
            // BUG: This should throw an IllegalArgumentException
            return 0; 
        }
        
        if ("PREMIUM".equalsIgnoreCase(memberType)) {
            // BUG: Incorrect multiplier logic
            return amount * 0.1; 
        }
        
        return amount * 0.1; // Standard members
    }
}

// --- The Test Suite (Do not change this) ---
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import static org.junit.jupiter.api.Assertions.*;

class LoyaltyPointCalculatorTest {
    private LoyaltyPointCalculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new LoyaltyPointCalculator();
    }

    @Test
    void shouldCalculateStandardPoints() {
        assertEquals(10.0, calculator.calculatePoints(100.0, "STANDARD"));
    }

    @Test
    void shouldCalculatePremiumPoints() {
        assertEquals(15.0, calculator.calculatePoints(100.0, "PREMIUM"));
    }

    @Test
    void shouldThrowExceptionForNegativeAmount() {
        assertThrows(IllegalArgumentException.class, () -> {
            calculator.calculatePoints(-50.0, "STANDARD");
        });
    }
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.