JUnit Essentials
JUnit Essentials What JUnit Is and Its Place in the Java Testing Model JUnit is the standard unit testing framework for Java. This page covers the classic JUnit…
JUnit Essentials
What JUnit Is and Its Place in the Java Testing Model
JUnit is the standard unit testing framework for Java. This page covers the classic JUnit 4 model — the org.junit package, @Before/@After lifecycle annotations, @RunWith test runners — which is still what you will find in a large share of existing Java codebases and is the foundation the newer JUnit 5 (Jupiter) architecture builds on and reworks. If a project already targets JUnit 5, see the separate JUnit 5 page for what specifically changed.
A JUnit test class is a plain Java class where individual test methods are marked with @Test. JUnit discovers these methods via reflection, instantiates a fresh instance of the test class for every single @Test method (not once per class), runs any @Before/@After hooks around it, and reports pass/fail based on whether an assertion failed or an unexpected exception was thrown.
Test Lifecycle Annotations
The lifecycle annotations control setup and teardown at two different scopes: per-test (@Before/@After) and per-class (@BeforeClass/@AfterClass). Getting the scope right matters for both correctness and speed — per-test hooks guarantee isolation between tests (each test starts from the same known state), while per-class hooks are for genuinely expensive, shareable setup like an in-memory database connection.
import org.junit.*;
import static org.junit.Assert.*;
public class ShoppingCartTest {
private ShoppingCart cart;
private static Database sharedDb;
// Runs once before any test method in this class — expensive, class-level setup
@BeforeClass
public static void setUpClass() {
sharedDb = Database.connectInMemory();
}
// Runs once after all test methods in this class have finished
@AfterClass
public static void tearDownClass() {
sharedDb.close();
}
// Runs before EVERY @Test method — gives each test a fresh, isolated instance
@Before
public void setUp() {
cart = new ShoppingCart(sharedDb);
cart.addItem("sku-1", 2);
}
// Runs after EVERY @Test method, even if the test failed — good for cleanup
@After
public void tearDown() {
cart.clear();
}
@Test
public void addsItemQuantityCorrectly() {
cart.addItem("sku-1", 3);
assertEquals(5, cart.getQuantity("sku-1"));
}
@Test
public void totalPriceReflectsAllItems() {
cart.addItem("sku-2", 1);
assertEquals(new BigDecimal("59.98"), cart.getTotalPrice());
}
@Test(expected = IllegalArgumentException.class)
public void rejectsNegativeQuantity() {
cart.addItem("sku-1", -1);
}
@Test(timeout = 200)
public void computesTotalsQuickly() {
cart.getTotalPrice();
}
@Ignore("Flaky until CART-482 is fixed")
@Test
public void appliesBulkDiscount() {
// ...
}
}@BeforeClass/@AfterClassmethods must bestatic— they run once for the whole class, before any instance of the test class exists.@Before/@Afterrun around every single@Testmethod, giving each test a clean, independent instance — this is what makes JUnit tests safe to run in any order.@Test(expected = ...)is the classic way to assert an exception type, but it cannot inspect the exception's message or fields — preferassertThrows(available since JUnit 4.13) when you need to.@Ignoreskips a test without deleting it; always pair it with a comment or ticket reference explaining why, so it doesn't quietly rot forever.
Assertions
All of JUnit 4's core assertions live as static methods on org.junit.Assert, conventionally imported with import static org.junit.Assert.*; so they read as bare assertEquals(...) calls. A commonly overlooked detail: the (deprecated but still everywhere) two-argument-first overloads put the failure message first, expected second, actual third — assertEquals("message", expected, actual) — getting expected/actual backwards doesn't break the test but does produce a confusing failure message.
import static org.junit.Assert.*;
@Test
public void demonstratesCoreAssertions() {
assertEquals("expected value matches actual", 42, calculateAnswer());
assertEquals("floating point needs a delta", 3.14, computePi(), 0.001);
assertTrue("condition must be true", user.isActive());
assertFalse("condition must be false", user.isBanned());
assertNull("reference must be null", cache.get("missing-key"));
assertNotNull("reference must not be null", repository.findById(1L));
assertSame("must be the exact same instance", singleton, ConfigService.getInstance());
assertArrayEquals("arrays must match element by element", new int[]{1, 2, 3}, sortResult);
// assertThrows (JUnit 4.13+) — the modern way to assert an exception, without
// relying on the coarser @Test(expected = ...) which can't inspect the exception
IllegalStateException ex = assertThrows(IllegalStateException.class, () -> {
orderService.ship(unpaidOrder);
});
assertEquals("Cannot ship an unpaid order", ex.getMessage());
}Test Runners and Mockito Integration
The @RunWith annotation replaces JUnit's default test runner with a custom one, which is how JUnit 4 plugs in third-party extensions. MockitoJUnitRunner is the most common example: it auto-initializes any @Mock-annotated fields and validates that mocks were actually used as stubbed, catching a whole class of "I set up a mock but the code path never called it" bugs that would otherwise pass silently.
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import static org.mockito.Mockito.*;
import static org.junit.Assert.*;
// @RunWith swaps out JUnit's default test runner. MockitoJUnitRunner wires up
// @Mock fields and validates mock usage automatically, without manual MockitoAnnotations.initMocks().
@RunWith(MockitoJUnitRunner.class)
public class OrderServiceTest {
@Mock
private PaymentGateway paymentGateway;
@Mock
private InventoryClient inventoryClient;
private OrderService orderService;
@org.junit.Before
public void setUp() {
orderService = new OrderService(paymentGateway, inventoryClient);
}
@Test
public void chargesCustomerBeforeReservingStock() {
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);
when(inventoryClient.reserve("sku-1", 2)).thenReturn(true);
orderService.placeOrder("cust-1", "sku-1", 2, new BigDecimal("19.99"));
// Verify call order and interaction counts — a common source of real regressions
InOrder inOrder = inOrder(paymentGateway, inventoryClient);
inOrder.verify(paymentGateway).charge("cust-1", new BigDecimal("19.99"));
inOrder.verify(inventoryClient).reserve("sku-1", 2);
verify(inventoryClient, times(1)).reserve(anyString(), anyInt());
}
@Test
public void doesNotReserveStockWhenPaymentFails() {
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);
assertFalse(orderService.placeOrder("cust-1", "sku-1", 1, BigDecimal.TEN));
verify(inventoryClient, never()).reserve(anyString(), anyInt());
}
}A class can only have one
@RunWith— this is one of JUnit 4's real architectural constraints, and a big part of why JUnit 5's extension model (multiple@ExtendWithon one class) exists.Mockito's
verify()checks that a mock was called; combine withInOrderwhen the sequence of calls matters, not just whether each one happened.when(...).thenReturn(...)stubs return values; a stub that is declared but never exercised by the test can indicate the test isn't actually covering the path it claims to.
Test Suites
A test suite groups several test classes so they can be run together as one unit — handy for a CI stage named "checkout tests" that should map onto a specific, curated set of classes rather than a fragile package-glob pattern.
import org.junit.runner.RunWith;
import org.junit.runners.Suite;
// A test suite groups multiple test classes to run together as one unit —
// useful for a CI stage that should run "all checkout tests" as a named group.
@RunWith(Suite.class)
@Suite.SuiteClasses({
ShoppingCartTest.class,
OrderServiceTest.class,
PricingCalculatorTest.class
})
public class CheckoutTestSuite {
// No body needed — this class is just a runner configuration holder
}
// Run with Maven: mvn test -Dtest=CheckoutTestSuite
// Or target a package's worth of tests directly:
// mvn test -Dtest=com.example.checkout.*Common Pitfalls
Sharing mutable state on instance fields across tests without
@Beforeresetting it — JUnit creates a new instance per test method, but state accidentally held in a static field silently leaks between tests.Relying on test execution order — JUnit does not guarantee method order by default; a test that only passes because an earlier test happened to run first is a latent bug.
Using
@Test(expected = ...)when you actually need to assert something about the thrown exception's message or fields — switch toassertThrowsso you can inspect the exception object.Forgetting that
@BeforeClass/@AfterClassmethods must bestatic— a common compile error for anyone new to the lifecycle annotations.Mixing real dependencies and mocks inconsistently across a suite, making some tests slow/flaky (real database, real HTTP calls) when a mock would isolate the unit under test.