Java

JUnit 5: Writing Your First Tests

Executive Summary

In JUnit 5, a test is a method annotated @Test in a class under src/test/java. Per the JUnit 5 user guide it passes when its assertions hold and fails when the first one does not. The assertion vocabulary is small and composable. First, assertEquals(expected, actual) takes expected first, because the failure message reads that way. Then assertTrue and assertFalse check conditions, and assertNull and assertNotNull check presence. Also, assertAll groups assertions that all report. Finally, assertThrows verifies that the exceptions article’s failures happen and returns the exception for further checking. Test independence comes from the lifecycle. @BeforeEach runs before every test, giving each one fresh state. Then @AfterEach cleans up, while @BeforeAll handles expensive one-time setup.

Parameterized tests, @ParameterizedTest with @ValueSource or @CsvSource, let one method cover many inputs. In effect, they multiply your boundary cases. Then Maven runs it all through surefire, which picks up classes named *Test by convention. Afterward, mvn test reports each failure with the method, the assertion, and both values. Four habits matter from day one. Put expected before actual, and test one behavior per test. Give tests names that read as sentences, and give each one fresh state. After all, a test suite that depends on execution order is a suite that lies.

Your First Test

Start with the calculator’s divide method, plain code with real failure modes:

// src/main/java/in/imraan/calc/Calculator.java
package in.imraan.calc;

public class Calculator {
    public double divide(double dividend, double divisor) {
        if (divisor == 0) {
            throw new ArithmeticException("divisor must not be zero");
        }
        return dividend / divisor;
    }
}

The test class mirrors the main class in src/test/java. That convention carries the whole industry: same package, Test suffix:

// src/test/java/in/imraan/calc/CalculatorTest.java
package in.imraan.calc;

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

class CalculatorTest {

    @Test
    void dividesTwoNumbers() {
        var calculator = new Calculator();

        double result = calculator.divide(10, 4);

        assertEquals(2.5, result);
    }
}
mvn test
// [INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
// the red-green rhythm: break the code, watch it fail, fix it, watch it pass

Three conventions in that test deserve their weight in gold. First, the name reads as a sentence, dividesTwoNumbers. A failing report that says dividesByZeroIsRejected tells the story without opening the file. Second, the arrange, act, assert shape keeps every test scannable: build the input, call the method, check the result. Third, there is no main method anywhere. Instead, surefire found the class by its name, found the method by its annotation, and ran it for you.

Assertions: The Vocabulary of Verification

Six assertions cover almost everything, and the JUnit API documents dozens of specialized variants of these:

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

assertEquals(2.5, result);                    // expected FIRST, actual second
assertTrue(result > 0);                       // a condition that must hold
assertFalse(list.isEmpty());                  // a condition that must not
assertNull(cache.get("absent"));               // absence
assertNotNull(new Calculator());              // presence

// groups: every assertion runs, every failure reports (not just the first)
assertAll("loan",
    () -> assertEquals(2.5, loan.rate()),
    () -> assertEquals(12, loan.term()));

// exceptions: verify the throw, then check it, the exceptions article tie-in
var thrown = assertThrows(ArithmeticException.class,
                          () -> calculator.divide(10, 0));
assertEquals("divisor must not be zero", thrown.getMessage());

The argument order matters more than it looks. The signature assertEquals(expected, actual) exists because the failure message reads expected X but was Y. So swapped arguments produce a report that misleads exactly when you are tired. For floating point, use the delta form, assertEquals(2.5, result, 1e-9). That matters because exact binary equality on computed doubles is a flaky test waiting to happen. And assertAll is the honest answer to “many assertions or many tests”. When they describe one concept, group them so the report shows every broken field, not just the first.

The Lifecycle: Fresh State for Every Test

One rule makes suites trustworthy. Each test runs on fresh state, in any order, so no test can quietly depend on another’s leftovers. The annotations enforce it:

Annotation Runs Typical use
@BeforeEach Before every test method Build the object under test, seed small data
@AfterEach After every test method Close what a test opened, reset static state
@BeforeAll Once, before all tests, static by default Expensive setup: start a server, load a model
@AfterAll Once, after all tests Shut the expensive thing down
class LoanServiceTest {
    private LoanService service;

    @BeforeEach
    void freshService() {
        service = new LoanService();        // EVERY test starts from this state
    }

    @Test
    void approvesShortLoan() { ... }

    @Test
    void rejectsLoanAboveLimit() { ... }
    // either test could run first: neither knows, neither cares
}

The design point is simple. @BeforeEach replaces the alternative, sharing a field between tests, which is exactly how order-dependent suites get born. If setup is expensive enough to share, that is a signal about your design: the object under test is hard to construct. Fortunately, the good tests article two places ahead handles that pattern properly with test fixtures and builders.

Parameterized Tests: One Method, Many Cases

Boundary bugs live at the edges: zero, one, the last element, the first after the end. However, writing a separate method per edge case drowns the logic in ceremony. JUnit 5’s parameterized tests let one method consume a table:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

class LateFeeTest {

    @ParameterizedTest
    @CsvSource({
        " 0,  0.00",      // on time: day zero is the boundary, still no fee
        " 1,  1.50",      // the off-by-one the control-flow article charged twice
        "30, 45.00",
        "31, 60.00"       // the next tier boundary
    })
    void feeMatchesPolicy(int daysLate, double expectedFee) {
        assertEquals(expectedFee, new LateFee().forDays(daysLate), 1e-9);
    }
}

The @ValueSource sibling covers the simpler case, one primitive per run. Meanwhile, @CsvSource covers rows like the table above. The discipline this feature teaches is the valuable part. When you add a case, you add a row, not a method. As a result, the boundary cases accumulate visibly instead of rotting in a backlog. Notice also the record-friendly shape. In fact, records make ideal test inputs. Use one compact constructor line per case when the input outgrows a CSV row.

Running It All in Maven

The Maven article’s test phase is where all of this executes, and the conventions do the wiring. Surefire scans src/test/java for classes named *Test and runs every @Test and @ParameterizedTest. Then it fails the build on the first suite failure:

mvn test
// [ERROR] LateFeeTest.feeMatchesPolicy: expected: <1.5> but was: <0.0>
// [INFO] BUILD FAILURE
// the build refuses to pass while a belief is broken: this is the whole point

That BUILD FAILURE line is the feature, not the noise. A broken test stops the pipeline, and the CI article turns that stop into a blocked merge. Thus, the team’s rule becomes mechanical: untested code cannot reach production. The tooling enforces it before any human has to.

How Real Systems Do This

Mature Java codebases are test-shaped. Every service class has a test twin, and boundary-heavy logic carries parameterized tables. Moreover, the suite runs in seconds, so developers run it constantly instead of occasionally. The suite’s real value shows up in the changes you never hear about. Take the refactoring that could not have broken anything, except it did. The test caught it in eleven milliseconds.

The loop bug from the control-flow article is my proof story, because it predates my testing habit and paid for it. A billing loop with <= instead of < charged the final customer of every batch twice. It shipped for three weeks before a reconciliation report flagged it. At that point, the fix was one character, but the aftermath was refunds and an audit. The boundary test that would have caught it is four lines. It is the exact @CsvSource shape above, with day 0 and day 1 as rows.

I did not lack the tools. Instead, I lacked the habit, and the post-incident review made the habit cheap. From then on, every loop we shipped got an empty-input test, a single-item test, and a boundary test before review. That rule costs minutes. Yet it has caught the same bug class every year since, in someone else’s code, at the exact same cheap moment. Tests are not ceremony. In fact, they are the cheapest incident response you will ever run.

Decision Framework

  1. Does the method have branches, boundaries, or thrown exceptions? It needs tests, and the boundaries get parameterized rows.
  2. Is the code pure logic over data, parsing, calculating, classifying? Test it directly and exhaustively, because this is the cheapest, most valuable kind of test.
  3. Does it touch a database, the network, or the clock? That is the integration-test category. The mocking article next and the good tests article cover how and where.
  4. Is the assertion about a value? assertEquals. A condition? assertTrue. A failure? assertThrows plus a message check.
  5. Do several assertions describe one behavior? assertAll, so the report shows every break at once.
  6. Does each test build its own state? @BeforeEach, and any test that depends on a sibling’s leftovers is a bug in the suite.
  7. Are you choosing between many methods and many rows? One behavior per method, many cases per behavior, is the shape that scales.

When NOT to Use This

  • Do not write assertion-free tests. A @Test method that only calls code passes silently and verifies nothing. It is worse than no test because it looks like coverage.
  • Do not test trivial delegation, a getter returning a field, because the test then tests the language, not your logic.
  • Do not chase a coverage number. Coverage measures execution, not assertion quality. So 100 percent coverage of weak assertions is a security blanket, not a net.
  • Do not test private methods directly. Instead, test the observable behavior that uses them. Private means implementation detail, and testing it welds your suite to today’s internals.
  • Do not tolerate a slow suite. When mvn test takes minutes, developers stop running it. So profile the slow tests and fix them like any other performance problem.

Common Mistakes

  • Swapped assertion arguments: assertEquals(actual, expected) inverts every failure message. Then you debug backwards exactly when you are tired.
  • Shared mutable state between tests: the suite passes in one order and fails in another. It is the most disheartening bug class in testing, and @BeforeEach is the cure.
  • The try-fail anti-pattern: try { code(); fail(); } catch (…) {} verifies less than assertThrows. Worse, a miswritten catch can pass without anyone ever checking the exception.
  • Floating point without a delta: assertEquals on computed doubles is binary-exact comparison. As a result, the test flakes on the tenth CI run.
  • Test names like test1 and testDivide: the failure report becomes useless, and the sentence-named method is the documentation.
  • One test method verifying five behaviors: the first failure hides the rest, and assertAll or five focused methods report better.
  • Ignoring an intermittently failing test: a flaky test is a real bug or a bad test. Either way, deleting or ignoring it is how defects reach production quietly.

Key Takeaways

  • A test is an annotated method plus assertions. Surefire finds it, runs it, and fails the build when a belief breaks.
  • The assertion vocabulary is small: assertEquals with expected first, plus the condition and null checks. Also, assertAll handles grouped reporting, and assertThrows covers the exceptions article’s failures.
  • Fresh state per test via @BeforeEach is the independence rule: suites that depend on order lie.
  • Parameterized tests turn boundary cases into table rows: one behavior, many cases, visible accumulation.
  • Sentence-shaped names make the failure report readable without opening the file.
  • Floating point assertions need a delta. Also, real boundaries, zero, one, and the edges, are where the bugs live and where the rows belong.
  • A failing test failing the build is the point. After all, the suite is the mechanism that stops regressions before any human has to.

FAQ

What is JUnit 5?

JUnit 5 is the standard Java testing framework. It offers annotated test methods, a small assertion library, a lifecycle for fresh state, and parameterized tests for many inputs per method. Build tools run it automatically, and a failing assertion fails the build.

How do I write my first JUnit test?

Create a class under src/test/java named after the class under test, with a Test suffix. Then add a @Test method, build the input, and call the method. Finally, assert the result with assertEquals(expected, actual). Run mvn test and surefire executes it.

What is @BeforeEach in JUnit 5?

A method annotation that runs before every test method to give each test fresh state. For example, it builds the object under test, seeds small data, and resets anything shared. It is the mechanism behind test independence, because no test depends on another’s leftovers.

What is a parameterized test in JUnit 5?

A test method annotated @ParameterizedTest whose arguments come from a source like @ValueSource or @CsvSource. As a result, one method verifies a table of inputs. Boundary cases become rows, and adding a case costs one line instead of one method.

How do I test exceptions in JUnit 5?

Use assertThrows with the expected exception class and a lambda performing the risky call. The test fails if nothing throws. Otherwise, assertThrows returns the thrown exception so you can assert its type details and message.

Conclusion

You can now write tests that matter with JUnit 5. They use sentence-named methods with honest assertions and fresh state per test. Boundary cases become parameterized rows, and the build refuses to pass while a belief is broken. The calculator, the library domain, and the link checker all deserve this treatment, and the exercises from here assume it.

The next article handles the wall you have already hit or soon will. How do you test code whose dependencies are slow, external, or not yours yet? Mockito is the answer, stubbing the parts you do not want to run and verifying the interactions you do.

Write the test at the boundary, watch it fail once, and keep the cheapest incident response you will ever fund.

Last updated on 6 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *