Skip to content

07 · JUnit 5 Fundamentals

JUnit 5 is the standard unit-testing framework for Java. It gives you three things: a way to mark a method as a test (@Test), a way to check a result (assertions), and a lifecycle for setup and teardown. Everything you learned about equivalence partitioning and boundary values in Module 05 now becomes executable.

1. Your first test

Create src/test/java/com/example/tests/CalculatorTest.java, testing the Calculator from Module 06:

package com.example.tests;

import com.example.Calculator;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CalculatorTest {

    @Test
    void addsTwoPositiveNumbers() {
        Calculator calc = new Calculator();      // Arrange
        int result = calc.add(2, 3);             // Act
        assertEquals(5, result);                 // Assert
    }
}
mvn test
[INFO] -------------------------------------------------------
[INFO]  T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.example.tests.CalculatorTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.041 s
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] BUILD SUCCESS

The AAA pattern

Every test, in every framework, in every language, has the same three parts:

Phase Purpose
Arrange Set up the object and data under test
Act Perform the single action being tested
Assert Verify the outcome matches the expectation

Keep them visually separate. A test with assertions scattered between actions is hard to read and usually testing more than one thing.

What a failure looks like

Change the assertion to assertEquals(6, result):

[ERROR] Tests run: 1, Failures: 1, Errors: 0, Skipped: 0
[ERROR] CalculatorTest.addsTwoPositiveNumbers:14
       expected: <6> but was: <5>
[ERROR] BUILD FAILURE

expected: <6> but was: <5> — this is the machine equivalent of the "Expected Result / Actual Result" fields from your bug report template in Module 04. The parallel is exact.

2. Assertions

An assertion is the pass/fail decision. If it holds, execution continues; if it fails, it throws and the test is marked failed immediately.

package com.example.tests;

import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;

class AssertionDemoTest {

    @Test
    void coreAssertions() {
        assertEquals(5, 2 + 3);                        // exact equality
        assertNotEquals(4, 2 + 3);                     // inequality
        assertTrue("qa.user@example.com".contains("@"));
        assertFalse("".length() > 0);
        assertNull(System.getProperty("no.such.property"));
        assertNotNull("hello");
        assertSame("abc", "abc");                      // same object reference
        assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
        assertIterableEquals(List.of("a", "b"), List.of("a", "b"));
    }

    @Test
    void floatingPointNeedsATolerance() {
        double total = 0.1 + 0.2;             // = 0.30000000000000004
        // assertEquals(0.3, total);          // FAILS -- binary floating point
        assertEquals(0.3, total, 0.0001);     // passes: within delta
    }

    @Test
    void failureMessagesShouldExplainThemselves() {
        int cartItems = 3;
        assertEquals(3, cartItems,
                "Cart should contain 3 items after adding 3 products");
    }
}

Always add a message to a non-obvious assertion

expected: <true> but was: <false> tells you nothing at 2 a.m. when a CI run fails. "Logout link should be visible after successful login ==> expected: <true> but was: <false>" tells you exactly what broke. The third argument to any assertion is a message — use it.

Testing that something throws

Negative test cases (Module 02) become assertThrows:

@Test
void divideByZeroThrowsIllegalArgument() {
    Calculator calc = new Calculator();

    IllegalArgumentException ex = assertThrows(
            IllegalArgumentException.class,
            () -> calc.divide(10, 0));

    assertEquals("Cannot divide by zero", ex.getMessage());
}

Capturing the exception and asserting on its message matters — otherwise the test passes if any IllegalArgumentException is thrown, including one from an unrelated bug.

Grouped assertions

assertAll runs every assertion even if earlier ones fail, then reports all failures together — the equivalent of the multi-part expected result from your Module 02 test case template.

@Test
void discountTiersAreCorrect() {
    Calculator calc = new Calculator();

    assertAll("discount tiers",
            () -> assertEquals(0,  calc.discountFor(500),   "under 1000 -> 0%"),
            () -> assertEquals(5,  calc.discountFor(2500),  "1000-4999 -> 5%"),
            () -> assertEquals(10, calc.discountFor(7500),  "5000-9999 -> 10%"),
            () -> assertEquals(15, calc.discountFor(15000), "10000+ -> 15%"));
}

If two tiers are wrong, you see both failures in one run instead of fixing, re-running, and discovering the next one.

3. The test lifecycle

package com.example.tests;

import com.example.Calculator;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.assertEquals;

class LifecycleTest {

    private Calculator calc;

    @BeforeAll
    static void setUpSuite() {
        System.out.println("@BeforeAll  -- once, before any test in this class");
    }

    @BeforeEach
    void setUp() {
        System.out.println("  @BeforeEach -- before EVERY test");
        calc = new Calculator();          // fresh object per test
    }

    @Test
    void firstTest() {
        System.out.println("    running firstTest");
        assertEquals(4, calc.add(2, 2));
    }

    @Test
    void secondTest() {
        System.out.println("    running secondTest");
        assertEquals(1, calc.subtract(3, 2));
    }

    @AfterEach
    void tearDown() {
        System.out.println("  @AfterEach  -- after EVERY test");
        calc = null;
    }

    @AfterAll
    static void tearDownSuite() {
        System.out.println("@AfterAll   -- once, after all tests in this class");
    }
}
@BeforeAll  -- once, before any test in this class
  @BeforeEach -- before EVERY test
    running firstTest
  @AfterEach  -- after EVERY test
  @BeforeEach -- before EVERY test
    running secondTest
  @AfterEach  -- after EVERY test
@AfterAll   -- once, after all tests in this class
Annotation Runs Must be static? Typical use in test automation
@BeforeAll Once per class, first Yes (by default) Start a WebDriver, open a DB connection, load config
@BeforeEach Before every test No Fresh object, navigate to the start page, reset test data
@Test The test itself No One check
@AfterEach After every test No Take a screenshot on failure, clear cookies, delete created records
@AfterAll Once per class, last Yes Quit the driver, close the connection

Why @BeforeEach creates a new object every time

Test independence (Module 02, rule 5) is enforced here. Each test gets a clean Calculator, so no test can leave state that changes another test's result. This is the number-one cause of "passes alone, fails in the suite" — and of the flaky tests you will diagnose in Level 3.

4. Other annotations you will use

package com.example.tests;

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

@DisplayName("Shopping cart behaviour")
class AnnotationDemoTest {

    @Test
    @DisplayName("Cart total updates when an item is removed")
    void cartTotalUpdatesOnRemoval() {
        assertEquals(2, 3 - 1);
    }

    @Test
    @Disabled("Blocked by BUG-114 -- re-enable when the fix ships")
    void loginShowsSpecificErrorMessage() {
        // Skipped, but visible in the report as skipped rather than deleted
    }

    @Test
    @Tag("smoke")
    void applicationHomePageLoads() {
        assertEquals(200, 200);
    }

    @Test
    @Tag("regression")
    void archivedOrdersAreSearchable() {
        assertEquals(1, 1);
    }

    @RepeatedTest(3)
    @DisplayName("Session token generation is stable across repeats")
    void repeatsThreeTimes(RepetitionInfo info) {
        System.out.println("Run " + info.getCurrentRepetition()
                + " of " + info.getTotalRepetitions());
        assertEquals(1, 1);
    }
}
Run 1 of 3
Run 2 of 3
Run 3 of 3
Tests run: 6, Failures: 0, Errors: 0, Skipped: 1

@Tag lets you run subsets — exactly the smoke/regression split from Module 03:

mvn test -Dgroups=smoke

@Disabled with a reason is far better than commenting a test out: the report shows it as skipped, so nobody forgets it exists.

5. Parameterized tests — automating EP and BVA

This is where Module 05 pays off. Instead of one test method per boundary value, you feed the values in.

Add to pom.xml:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-params</artifactId>
    <version>5.10.2</version>
    <scope>test</scope>
</dependency>
package com.example.tests;

import com.example.Calculator;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;

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

class ParameterizedDemoTest {

    private final Calculator calc = new Calculator();

    // Boundary value analysis for the 1000/5000/10000 discount tiers
    @ParameterizedTest(name = "order {0} -> {1}% discount")
    @CsvSource({
            "999.99,   0",     // just below the first boundary
            "1000.00,  5",     // exactly at the boundary
            "1000.01,  5",     // just above
            "4999.99,  5",
            "5000.00, 10",
            "5000.01, 10",
            "9999.99, 10",
            "10000.00, 15",
            "10000.01, 15",
            "0.00,     0"      // zero -- the edge from Module 05
    })
    void discountBoundaries(double amount, int expectedDiscount) {
        assertEquals(expectedDiscount, calc.discountFor(amount));
    }

    // Equivalence partition: every negative value is rejected identically
    @ParameterizedTest(name = "amount {0} is rejected")
    @ValueSource(doubles = {-0.01, -1, -100, -99999})
    void negativeAmountsAreRejected(double amount) {
        assertThrows(IllegalArgumentException.class,
                () -> calc.discountFor(amount));
    }
}
[INFO] Running com.example.tests.ParameterizedDemoTest
order 999.99 -> 0% discount           PASSED
order 1000.0 -> 5% discount           PASSED
order 1000.01 -> 5% discount          PASSED
order 4999.99 -> 5% discount          PASSED
order 5000.0 -> 10% discount          PASSED
order 5000.01 -> 10% discount         PASSED
order 9999.99 -> 10% discount         PASSED
order 10000.0 -> 15% discount         PASSED
order 10000.01 -> 15% discount        PASSED
order 0.0 -> 0% discount              PASSED
amount -0.01 is rejected              PASSED
amount -1.0 is rejected               PASSED
amount -100.0 is rejected             PASSED
amount -99999.0 is rejected           PASSED
[INFO] Tests run: 14, Failures: 0, Errors: 0, Skipped: 0

Fourteen boundary and partition checks from two methods. That is the whole point: your manual test design technique becomes a data table, and the framework does the repetition.

Other data sources:

Annotation Source
@ValueSource A simple array of one-argument values
@CsvSource Inline rows of comma-separated arguments
@CsvFileSource(resources = "/testdata.csv") A CSV in src/test/resources
@MethodSource("methodName") A static method returning a Stream of arguments
@EnumSource(Status.class) Every value of an enum
@NullAndEmptySource null and "" — the two inputs everyone forgets

6. Naming tests

The test name is documentation. It appears in the report when it fails, so it should state the expectation.

❌ Weak ✅ Strong
test1() addsTwoPositiveNumbers()
testLogin() loginWithInvalidPasswordShowsGenericError()
testDiscount() orderOfExactly5000GetsTenPercentDiscount()
checkCart() removingLastItemSetsCartTotalToZero()

A widely used convention is methodUnderTest_condition_expectedOutcome, e.g. discountFor_amountBelow1000_returnsZero(). Pick one style and be consistent.

7. Reading the report

After mvn test, look in target/surefire-reports/:

target/surefire-reports/
├── com.example.tests.CalculatorTest.txt      ← human-readable summary
└── TEST-com.example.tests.CalculatorTest.xml ← machine-readable (JUnit XML)

The XML format is the universal interchange for test results — Jenkins, GitHub Actions, Allure and every other tool reads it. That is how your local run becomes a CI dashboard in Level 3.

Cheat sheet

Annotation / method Purpose
@Test Marks a test method
@BeforeAll / @AfterAll Once per class (static)
@BeforeEach / @AfterEach Around every test
@DisplayName("…") Readable name in reports
@Disabled("reason") Skip with a documented reason
@Tag("smoke") Group tests for selective runs
@RepeatedTest(n) Run the same test n times
@ParameterizedTest + @CsvSource Data-driven tests — automate EP/BVA
assertEquals(exp, act, msg) Value equality
assertTrue / assertFalse Boolean conditions
assertNull / assertNotNull Null checks
assertThrows(Type.class, () -> …) Negative tests
assertAll(…) Report every failure in one run
mvn test Run the suite

How It Actually Works

Nothing in CalculatorTest ever calls addsTwoPositiveNumbers() directly — you never write a main method, and no line of your code invokes that method by name. JUnit 5 finds and runs it through reflection-based test discovery: the JUnit Platform's discovery phase scans every .class file on the test classpath, loads each class via Class.forName(...), and asks the Jupiter engine's TestEngine implementation to inspect it for methods annotated @Test using java.lang.reflect.Method.getAnnotations(). Nothing about the name addsTwoPositiveNumbers matters to the framework — only the annotation does, which is why you're free to name test methods in plain English describing behavior.

Once a @Test method is found, JUnit builds a fresh instance of the test class per test method (new CalculatorTest() runs again for every @Test, by default) specifically so that fields aren't shared state leaking between tests, then invokes the method reflectively via method.invoke(instance). Any thrown exception — including the AssertionError an assertion raises — is caught by the engine and recorded as a failure; a clean return is recorded as a pass. That's the entire mechanism behind expected: <6> but was: <5>: assertEquals doesn't "fail" the test in any special way, it just constructs and throws a org.opentest4j.AssertionFailedError with that message, and the engine's invoke() call sees the exception propagate and marks the result accordingly — an assertion failure and an uncaught bug in your code (a NullPointerException, say) are structurally the same event to JUnit, which is why Surefire's report distinguishes "Failures" (assertion failures) from "Errors" (everything else) even though both come through the identical reflective invocation path.

The Maven Surefire plugin sits one layer above this: it doesn't run JUnit directly, it forks a JVM process, puts your compiled test classes and JUnit's jars on that JVM's classpath, and hands control to the JUnit Platform Launcher API, which is what actually drives discovery and execution and reports results back up to Maven's build lifecycle — this is why mvn test can run tests written against JUnit, TestNG, or both, in the same build: Surefire talks to a stable Launcher API, not to any one framework's internals.

Exercise

Working in the java-testing-practice project from Module 06:

  1. Create LoginValidator in src/main/java/com/example/ with a method boolean isValidPassword(String password) implementing the rule from Module 05: 8–20 characters, at least one uppercase letter, one digit and one special character. Throw IllegalArgumentException for null.
  2. Write LoginValidatorTest covering it. Include:
  3. A @ParameterizedTest with @CsvSource covering the length boundaries — 7, 8, 9, 19, 20 and 21 characters — with the expected boolean for each.
  4. Separate tests for each missing character class (no uppercase, no digit, no special).
  5. An assertThrows test for null, asserting the exception message.
  6. @NullAndEmptySource on a parameterized test.
  7. Use @DisplayName on the class and on at least three methods so the report reads like specification sentences.
  8. Add @BeforeEach that prints the test's TestInfo display name, and confirm from the output that it runs before each test.
  9. Deliberately introduce an off-by-one bug in isValidPassword (use > 8 instead of >= 8). Run mvn test, and confirm that exactly the boundary test for 8 characters fails — and that nothing else does. This demonstrates why BVA has the highest yield per test case.