All topics
Testing · Learning hub

JUnit notes for developers

Master JUnit with a curated set of 1 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallTest yourself — JUnit quizMore Testing notes
JUnit

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/@AfterClass methods must be static — they run once for the whole class, before any instance of the test class exists.

  • @Before/@After run around every single @Test method, 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 — prefer assertThrows (available since JUnit 4.13) when you need to.

  • @Ignore skips 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 @ExtendWith on one class) exists.

  • Mockito's verify() checks that a mock was called; combine with InOrder when 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 @Before resetting 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 to assertThrows so you can inspect the exception object.

  • Forgetting that @BeforeClass/@AfterClass methods must be static — 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.

Keep your JUnit knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever