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
@Testare discovered and run by the platform, no inheritance from a baseTestCaseclass required. - Lifecycle annotations control setup and teardown around each test:
@BeforeAll/@AfterAllrun once per class (must be static by default),@BeforeEach/@AfterEachrun around every single test method. - Assertions (
assertEquals,assertTrue,assertThrows) come fromorg.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@Rulesystem, letting you plug in dependency injection, mocking frameworks like Mockito, or custom lifecycle callbacks. @ParameterizedTestwith 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
testtask 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
@BeforeEachresets 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 ofassertEquals(a, b). The latter produces a readable “expected X but was Y” failure message; the former just saysfalse. - Making
@BeforeAll/@AfterAllnon-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 ofassertThrows, which is more verbose and easy to get wrong. - Over-parameterizing a single test with unrelated cases, so one failure in a 40-case
@CsvSourceis hard to isolate from the report alone.
Comparison
| JUnit 5 | TestNG | PyTest | Jest | |
|---|---|---|---|---|
| Ecosystem | Java/JVM | Java/JVM | Python | JavaScript/TypeScript |
| Setup/teardown | @BeforeEach/@AfterEach | @BeforeMethod/@AfterMethod | Fixtures via dependency injection | beforeEach/afterEach |
| Parameterization | @ParameterizedTest + source annotations | @DataProvider | @pytest.mark.parametrize | test.each |
| Parallel execution | Supported via config, opt-in | Built-in, mature | Via pytest-xdist plugin | Built-in, worker processes |
| Build tool integration | Maven/Gradle native | Maven/Gradle native | N/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.
Related Terms
- PyTest — Python’s equivalent, with fixtures instead of annotation-based lifecycle hooks
- Jest — JavaScript’s closest analog, sharing the same
describe/beforeEachmental model - Test Pyramid and TDD — the strategy layer JUnit’s unit-test annotations implement in practice
- CI-CD — where
mvn test/gradle testtypically runs on every push - SOLID Principles — dependency injection via
@ExtendWithleans on the same principles as constructor injection in production code