Skip to main content

JUnit โ€” The Complete Guide

A single-read, end-to-end reference for JUnit: enough to write idiomatic JUnit 5 tests, build a custom extension, or walk into an SDET interview. Organized as a lookup you can also read top-to-bottom.

๐Ÿ“‹ Quick reference: JUnit โ†’ Beginner

Prerequisites: Java

Learn this in 10 minutes10 min
  1. Learn the core annotations: @Test, @BeforeEach, @AfterEach
  2. Write assertions and one @ParameterizedTest
  3. Use @Tag to filter which tests run
  4. Skim JUnit 5 vs TestNG to know which fits your project
After this guide you can
  • Write JUnit 5 tests with the core annotations
  • Use parameterized and nested tests
  • Filter runs with tags
  • Extend behaviour with @ExtendWith

๐Ÿงญ Mental modelJUnit 5's three-module architectureThe JUnit Platform is the foundation that launches test engines; it hosts the JUnit Jupiter engine, which runs new JUnit 5 tests, and the JUnit Vintage engine, which runs legacy JUnit 3 and 4 tests side by side.@Test classesuses Jupiter APILegacy testsJUnit 3 / 4 styleJUnit Jupiternew programming modelJUnit Vintageruns JUnit 3/4 unchangedJUnit Platformlaunches test engines

JUnit 5 splits into three modules stacked on one foundation: the Platform launches whichever TestEngine is registered, the Jupiter engine runs tests written against the new JUnit 5 programming model, and the Vintage engine runs old JUnit 3/4 tests unchanged โ€” so a codebase can migrate gradually with both generations of tests executing side by side in the same run.

1. What JUnit Is, in Practical Termsโ€‹

JUnit is the de facto standard testing framework for the JVM. JUnit 5 (a.k.a. "Jupiter") is the current generation, a ground-up rewrite from JUnit 4 that splits the framework into three distinct components โ€” this architecture is the single most-asked JUnit interview topic.

<!-- Maven โ€” JUnit 5 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>

The three-module architectureโ€‹

ModuleRole
JUnit PlatformThe foundation โ€” launches testing frameworks on the JVM, defines the TestEngine API. Runs Jupiter, Vintage (JUnit 3/4), and third-party engines side by side.
JUnit JupiterThe new programming model and extension API โ€” @Test, @ExtendWith, assertions. What you write tests against in JUnit 5.
JUnit VintageA TestEngine that runs JUnit 3/4 tests on the Platform โ€” lets legacy tests coexist with new Jupiter tests during migration.
JUnit Platform (launcher, TestEngine API)
โ”‚
โ”Œโ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
JUnit Jupiter JUnit Vintage (3rd-party engines: Spock, Cucumber, ...)
(JUnit 5 API) (JUnit 3/4 tests)

This separation is why JUnit 5 can run old JUnit 4 tests unmodified (Vintage) while you migrate incrementally to Jupiter's API โ€” and why build tools (Maven Surefire, Gradle) only need to talk to one Platform launcher regardless of which engine(s) are in play.


2. Core Annotationsโ€‹

import org.junit.jupiter.api.*;

class OrderServiceTest {

@BeforeAll
static void beforeAll() { /* once, before all tests โ€” MUST be static */ }

@BeforeEach
void beforeEach() { /* before every @Test method */ }

@Test
void orderTotalsAreCorrect() {
// the actual test
}

@AfterEach
void afterEach() { /* after every @Test method */ }

@AfterAll
static void afterAll() { /* once, after all tests โ€” MUST be static */ }
}
  • @BeforeAll/@AfterAll must be static by default because a new test instance is created per test method (JUnit 5's default lifecycle) โ€” no instance exists yet when suite-level setup runs. This trips up everyone coming from TestNG, where suite-level hooks are plain instance methods.
  • Override with @TestInstance(Lifecycle.PER_CLASS) on the class to allow non-static @BeforeAll/@AfterAll and share instance state across test methods in that class (useful for expensive shared fixtures, at the cost of test isolation).
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class ExpensiveSetupTest {
private Connection sharedConnection;

@BeforeAll
void beforeAll() { // no longer needs to be static
sharedConnection = createConnection();
}
}

Other core annotationsโ€‹

AnnotationPurpose
@DisplayName("...")Human-readable test name in reports/IDE
@Disabled("reason")Skip a test, with a mandatory-in-spirit reason
@Timeout(5)Fail if the test exceeds 5 seconds
@RepeatedTest(5)Run the same test 5 times (flakiness checks, RepetitionInfo injectable)
@Tag("smoke")Categorize tests for selective execution (see ยง6)
@Test
@DisplayName("Rejects orders with negative quantity")
@Timeout(3)
void rejectsNegativeQuantity() {
assertThrows(IllegalArgumentException.class, () -> orderService.createOrder(-1));
}

3. Assertions & Assumptionsโ€‹

Assertions (org.junit.jupiter.api.Assertions)โ€‹

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

@Test
void basicAssertions() {
assertEquals(99.99, cart.getTotal(), 0.01); // delta for double comparison
assertTrue(user.isActive());
assertNotNull(response.getBody());
assertThrows(NullPointerException.class, () -> service.process(null));

assertAll("order validation", // grouped โ€” reports ALL failures, not just the first
() -> assertEquals("CONFIRMED", order.getStatus()),
() -> assertEquals(3, order.getItemCount()),
() -> assertTrue(order.getTotal() > 0)
);
}

assertAll() is JUnit 5's built-in answer to TestNG's SoftAssert โ€” every lambda executes and every failure is collected into one combined MultipleFailuresError, instead of stopping at the first failed assertion. Unlike TestNG, there's no separate object to instantiate and no assertAll call to forget โ€” it's just how you group assertions from the start.

Assumptions โ€” conditionally skip, don't failโ€‹

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

@Test
void onlyRunsOnStaging() {
assumeTrue("staging".equals(System.getenv("ENV")));
// rest of the test โ€” skipped (not failed) if the assumption is false
}

assumeTrue/assumeFalse/assumingThat mark a test aborted (a distinct outcome from pass/fail) when a precondition isn't met โ€” useful for environment-dependent tests that shouldn't count as failures in CI when run in the wrong environment.

Third-party assertionsโ€‹

Most production JUnit 5 suites pair it with AssertJ (assertThat(order.getTotal()).isGreaterThan(0)) for fluent, more readable chained assertions and better failure messages than the built-in Assertions class provides.


4. Parameterized Testsโ€‹

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

@ParameterizedTest
@ValueSource(strings = {"", " ", "\t"})
void blankUsernamesAreRejected(String username) {
assertFalse(validator.isValid(username));
}

@ParameterizedTest
@CsvSource({
"validUser, correctPass, true",
"validUser, wrongPass, false",
"'', anyPass, false"
})
void testLogin(String username, String password, boolean expected) {
assertEquals(expected, loginService.login(username, password));
}

@ParameterizedTest
@MethodSource("loginProvider")
void testLoginFromMethod(String username, String password, boolean expected) {
assertEquals(expected, loginService.login(username, password));
}

static Stream<Arguments> loginProvider() {
return Stream.of(
Arguments.of("validUser", "correctPass", true),
Arguments.of("validUser", "wrongPass", false)
);
}

@ParameterizedTest
@CsvFileSource(resources = "/testdata/users.csv", numLinesToSkip = 1)
void testLoginFromCsvFile(String username, String password, boolean expected) { ... }
SourceUse for
@ValueSourceA single simple literal per run (String/int/etc.)
@CsvSourceSmall inline multi-arg data sets
@CsvFileSourceLarger data sets kept in an external .csv file
@MethodSourceProgrammatically generated or complex objects
@EnumSourceIterate over an enum's values
@ArgumentsSourceCustom ArgumentsProvider for reusable, complex data logic

This is the direct equivalent of TestNG's @DataProvider, just declared inline via annotations rather than a separate provider method referenced by string name (though @MethodSource is functionally very close to it).


5. @Nested Testsโ€‹

class OrderServiceTest {

@Nested
@DisplayName("when the cart is empty")
class EmptyCart {
@Test
void checkoutIsDisabled() { ... }
}

@Nested
@DisplayName("when the cart has items")
class NonEmptyCart {
@BeforeEach
void addItems() { cart.add(sampleItem); }

@Test
void checkoutCalculatesTotal() { ... }

@Nested
@DisplayName("and a coupon is applied")
class WithCoupon {
@Test
void totalReflectsDiscount() { ... }
}
}
}

@Nested (non-static inner classes) groups related tests under a shared context/state, producing readable, hierarchical output in IDE/CI reports that mirrors BDD-style "when X, then Y" structure โ€” each nested class gets its own @BeforeEach chain from outer to inner.


6. Tagging & Filteringโ€‹

@Tag("smoke")
@Test
void homePageLoads() { ... }

@Tag("regression")
@Tag("checkout")
@Test
void fullCheckoutFlow() { ... }
<!-- Maven Surefire: run only "smoke", exclude "flaky" -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<groups>smoke</groups>
<excludedGroups>flaky</excludedGroups>
</configuration>
</plugin>
mvn test -Dgroups=smoke
// Gradle
test {
useJUnitPlatform {
includeTags 'smoke'
excludeTags 'flaky'
}
}

Tags are JUnit 5's direct equivalent of TestNG's groups โ€” the mechanism for slicing one test codebase into CI-stage-appropriate subsets (fast smoke suite on every commit, full regression nightly).


7. The Extension Model (@ExtendWith)โ€‹

JUnit 4 had a single, limited @RunWith extension point. JUnit 5 replaced it with a composable Extension API โ€” you can stack multiple extensions on one test, unlike JUnit 4's one-runner-per-class limit.

public class ScreenshotOnFailureExtension implements TestWatcher {
@Override
public void testFailed(ExtensionContext context, Throwable cause) {
byte[] screenshot = DriverFactory.getDriver()
.getScreenshotAs(OutputType.BYTES);
ExtentReportManager.attach(context.getDisplayName(), screenshot);
}
}

@ExtendWith(ScreenshotOnFailureExtension.class)
class CheckoutTest {
@Test
void completesCheckout() { ... }
}

Common extension interfacesโ€‹

InterfaceHooks into
BeforeEachCallback / AfterEachCallbackAround every test method โ€” like AOP setup/teardown
BeforeAllCallback / AfterAllCallbackAround the whole class
TestWatcherObserve outcomes (testSuccessful, testFailed, testAborted, testDisabled) without altering execution
ParameterResolverInject custom objects as test method parameters (e.g., a WebDriver instance)
ExecutionConditionProgrammatically enable/disable tests (custom @Disabled-like logic)
// A WebDriver injected via ParameterResolver โ€” no field, no static state
class DriverExtension implements ParameterResolver, AfterEachCallback {
@Override
public boolean supportsParameter(ParameterContext pc, ExtensionContext ec) {
return pc.getParameter().getType() == WebDriver.class;
}
@Override
public Object resolveParameter(ParameterContext pc, ExtensionContext ec) {
return new ChromeDriver();
}
@Override
public void afterEach(ExtensionContext ec) {
// quit the driver stored in the ExtensionContext.Store
}
}

@ExtendWith(DriverExtension.class)
class LoginTest {
@Test
void login(WebDriver driver) { // injected automatically
driver.get("https://example.com");
}
}

Spring Boot's @SpringBootTest, Mockito's @ExtendWith(MockitoExtension.class), and Testcontainers' JUnit 5 integration are all built on this same extension API โ€” understanding it is what lets you read (and eventually write) any of those integrations.


8. Parallel Executionโ€‹

Unlike TestNG (parallel by default via testng.xml), JUnit 5 parallel execution is opt-in via a properties file:

# src/test/resources/junit-platform.properties
junit.jupiter.execution.parallel.enabled = true
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.config.strategy = fixed
junit.jupiter.execution.parallel.config.fixed.parallelism = 4
@Execution(ExecutionMode.CONCURRENT)
class OrderServiceTest { ... }

@Execution(ExecutionMode.SAME_THREAD) // opt a specific class OUT of parallel runs
class LegacySequentialTest { ... }

Same ThreadLocal caveat as TestNG applies to shared resources like WebDriver โ€” parallel execution doesn't make non-thread-safe code thread-safe.


9. JUnit 5 vs. TestNG โ€” Quick Comparisonโ€‹

CapabilityJUnit 5TestNG
ParallelismOpt-in via properties file, class/method granularity via @ExecutionBuilt-in via testng.xml, more configuration knobs out of the box
Data-driven tests@ParameterizedTest + source annotations โ€” declarative@DataProvider โ€” a Java method, more programmatically flexible
Suite configurationCode-based (@Suite, tags) or build-tool configExternal testng.xml โ€” reorganize suites with zero code change
Grouped soft assertionsassertAll() built into core AssertionsRequires instantiating SoftAssert + calling assertAll()
Test dependenciesNot supported โ€” by design (tests should be independent)dependsOnMethods/dependsOnGroups native
ExtensibilityComposable @ExtendWith โ€” stack many extensionsListeners (ITestListener, etc.) โ€” also composable, older API shape
EcosystemDefault for Spring Boot, most general Java projectsDefault for many large-scale Selenium/API SDET suites

Rule of thumb: JUnit 5's opinionated independence (no test dependencies, opt-in parallelism) fits general application testing and CI pipelines that value strict isolation; TestNG's configurability (XML suites, native dependencies, default parallelism) fits large end-to-end automation suites that need fine control over execution order and grouping.


10. Interview-Ready Q&Aโ€‹

Q: What are the three modules of JUnit 5 and why does that architecture matter? A: JUnit Platform (the launcher and TestEngine API), JUnit Jupiter (the new programming model โ€” annotations, assertions), and JUnit Vintage (runs old JUnit 3/4 tests on the same Platform). It matters because it decouples "how tests are launched" from "what testing model they're written in" โ€” Jupiter and Vintage tests can run side by side in one build, which is what makes incremental JUnit 4 โ†’ 5 migration possible instead of a big-bang rewrite.

Q: Why do @BeforeAll/@AfterAll need to be static? A: By default JUnit 5 creates a new test instance for every test method (Lifecycle.PER_METHOD), so there's no single instance to attach class-level setup/teardown to โ€” it has to be static, tied to the class itself, not an instance. Annotating the class @TestInstance(PER_CLASS) switches to one shared instance per class, which allows non-static @BeforeAll/@AfterAll at the cost of state potentially leaking between test methods.

Q: How does assertAll() differ from just writing several assertEquals calls in a row? A: Plain sequential assertions stop at the first failure โ€” you only ever see one failure per run even if three things are wrong. assertAll() wraps several assertions (as lambdas) so every one of them executes regardless of earlier failures, then reports all failures together in one MultipleFailuresError. It's JUnit 5's built-in equivalent of TestNG's SoftAssert, without a separate object to instantiate.

Q: What's the difference between an assertion failing and an assumption failing? A: A failed assertion marks the test failed. A failed assumption (assumeTrue, etc.) marks the test aborted โ€” a distinct outcome that most CI reporting treats differently from a real failure. Assumptions exist for preconditions the test can't control, like "only run this against the staging environment" โ€” skipping cleanly there shouldn't count against your pass rate.

Q: How would you inject a WebDriver into test methods without a static field or a @BeforeEach boilerplate in every class? A: Write a custom ParameterResolver extension that recognizes the WebDriver parameter type and supplies an instance, then register it with @ExtendWith. Combined with AfterEachCallback to quit the driver, this keeps driver lifecycle logic in one reusable extension instead of copy-pasted setup/teardown in every test class.

Q: Why doesn't JUnit 5 support test dependencies the way TestNG does? A: It's a deliberate design choice โ€” JUnit's philosophy is that tests should be independent and order-agnostic so they can run in parallel, be safely reordered, and fail without cascading false "skipped" results across unrelated tests. If you need setup shared across tests, that belongs in @BeforeEach/@BeforeAll or a shared fixture, not a dependency chain between test methods.

Q: @ValueSource vs @MethodSource vs @CsvFileSource โ€” when would you use each? A: @ValueSource for a single simple literal per run (a list of strings or ints). @CsvFileSource for larger, tabular data sets better kept out of the code, in an actual .csv file. @MethodSource when the data needs to be generated programmatically or involves complex objects that don't fit into a CSV row โ€” it points at a static method returning a Stream<Arguments>.

Q: How do you run only a subset of tests โ€” say, just smoke tests โ€” in CI? A: Tag the relevant tests with @Tag("smoke"), then configure Surefire (<groups>smoke</groups>) or Gradle's useJUnitPlatform { includeTags 'smoke' } to filter by tag, or pass -Dgroups=smoke on the CLI. This lets one test codebase serve both a fast on-commit smoke suite and a full nightly regression run without maintaining separate test classes.


Practical exercises
  1. In a Maven project with org.junit.jupiter:junit-jupiter 5.10.x as a test dependency, add this method:

    public class Price {
    /** Returns the price in cents after taking percent off; the discount is rounded down. */
    public static int applyDiscount(int cents, int percent) {
    return cents - (cents * percent / 100);
    }
    }

    Write one @ParameterizedTest with @CsvSource and at least four rows: no discount, half off, full discount, and an odd amount where the discount does not divide evenly. Run mvn test, then change the method to subtract one extra cent and run again.

    Done when: all rows pass first, and after the change the report lists each failing row separately, by its position, instead of stopping at the first.

    Stretch: Group the tests into @Nested classes with @DisplayName and read the hierarchical report.

  2. Add one test tagged @Tag("smoke") and one tagged @Tag("flaky") next to your parameterized test. Then compare the two runs:

    mvn test
    mvn test -Dgroups=smoke -DexcludedGroups=flaky

    Done when: the first run executes every test, and the second reports Tests run: 1, and you can say why a pipeline would run smoke on every pull request and keep flaky out entirely.

The test that only makes sense on staging
Context

Illustrative scenario. A suite includes tests that need a staging environment. The nightly build runs on a runner that cannot reach it, and those tests fail every night.

What happened

The red builds were not real regressions, but they looked identical to real ones. The team began to skim past red results, and eventually a genuine failure sat unnoticed among the expected ones.

Lesson

Environment-dependent tests should declare their precondition with an assumption such as assumeTrue, so they end up aborted rather than failed when the environment is wrong. Combined with tags, that keeps a red build meaning something.

Use this with AI
  • Ask an assistant to generate @CsvSource rows for a method's edge cases (zero, boundaries, rounding), then run them and read every failure yourself, because a generated expected value can simply be wrong.
  • Have it merge a set of copy-pasted similar tests into one parameterized test, and confirm the report still identifies each case individually.
  • Ask it to suggest @Tag groupings such as smoke and regression, then count the results to check that the Surefire filters select exactly the tests you intend.

11. One-Line Summaryโ€‹

JUnit 5's Platform/Jupiter/Vintage split and composable extension model make it the flexible, opinionated-toward-independence default for JVM testing โ€” reach for @ParameterizedTest, assertAll(), and @ExtendWith before reaching for a third-party add-on, and pair it with TestNG only when a suite genuinely needs native test dependencies or XML-driven parallel suite composition.