JUnit

JUnit

Definition: The standard unit testing framework for Java, and the original inspiration for the “xUnit” style of testing framework adopted across many other languages.

How It Works

  • JUnit 5 (Jupiter, the current generation) splits into three parts: the JUnit Platform (launches tests and discovers engines), Jupiter (the modern annotation-based API), and Vintage (runs old JUnit 3/4 tests on the same platform).
  • Test classes are plain Java classes. Methods annotated @Test are discovered and run by the platform, no inheritance from a base TestCase class required.
  • Lifecycle annotations control setup and teardown around each test: @BeforeAll/@AfterAll run once per class (must be static by default), @BeforeEach/@AfterEach run around every single test method.
  • Assertions (assertEquals, assertTrue, assertThrows) come from org.junit.jupiter.api.Assertions, usually static-imported. assertAll() groups multiple assertions so a test reports every failure, not just the first.
  • The extension model (@ExtendWith) replaces JUnit 4’s rigid @Rule system, letting you plug in dependency injection, mocking frameworks like Mockito, or custom lifecycle callbacks.
  • @ParameterizedTest with a source annotation (@ValueSource, @CsvSource, @MethodSource) runs the same test body once per input, instead of copy-pasting near-identical test methods.
  • Build tools drive it directly: Maven’s Surefire plugin and Gradle’s test task both discover and run JUnit tests as part of the normal build lifecycle, failing the build on any test failure.

Under the Hood

Each @Test method gets a fresh instance of the test class by default (per-method lifecycle), so instance fields don’t leak state between tests unless you explicitly opt into @TestInstance(Lifecycle.PER_CLASS).

Given a class with a counter reset before every test Step:

class CounterTest {
    private Counter counter;

    @BeforeEach
    void setUp() {
        counter = new Counter();
    }

    @Test
    void incrementsFromZero() {
        counter.increment();
        assertEquals(1, counter.value());
    }

    @Test
    void decrementsBelowZero() {
        counter.decrement();
        assertEquals(-1, counter.value());
    }
}

Answer: Because JUnit creates a new CounterTest instance per test method, @BeforeEach gives each test a fresh Counter, so incrementsFromZero and decrementsBelowZero never see each other’s state, regardless of run order.

Given a method that should throw on invalid input Step: assertThrows(IllegalArgumentException.class, () -> new Account(-100)); Answer: JUnit runs the lambda, catches the exception, and passes the test only if an IllegalArgumentException (or subtype) was actually thrown; any other exception or a normal return fails the test with a clear message.

Why It Matters

  • Being wired directly into Maven/Gradle means a broken test fails the build automatically, no separate CI step to wire up.
  • The xUnit pattern JUnit popularized (setUp/tearDown/assert) is now the mental model developers carry into PyTest, Jest, and nearly every other testing framework.
  • The extension model makes it the integration point for the rest of the JVM ecosystem: Spring Boot’s @SpringBootTest, Mockito’s @ExtendWith(MockitoExtension.class), and Testcontainers all build on JUnit 5 extensions.
  • Decades of stability mean almost every JVM tool, IDE, and CI system has first-class JUnit support out of the box.

Common Pitfalls

  • Sharing mutable static state between test methods, assuming test order won’t matter, then getting order-dependent failures when the suite runs in a different sequence or in parallel.
  • Forgetting @BeforeEach resets fields but not external state like a database or a file on disk, letting tests silently pollute each other’s fixtures.
  • Using assertTrue(a.equals(b)) instead of assertEquals(a, b). The latter produces a readable “expected X but was Y” failure message; the former just says false.
  • Making @BeforeAll/@AfterAll non-static without switching to @TestInstance(Lifecycle.PER_CLASS), which fails at runtime with a confusing error.
  • Catching exceptions manually with try/catch and a fail() call instead of assertThrows, which is more verbose and easy to get wrong.
  • Over-parameterizing a single test with unrelated cases, so one failure in a 40-case @CsvSource is hard to isolate from the report alone.

Comparison

JUnit 5TestNGPyTestJest
EcosystemJava/JVMJava/JVMPythonJavaScript/TypeScript
Setup/teardown@BeforeEach/@AfterEach@BeforeMethod/@AfterMethodFixtures via dependency injectionbeforeEach/afterEach
Parameterization@ParameterizedTest + source annotations@DataProvider@pytest.mark.parametrizetest.each
Parallel executionSupported via config, opt-inBuilt-in, matureVia pytest-xdist pluginBuilt-in, worker processes
Build tool integrationMaven/Gradle nativeMaven/Gradle nativeN/A (Python has no single build tool)npm scripts / CI runners

Example

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import static org.junit.jupiter.api.Assertions.*;

class DiscountCalculatorTest {
    private DiscountCalculator calculator;

    @BeforeEach
    void setUp() {
        calculator = new DiscountCalculator();
    }

    @ParameterizedTest
    @CsvSource({
        "100.00, GOLD, 90.00",
        "100.00, SILVER, 95.00",
        "100.00, NONE, 100.00"
    })
    void appliesDiscountByTier(double price, String tier, double expected) {
        double result = calculator.applyDiscount(price, tier);
        assertEquals(expected, result, 0.01);
    }

    @Test
    void rejectsNegativePrice() {
        assertThrows(IllegalArgumentException.class,
            () -> calculator.applyDiscount(-10.0, "GOLD"));
    }
}

One parameterized test replaces three near-identical methods, and each row shows up as its own result in the test report if it fails.

  • PyTest — Python’s equivalent, with fixtures instead of annotation-based lifecycle hooks
  • Jest — JavaScript’s closest analog, sharing the same describe/beforeEach mental model
  • Test Pyramid and TDD — the strategy layer JUnit’s unit-test annotations implement in practice
  • CI-CD — where mvn test/gradle test typically runs on every push
  • SOLID Principles — dependency injection via @ExtendWith leans on the same principles as constructor injection in production code

Dig deeper