AlgoMaster Logo

JUnit Testing

Medium Priority29 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

The previous lesson covered the conceptual side of testing: the test pyramid, the AAA shape, F.I.R.S.T. properties, and how to name a test so it reads like a sentence. This lesson is about the tool that actually runs those tests. JUnit 5 (also called Jupiter) is the de facto standard for Java testing, and almost every Java codebase has it set up already. This lesson wires it into a Maven and a Gradle project, writes a first test against an e-commerce class, and walks through the common parts of the API: assertions, lifecycle hooks, parameterized tests, nested grouping, exception testing, and mocking with Mockito.

Why JUnit 5

JUnit 4 ran the Java world for over a decade. It worked, but it was a single jar with no internal seams, designed before Java had lambdas, and its parameterized test API was famously awkward. JUnit 5 is a rewrite that fixes all three problems at once. The artifact is now split into three pieces:

ModuleWhat it does
JUnit PlatformThe runtime that IDEs, build tools, and CI servers talk to. It launches tests but doesn't define them.
JUnit JupiterThe actual programming model: @Test, assertions, lifecycle hooks, parameterized tests. This is what you write tests against.
JUnit VintageA compatibility engine that lets old JUnit 3 and 4 tests run on the new platform during a migration.

That split matters because it lets the API evolve without breaking the runners. A new annotation in Jupiter doesn't force every IDE to update. New engines can plug into the same platform; Kotlin's kotlin-test and Spock both ride on it.

The user-facing wins are simpler. Assertions take a Supplier<String> for failure messages, so the message string is only built when an assertion actually fails. Tests can be nested with @Nested to group related cases by behavior. Parameterized tests are first-class with a clean @ParameterizedTest plus source annotations. Display names can be sentences. The whole library targets Java 8 and up, so lambdas and method references are first-class throughout.

The build tool talks to the Platform. The Platform finds engines (Jupiter, Vintage, or others) and asks each one to discover and run its tests. Application code rarely touches the Platform directly; Jupiter tests do the work and the build tool handles the rest.

Adding JUnit 5 to a Project

A new test depends on the junit-jupiter aggregator artifact, which pulls in the API you write against, the engine that runs the tests, and the assertion classes.

Maven (pom.xml):

The test scope means the jars are on the test classpath but not the main one, which keeps test code from leaking into production builds. Surefire is the Maven plugin that runs unit tests, and it speaks the JUnit Platform protocol natively from version 3.0 onward.

Gradle (build.gradle):

The useJUnitPlatform() line is easy to miss on a first setup. Without it, Gradle defaults to the JUnit 4 runner and quietly skips every Jupiter test.

The standard source layout puts production code in src/main/java and tests in src/test/java. Both Maven and Gradle pick this up by convention. A test class that lives at src/test/java/com/shop/cart/CartTest.java belongs to the com.shop.cart package, the same as the class it tests. That matters because it lets the test see package-private members without making them public.

The two trees mirror each other one for one. A new class on the left wants a new test class on the right, in the same package, named after the class with a Test suffix.

Your First Test

A small e-commerce class and the test that exercises it. The class lives at src/main/java/com/shop/cart/Cart.java.

The test class lives at src/test/java/com/shop/cart/CartTest.java.

A few details. The test class and the test methods are both package-private (no public keyword). Jupiter discovers them by reflection and doesn't require public visibility, which keeps the test file uncluttered. The @Test annotation marks a method as a test case. The assertEquals import is a static import, which is the idiomatic style: it allows assertEquals(0, cart.size()) instead of Assertions.assertEquals(0, cart.size()).

Running mvn test (or gradle test) compiles the test sources, launches the JUnit Platform, and runs every @Test method it can find. The console output looks something like this.

Two passes. Each test method runs in isolation: Jupiter creates a fresh CartTest instance for each method by default, so the cart in newCartHasZeroItems is a different object from the cart in addingOneItemMakesSizeOne. The lifecycle section below covers this in more detail.

The Assertions API

Jupiter's Assertions class is the home of every standard assertion. Each method either passes silently or throws an AssertionFailedError with a message that includes the expected and actual values. The full set is in org.junit.jupiter.api.Assertions; the ones below cover the majority of cases.

assertEquals is the main building block. The first argument is the expected value, the second is the actual one. Get the order wrong and the failure message reads backwards, which is confusing at 2 AM but doesn't change whether the test passes. assertArrayEquals compares arrays element by element; using assertEquals on two arrays compares references and almost always fails. assertIterableEquals walks both iterables in order, comparing element by element.

The third argument on most assertions is an optional failure message. Jupiter accepts both a String and a Supplier<String>. The supplier form is preferred when the message is expensive to build, because the lambda only runs on failure.

If the assertion passes, the lambda never runs and the cart.items() call is skipped. Passing a plain String builds the message on every invocation, pass or fail. For one-off checks it doesn't matter; in a parameterized test that runs a thousand times, the Supplier form is preferable.

Grouped Assertions with assertAll

A test often has multiple expectations against the same object. Writing them one after another stops the test at the first failure, so a broken object that violates three invariants produces three separate test runs to find them all. assertAll solves this by running each assertion and reporting every failure together.

If two of those three checks fail, the failure message lists both. Without assertAll, only the first failure surfaces; fixing it, rerunning, and then learning about the second turns one investigation into several. Use assertAll whenever a single object's state needs to satisfy several conditions.

Every lambda inside assertAll is wrapped in a try/catch, so the assertion machinery is slightly heavier per check than a flat sequence. Negligible in normal tests, but avoid assertAll to wrap thousands of assertions in a loop.

Testing for Exceptions

Two patterns come up often. The first is "this call should throw exception X with a particular message." The second is "this call should not throw."

assertThrows takes the expected exception class and a lambda. It passes if the lambda throws that exception (or a subclass) and fails if the lambda completes normally or throws a different type. The returned exception is the actual thrown object, available for further assertions about its message, cause, or fields. assertDoesNotThrow is the inverse, and is mostly useful when the call has side effects that should keep running after the check.

Time-Bound Assertions

assertTimeout runs a lambda and fails if it takes longer than the given duration. There are two flavors with a real difference in behavior.

assertTimeout runs the lambda to completion on the same thread, then checks whether it took too long. A hung lambda will block the test forever. assertTimeoutPreemptively runs the lambda on a separate thread and interrupts it when the timeout fires. The preemptive form is appropriate when "stuck forever" is one of the failure modes; the regular form is fine for measuring code that's trusted to finish.

Lifecycle Hooks

A test class often needs setup work: building a fixture, opening a connection, registering a fake clock. Jupiter has four annotations for that, and the names spell out exactly when they fire.

AnnotationFiresTypical use
@BeforeEachBefore every @Test methodBuild a fresh fixture per test
@AfterEachAfter every @Test methodClean up state, reset statics
@BeforeAllOnce before any test in the classOpen a connection, start a fake server
@AfterAllOnce after every test in the classClose the connection, stop the server

The BeforeAll and AfterAll methods must be static by default, because Jupiter calls them before any instance of the test class exists. The BeforeEach and AfterEach methods are instance methods, so they can read and write fields on the test class.

The BeforeEach method runs before each test, so each test sees a cart with exactly one item. The second test adds another item and gets a size of two; the first test isn't affected, because its cart was a different object. This isolation prevents bugs in one test from leaking into another.

The diagram below shows the order in which hooks fire when a class has two test methods.

BeforeAll and AfterAll bookend the whole class. BeforeEach and AfterEach bookend every test. If a BeforeEach throws, the test is reported as a failure and AfterEach still runs, so cleanup is guaranteed even on a broken fixture.

Test Instance Lifecycle: PER_METHOD vs PER_CLASS

By default, Jupiter creates a new instance of the test class for every @Test method. This is why @BeforeAll has to be static: there's no instance to hold the state. The default mode is called PER_METHOD and is the standard choice, because it forces tests to be independent.

For one instance per whole class (which lets @BeforeAll be non-static and lets tests share fields without resetting them), annotate the class with @TestInstance(Lifecycle.PER_CLASS).

PER_CLASS fits when the per-test setup is expensive (a real database connection, a server process) and the tests do not mutate shared state. Otherwise, leave the default. The default isolation prevents a whole class of "works alone, fails in a suite" bugs.

Display Names

By default a test's display name is the method name, which is fine for simple cases but reads awkwardly in reports for anything more interesting. @DisplayName overrides the name with a human-friendly string.

The class and method names still need to be valid Java identifiers, but the display names can contain spaces, punctuation, and even non-ASCII characters. IDEs and CI reports show the display name when one is set, falling back to the method name when not.

For a project-wide style, @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) will turn underscores in method names into spaces automatically, so void adding_an_item_increases_size() reads as "adding an item increases size" in reports. The trade-off is method names full of underscores in source. Pick a style and apply it consistently.

Disabling and Conditional Execution

Sometimes a test needs to be temporarily skipped: it's flaky, the feature isn't shipped yet, or it only makes sense on a particular operating system. Jupiter has annotations for each case.

@Disabled skips the test and prints the reason in the report. The reason is mandatory in spirit, even when not enforced; a disabled test with no comment becomes a permanent skip. Conditional annotations like @EnabledOnOs, @EnabledOnJre, @EnabledIfSystemProperty, and @EnabledIfEnvironmentVariable give finer control.

The skip is visible in the build output, so CI does not hide the fact that the test was skipped.

Tagging Tests

@Tag attaches arbitrary labels to a test or a whole class. The build can then run only tests with a particular tag, or exclude tests by tag.

In Maven Surefire, run only fast tests with mvn test -Dgroups=fast. In Gradle, use test { useJUnitPlatform { includeTags 'fast' } }. Tags are useful for separating quick unit tests from slow integration tests, so CI can run the fast suite on every commit and the slow suite once an hour. Keep the tag vocabulary small; a project with twenty tags becomes hard to navigate.

Parameterized Tests

The same test logic often needs to run against many inputs. Writing one method per input is repetitive and obscures the pattern. @ParameterizedTest runs the same method body once per value, with each value passed as an argument.

Each source annotation is its own micro-language. @ValueSource works for a flat list of primitives or strings. @CsvSource accepts comma-separated lines, parsing each line into the method's parameter list. @EnumSource runs the method once per enum constant. @MethodSource calls a static method on the test class that returns a Stream<Arguments>, the fallback for arbitrarily complex inputs that the others can't express.

The output names each invocation, so a failure points at the exact input that broke.

For larger CSV fixtures, @CsvFileSource reads a CSV file from the test classpath, which keeps the data out of the source file when the table grows past five or ten rows.

Repeated Tests

@RepeatedTest runs the same test method a given number of times. It can flush out flakiness in tests that depend on timing or random data, although the better fix for a flaky test is usually to remove the source of the flake.

The name placeholder values produce report entries like Run 1 of 5, Run 2 of 5, and so on. The optional RepetitionInfo parameter exposes which repetition is running, occasionally useful for skipping the warm-up runs in a benchmark-style test.

Nested Tests

Real domain objects have multiple behaviors that deserve their own grouping. A Cart has add behavior, remove behavior, and total-calculation behavior. Putting all the tests in a flat class with eight methods works, but the relationships between methods are lost. @Nested groups tests by behavior in inner classes that still share the outer fixture.

The IDE displays this as a tree:

Each nested class is a non-static inner class, so it can access the outer class's fields. The outer @BeforeEach runs before every test in every nested class, giving each test a fresh cart. Nested classes can have their own @BeforeEach for setup that's specific to a behavior group.

Two or three levels of nesting works well; beyond that, the structure becomes harder to read than the flat version it was meant to replace.

Test Order

By default, Jupiter runs test methods in a deterministic but unspecified order. The order is stable across runs but isn't the source order, which is a deliberate choice: tests that depend on a specific order are fragile and almost always wrong.

When a specific order is required (a tutorial test class that walks through a workflow, for example), @TestMethodOrder(MethodOrderer.OrderAnnotation.class) lets @Order(int) on each method set the sequence.

Use this sparingly. A test that depends on the result of the previous test isn't a unit test; it's a workflow split across methods. The healthier alternative is one method that drives the whole workflow, with assertAll to report on every step in one go.

Assumptions

An assumption is a check that decides whether a test should run at all. Failing an assumption skips the test rather than failing it, which is the right behavior for "this test only makes sense in environment X." The two main entry points are Assumptions.assumeTrue and Assumptions.assumingThat.

assumeTrue short-circuits the test if the condition is false, marking it as skipped with the given message. assumingThat runs the lambda only when the condition is true; the rest of the test continues either way. The distinction matters: assumeTrue is "this whole test is irrelevant right now," assumingThat is "these extra checks only apply in some environments."

Compared to @Disabled, an assumption is dynamic: the decision is made at runtime based on whatever state is checked. Use assumptions for "this needs a database that's only available locally" and @Disabled for "this test is broken and tracked in ticket X."

Mocking Dependencies with Mockito

Unit tests work best when each test exercises one class in isolation. Real classes pull in dependencies: a service talks to a repository, the repository talks to a database, the controller talks to a payment client. To keep a service test from also testing the database and the payment provider, replace those dependencies with mocks: stand-ins that record calls and return pre-programmed values.

Mockito is a separate library, not part of JUnit. Add it as another test dependency.

Maven:

Gradle:

Suppose the e-commerce app has an OrderService that uses a PaymentClient interface to charge a customer.

The test verifies two things: that placeOrder calls paymentClient.charge with the right arguments, and that the return value reflects the charge outcome. A unit test should not talk to a real payment provider, so the PaymentClient is mocked.

Three Mockito ideas appear here. mock(PaymentClient.class) creates a fake implementation of the interface that returns default values (false, 0, null) until programmed otherwise. when(...).thenReturn(...) programs the mock to return a specific value when a method is called with the given arguments. verify(...) checks that a method was called the expected way. ArgumentCaptor is for inspecting an argument that the test would otherwise just match with a wildcard.

The matchers (anyString, anyDouble, and so on) mean "this argument's exact value doesn't matter." Mixing matchers with literal values in the same call requires every argument to be a matcher, producing code like verify(paymentClient).charge(eq("cust-1"), anyDouble()) where eq wraps the literal.

Mockito creates the mock class at runtime using bytecode generation. The first mock in a test pays a small startup cost. For a single test class this is invisible; for a large suite, the overhead adds up, which is why Mockito 5 switched to a faster engine by default.

The combination of JUnit and Mockito covers the bulk of day-to-day testing. A test class that wires up a mock, drives the class under test, asserts on the result, and verifies the interactions is a common shape of unit test.

Running Tests

From the command line, the build tool runs every test in the project.

Both commands compile the source and test trees, launch the JUnit Platform, run every test, and exit with a non-zero status if any test fails. To run a single test class, both tools have a flag.

To run one method:

In an IDE, JUnit-aware editors (IntelliJ IDEA, Eclipse, VS Code with the Java extension) show a green run icon next to test classes and methods. Click it to run that scope; click the line number in the gutter to run a single test. The IDE shows pass/fail in a tree view that mirrors the package structure, with each @Nested group as a sub-tree.

Test Reports

Surefire and Gradle both write XML and HTML reports after each test run. Maven puts them in target/surefire-reports/, Gradle in build/reports/tests/test/index.html. The XML files follow the JUnit XML format, which every CI system (Jenkins, GitHub Actions, CircleCI, GitLab) knows how to parse.

A passing run with two tests produces something like this:

The XML file has one <testcase> element per method, with <failure> or <error> elements for failed tests. CI systems read this and surface the results in the pipeline UI. The text file is the human-readable summary, useful for quick inspection without parsing XML.

When a test fails, the report includes the assertion message, the stack trace, and the values that compared unequal. For assertEquals(3, cart.size()) against a cart with two items, the message reads expected: <3> but was: <2>, which is enough to start investigating without rerunning anything.

AssertJ: An Alternative Assertion Library

Jupiter's built-in assertions are fine for most cases, but the fluent style of AssertJ reads better for complex objects and collections. A typical AssertJ assertion chains expectations off a single call to assertThat, which gives more specific failure messages and IDE autocompletion that points toward the right check. Adding org.assertj:assertj-core as a test dependency enables assertThat(cart.items()).containsExactly("Wireless Mouse", "USB Cable") instead of assertIterableEquals(List.of("Wireless Mouse", "USB Cable"), cart.items()). Many teams adopt AssertJ alongside JUnit for the collections and string assertions, while keeping the JUnit assertions for the simple equality checks. Both can coexist in the same test class without conflict.

Quiz

JUnit Testing Quiz

9 quizzes