QA How-To
JUnit 5 Tutorial for Beginners (2026)
A practical JUnit 5 tutorial for beginners covering setup, lifecycle, assertions, parameterized tests, tags, extensions, test design, and QA interviews.
24 min read | 2,765 words
TL;DR
JUnit 5 consists of the Platform, Jupiter programming model, and optional engines. Configure it through Maven or Gradle, write focused `@Test` methods with Jupiter assertions, use lifecycle and parameterization deliberately, and let the build tool discover and report tests.
Key Takeaways
- Use the JUnit Platform with the Jupiter API and engine through your build tool.
- Write tests around one behavior and use descriptive display names.
- Choose assertions that expose intent and group related checks carefully.
- Use parameterized tests for the same behavior across meaningful inputs.
- Keep lifecycle state isolated and understand per-method versus per-class instances.
- Extend JUnit through registered extensions instead of hidden global behavior.
A JUnit 5 tutorial for beginners should show how a test runner, annotations, assertions, lifecycle, and build tool fit together. JUnit 5 is not merely a newer @Test annotation. It is an architecture that separates test discovery and launching from the Jupiter programming model most Java teams use.
This guide uses modern Java examples without inventing dependency versions. Select a current JUnit BOM and build-plugin version approved by your project, then keep the suite consistent through dependency management.
TL;DR
| Need | JUnit Jupiter feature | Example |
|---|---|---|
| Ordinary test | @Test |
One behavior per method |
| Setup and cleanup | @BeforeEach, @AfterEach |
Fresh test subject |
| Multiple inputs | @ParameterizedTest |
Boundaries and equivalence classes |
| Group related checks | assertAll |
Validate one returned object |
| Conditional grouping | @Tag |
Smoke or integration selection |
| Reusable integration | Extension API | Timing, injection, lifecycle callbacks |
A minimal test imports org.junit.jupiter.api.Test and static methods from org.junit.jupiter.api.Assertions. Do not mix JUnit 4 annotations or assertions accidentally.
1. JUnit 5 Tutorial for Beginners: Understand the Architecture
JUnit 5 has three major parts. The JUnit Platform discovers and launches tests and provides integration points for build tools and IDEs. JUnit Jupiter supplies the modern annotations, assertions, and extension model. JUnit Vintage can run older JUnit 3 and 4 tests when included, mainly as a migration bridge. A new project normally needs Jupiter, not Vintage.
This distinction explains common setup failures. Compiling against the Jupiter API lets source code recognize @Test, but execution also needs a compatible engine and a build tool configured to use the Platform. Convenience dependency artifacts can bring the appropriate Jupiter pieces together. Dependency management through the JUnit BOM keeps module versions aligned.
JUnit is a unit-testing framework, but its Platform can also launch integration and component tests. The annotation does not determine test scope. Scope depends on which collaborators are real, what infrastructure starts, and how long the test takes. Separate fast isolated tests from slower integration suites through source sets, tags, or build tasks.
For the bigger picture, read the Java test automation roadmap. The goal is not maximum annotation usage. It is fast, deterministic evidence about behavior.
2. Configure JUnit Jupiter with Maven or Gradle
For Maven, import a current JUnit BOM and declare the Jupiter aggregate dependency in test scope. Replace the property value with the current release validated by your project rather than copying an unverified number.
<properties>
<maven.compiler.release>21</maven.compiler.release>
<junit.version>${junit.version.from.your.dependency-management}</junit.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Use a current Maven Surefire Plugin that supports the JUnit Platform. Many modern builds already manage it, but relying on an old transitive default can cause zero discovered tests. Run mvn test and inspect the report count.
For Gradle:
dependencies {
testImplementation platform("org.junit:junit-bom:${junitVersion}")
testImplementation "org.junit.jupiter:junit-jupiter"
}
test {
useJUnitPlatform()
}
Store junitVersion in the project's version catalog, properties, or approved dependency management. The important point is alignment and reproducibility, not a tutorial's fixed number.
3. Write Your First JUnit 5 Test
Create production code under src/main/java and tests under src/test/java using matching packages. A small example:
package com.qajobfit.money;
import java.math.BigDecimal;
public final class DiscountCalculator {
public BigDecimal apply(BigDecimal subtotal, BigDecimal rate) {
if (subtotal.signum() < 0) {
throw new IllegalArgumentException("subtotal must not be negative");
}
if (rate.signum() < 0 || rate.compareTo(BigDecimal.ONE) > 0) {
throw new IllegalArgumentException("rate must be between 0 and 1");
}
return subtotal.multiply(BigDecimal.ONE.subtract(rate));
}
}
package com.qajobfit.money;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
class DiscountCalculatorTest {
@Test
void appliesPercentageDiscount() {
var calculator = new DiscountCalculator();
var result = calculator.apply(new BigDecimal("100.00"), new BigDecimal("0.15"));
assertEquals(new BigDecimal("85.0000"), result);
}
}
Package-private test classes and methods are supported. Name the method for behavior and expected outcome, not test1. Arrange input, act once, and assert the observable result. Avoid testing private methods directly. Exercise them through public behavior, which allows implementation refactoring without rewriting every test.
4. Use JUnit 5 Annotations and Lifecycle Correctly
The main Jupiter lifecycle annotations are @BeforeAll, @BeforeEach, @AfterEach, and @AfterAll. By default, JUnit creates a new test class instance for each test method. That per-method lifecycle reduces accidental instance-field sharing. @BeforeAll and @AfterAll therefore need static methods unless the class uses @TestInstance(TestInstance.Lifecycle.PER_CLASS).
class ShoppingCartTest {
private ShoppingCart cart;
@BeforeEach
void createCart() {
cart = new ShoppingCart();
}
@Test
void startsEmpty() {
assertTrue(cart.items().isEmpty());
}
@AfterEach
void verifyInvariant() {
assertTrue(cart.total().signum() >= 0);
}
}
Use lifecycle methods for required setup or resource cleanup, not for hiding the test story. An assertion in cleanup can obscure an earlier failure, so invariant checks are best used selectively. Prefer try with resources for objects implementing AutoCloseable, because lexical resource ownership is explicit.
@DisplayName supplies readable report text. @Disabled temporarily skips a test and should include a reason, but a permanently disabled test is dead coverage. @Nested groups related scenarios and can give each context its own setup. @RepeatedTest repeats a test a fixed number of times, but repetition does not repair nondeterminism.
5. Choose Assertions That Explain the Contract
Jupiter's Assertions class includes equality, boolean, null, identity, iterable, array, exception, timeout, and grouped assertions. Put expected before actual for conventional failure output. Use domain-specific messages when values alone do not explain impact. Message suppliers defer construction until failure.
@Test
void rejectsNegativeSubtotal() {
var calculator = new DiscountCalculator();
var error = assertThrows(IllegalArgumentException.class, () ->
calculator.apply(new BigDecimal("-0.01"), BigDecimal.ZERO)
);
assertEquals("subtotal must not be negative", error.getMessage());
}
assertAll runs all grouped assertions and reports multiple failures together. Use it for several properties of one result, not to keep a long scenario running after a critical prerequisite failed.
assertAll("created user",
() -> assertEquals("Ada", user.firstName()),
() -> assertEquals("ACTIVE", user.status()),
() -> assertNotNull(user.id())
);
assertTimeout runs code in the calling thread and reports after completion if it exceeded the duration. assertTimeoutPreemptively executes separately and may interrupt, which can conflict with thread-local state and transaction management. For performance requirements, stable benchmarks and service-level tests are usually more appropriate than tight wall-clock unit assertions.
6. Write JUnit 5 Parameterized Tests
Parameterized tests execute the same test method with multiple arguments. Add the junit-jupiter-params module if your chosen aggregate dependency does not already provide it. Annotate with @ParameterizedTest and add at least one argument source.
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
class DiscountRateTest {
@ParameterizedTest(name = "rate {0} is valid")
@CsvSource({"0", "0.25", "0.5", "1.0"})
void acceptsRatesWithinInclusiveRange(String rateText) {
var rate = new BigDecimal(rateText);
assertTrue(rate.signum() >= 0 && rate.compareTo(BigDecimal.ONE) <= 0);
}
}
Common sources include @ValueSource, @NullSource, @EmptySource, @EnumSource, @CsvSource, @CsvFileSource, @MethodSource, and @ArgumentsSource. @MethodSource is appropriate for domain objects and complex cases:
static Stream<Arguments> invalidRates() {
return Stream.of(
Arguments.of("-0.01", "below minimum"),
Arguments.of("1.01", "above maximum")
);
}
Choose cases from boundaries, equivalence classes, and business rules rather than dumping a large spreadsheet into one method. Give each invocation a readable name. If cases require different setup, behavior, or expected reasoning, separate tests may communicate better than parameterization.
See boundary value analysis examples for systematic input selection.
7. Organize Tests with Nested Classes, Tags, and Assumptions
@Nested classes structure related behavior:
@Nested
@DisplayName("when the cart is empty")
class EmptyCart {
@Test
void totalIsZero() {
assertEquals(BigDecimal.ZERO, cart.total());
}
}
Non-static nested test classes can share outer setup while adding context-specific @BeforeEach methods. Keep nesting shallow enough that a reader can understand the complete setup. Deeply nested contexts can hide state and create cumbersome reports.
Tags classify tests independently of class names. Apply @Tag("smoke") or a domain tag and configure the build tool to include or exclude it. Tag syntax has restrictions, and team conventions should prevent aliases such as integration, int, and integration-test from fragmenting selection. Tags are metadata, not a substitute for sound suite architecture.
Assumptions such as assumeTrue abort a test when a prerequisite is not met. They can be useful for an environment-specific capability, but overuse produces green builds with little executed coverage. Prefer explicit build selection when the condition is known before test execution. Report aborted and skipped counts so missing evidence is visible.
A test suite should make its execution contract obvious: required services, tags, parallel-safety, and expected duration. The test pyramid for automation engineers helps decide which behaviors belong in isolated JUnit tests and which need broader integration coverage.
8. Test Exceptions, Collections, and Asynchronous Outcomes
Use assertThrows when an exception is expected and inspect its relevant properties. Use assertDoesNotThrow only when the absence of an exception is the meaningful contract. A test that calls a method without an assertion already fails on an unexpected exception, so wrapping every happy path adds noise.
For lists, assertIterableEquals compares elements in iteration order. Use assertArrayEquals for arrays. If order is not part of the requirement, normalize results or use a well-supported assertion library that expresses order-independent comparison. Do not sort the production output merely to make the test pass if ordering is a product contract.
Asynchronous code should expose a completion primitive such as CompletableFuture. Await it with a reasonable timeout and assert the value. Avoid arbitrary sleeps. A unit test can inject a controllable executor or clock to make scheduling deterministic. Integration tests may use a condition-waiting library already approved by the project, but every wait should target a specific observable condition.
@Test
void returnsComputedValue() throws Exception {
CompletableFuture<String> result = service.fetchLabel();
assertEquals("ready", result.get(2, TimeUnit.SECONDS));
}
The timeout protects the test process; it does not prove a production latency objective. Keep test timeout values generous enough for ordinary CI variation while still catching hangs, and diagnose repeated near-timeout behavior.
9. Use Mocks and Test Doubles Without Testing the Mock Setup
JUnit does not include a mocking framework. Mockito or another library can integrate through a Jupiter extension, but dependency versions and APIs should come from the selected framework's current documentation. JUnit remains responsible for test lifecycle and execution.
A test double is useful when a collaborator is slow, nondeterministic, unavailable, or difficult to force into a required state. Prefer fakes or small stubs when they express behavior simply. Mock interactions only when the interaction itself is the contract, such as publishing exactly one event after a successful transaction. Over-specified call order and internal method verification tie tests to implementation.
Structure the subject so dependencies are passed through constructors. This makes collaborators explicit and avoids reflection-based mutation of private fields. Assert the result or state first, then verify a critical interaction if needed. Do not mock value objects, collections, or the class under test.
A unit test with ten mocks may indicate that the production class has too many responsibilities. Refactoring the design often improves both testability and maintainability. Conversely, replacing every collaborator with a fake can miss integration contracts. Use component and integration tests to verify serialization, database queries, HTTP behavior, and framework configuration.
10. Extend JUnit Jupiter Safely
Jupiter's extension model replaces and unifies many older runners and rules. Extensions can implement callbacks, parameter resolution, exception handling, conditional execution, invocation interception, and other supported interfaces. Register one declaratively with @ExtendWith, programmatically with @RegisterExtension, or through supported automatic registration when the project intentionally enables it.
A small timing extension can implement BeforeTestExecutionCallback and AfterTestExecutionCallback, storing state in the ExtensionContext.Store. Namespaces prevent collisions. Extensions may be reused across suites, so avoid mutable global state and consider parallel execution.
Use extensions for cross-cutting infrastructure with a clear lifecycle: database cleanup, controlled clock injection, test resource management, or diagnostic attachments. Do not use one to hide arbitrary business setup. A test should still reveal the scenario and important inputs.
Extension order can matter when multiple extensions intercept the same lifecycle. Document dependencies and test the extension itself. Prefer explicit registration near the tests that need it over surprising classpath-wide behavior. If an extension starts external infrastructure, handle partial startup, cleanup failure, and concurrent runs.
JUnit's extension API is powerful, but ordinary helpers and fixtures are often enough. Choose the least magical solution that keeps the suite readable.
11. Control Test Execution, Reporting, and Parallelism
Build tools and IDEs discover tests through the Platform. Keep naming patterns compatible with the build plugin, and verify CI test counts. A build that reports zero tests may still exit successfully under some configurations, so enforce expected discovery where practical. Publish XML reports and logs as CI artifacts.
Parallel execution is opt-in and configurable through JUnit Platform settings. Before enabling it, remove shared mutable state, unique-port conflicts, fixed filenames, common database records, and order dependence. @Execution and resource-lock features can constrain selected tests, but widespread locks may erase the speed benefit and conceal poor isolation.
Tests must not rely on execution order. Method orderers exist for deliberate cases, but ordinary tests should construct their own state. An ordered suite that creates, updates, then deletes one record has cascading failures and cannot shard safely. Use fixtures or API setup to make each test independent.
Randomized test order can expose hidden coupling, but capture the seed and make failures reproducible. Track the slowest tests and quarantine policy transparently. A flaky test is a defect in the test, product, or environment, not harmless background noise. The flaky test debugging guide provides a root-cause workflow.
12. JUnit 5 Tutorial for Beginners: Build a Maintainable Suite
Use package structure that mirrors production domains. Name test classes after the subject and methods after behavior. Keep Arrange, Act, and Assert visible without turning them into mandatory comments. Build test-data builders for complex objects, but let each test override relevant values explicitly.
A good unit test is fast, isolated, deterministic, readable, and sensitive to the defect it claims to catch. It should fail for one understandable reason. Avoid broad exception catches, console-only validation, environmental time, random values without seeds, and network access in unit tests. Inject clocks, ID generators, and gateways.
Review tests during production-code review. Mutation testing can reveal assertions that do not detect behavioral changes, while coverage can reveal unexecuted code. Neither metric proves test quality. Use them as questions, not targets. A high line-coverage suite may miss boundaries and outcomes, while a focused set can protect the most important rules.
Refactor tests when duplication obscures intent, but accept small repetition when abstraction would hide meaning. Test code is production engineering infrastructure. Clear failure messages and stable design directly influence delivery speed.
13. JUnit 5 Tutorial for Beginners Interview Preparation
Be ready to distinguish Platform, Jupiter, and Vintage. Explain the default test-instance lifecycle, when static lifecycle methods are required, and how parameterized tests differ from repeated tests. Discuss assertions, extensions, tags, and parallel-safety with examples.
Interview answers should connect syntax to design. Saying @BeforeEach runs before each test is correct but shallow. Add that it supports fresh state under the default per-method lifecycle and that excessive setup can hide scenario details. When asked about mocks, explain contract boundaries and the risk of verifying implementation.
Prepare a debugging example: tests compile but zero execute because the engine or build integration is missing; tests pass alone but fail together because of shared static state; or a parameterized source selects poor cases. Explain how you would inspect build reports, isolate state, and improve data design.
Interview Questions and Answers
Q: What are the three main parts of JUnit 5?
The Platform launches and discovers tests, Jupiter provides the modern programming and extension model, and Vintage can run older JUnit tests when included.
Q: What is the default test instance lifecycle?
JUnit creates a new test class instance per test method. This reduces accidental instance-field sharing, while @BeforeAll remains static unless per-class lifecycle is selected.
Q: When should you use a parameterized test?
Use it when the same behavior and assertion apply to multiple meaningful inputs. Separate tests when setup or expected reasoning differs materially.
Q: What does assertAll do?
It executes grouped assertions and reports all failures together. It is useful for related properties of one result, not for continuing after a broken prerequisite.
Q: How do tags help?
Tags provide metadata that build tools can include or exclude, such as smoke or integration groups. Consistent naming and transparent skipped counts are essential.
Q: How does JUnit 5 support extensions?
Extensions implement supported callbacks or resolvers and can be registered declaratively or programmatically. They are suited to reusable lifecycle and infrastructure behavior.
Q: Why should tests not depend on order?
Order dependence creates cascading failures, prevents safe parallelism, and hides missing setup. Each test should arrange its own preconditions and clean up owned resources.
Common Mistakes
- Importing JUnit 4
org.junit.Testin a Jupiter test by accident. - Adding the API but missing the engine or Platform-aware build plugin.
- Sharing static mutable state between tests.
- Hiding important scenario setup in large lifecycle methods.
- Parameterizing cases that have different behaviors and deserve separate tests.
- Using sleeps for asynchronous outcomes instead of observable completion.
- Verifying every mock interaction and coupling tests to implementation.
- Enabling parallel execution before isolating files, ports, users, and databases.
- Treating coverage percentage as proof of assertion quality.
- Leaving disabled tests without ownership or a removal plan.
Conclusion
This JUnit 5 tutorial for beginners provides the foundation for a real Java suite: align Platform and Jupiter dependencies, write focused tests, manage lifecycle state, choose expressive assertions, parameterize meaningful inputs, and extend the framework deliberately.
As the suite grows, watch feedback time and failure quality. A test that takes seconds, needs a network, or fails for several unrelated reasons may belong in a different test layer. Keep unit tests independent of machine locale, current time, random order, and external availability. Publish test counts and failures in CI so missing discovery cannot look like success.
Review helpers and extensions like production APIs. Stable abstractions make intent clearer, while overly general fixtures hide the data and collaborators that matter. Refactor when duplication obstructs understanding, but prefer a little visible setup over a clever framework that only one maintainer can debug.
Start with one production rule and test its happy path, boundaries, and invalid inputs. Run it through the same build command CI will use, inspect the generated report, then grow the suite around behavior and risk rather than annotation count.
Interview Questions and Answers
Explain JUnit Platform, Jupiter, and Vintage.
The Platform discovers and launches tests and integrates with tools. Jupiter supplies JUnit 5 annotations, assertions, and extensions. Vintage is an optional engine for running older JUnit 3 and 4 tests during migration.
What is the default lifecycle of a JUnit test class?
Jupiter creates a new instance for every test method by default. This provides instance-field isolation. Because there is no single instance for the class, `@BeforeAll` and `@AfterAll` methods are static unless per-class lifecycle is enabled.
When would you use assertAll?
I use `assertAll` for multiple related properties of one result so one run reports all mismatches. I do not group dependent steps where a failed prerequisite makes later assertions meaningless. Focused failures remain the goal.
How are parameterized and repeated tests different?
A parameterized test receives defined input arguments and validates the same behavior across cases. A repeated test reruns the same method a configured number of times and receives repetition context if requested. Repetition alone is not a strategy for fixing flaky tests.
How do JUnit 5 extensions work?
An extension implements supported callback, resolver, condition, or interception interfaces. It is registered with `@ExtendWith`, `@RegisterExtension`, or an intentionally enabled automatic mechanism. I use extensions for clear cross-cutting lifecycle behavior, not hidden business setup.
Why might JUnit tests pass individually but fail as a suite?
Likely causes include shared static state, reused database records, fixed files or ports, time dependence, and missing cleanup. I randomize or vary order to expose coupling, then make each test own its setup and resources. Increasing retries would hide the defect.
How do you choose unit test cases?
I derive them from behavior, boundaries, equivalence classes, error contracts, and important state transitions. Coverage reports may reveal gaps, but I judge a test by whether its assertions detect meaningful faults. I keep cases deterministic and readable.
Frequently Asked Questions
What is the difference between JUnit 5 and JUnit Jupiter?
JUnit 5 is the overall generation and architecture. Jupiter is its modern programming and extension model, while the Platform handles discovery and launching and Vintage optionally supports older tests.
Do JUnit 5 test methods need to be public?
No. Jupiter supports package-private test classes and methods. Avoid making them private, and follow any additional constraints imposed by extensions or build tooling.
Why are my JUnit 5 tests not running?
Check that the Jupiter engine is on the test runtime path, the build plugin supports the JUnit Platform, test names match discovery rules, and imports use Jupiter annotations. Inspect the build's discovered test count.
When should I use BeforeEach instead of BeforeAll?
Use `@BeforeEach` for fresh state needed by every test. Use `@BeforeAll` for expensive class-level resources that can be shared safely and cleaned up afterward.
What is a parameterized test in JUnit 5?
It is one test method invoked with arguments from one or more sources. It is ideal when the same behavior applies to boundaries or equivalence-class inputs.
Can JUnit 5 run tests in parallel?
Yes, through JUnit Platform configuration. First remove shared mutable state, fixed ports, common records, and ordering assumptions, then introduce parallelism gradually.
Should I use assertTimeoutPreemptively?
Use it cautiously because it executes code in a different thread and may break thread-local or transaction behavior. Ordinary `assertTimeout` or a purpose-built integration timeout is often safer.