java5 min read

JUnit Tutorial: Learn Unit Testing from Scratch (2026)

JUnit Tutorial: Learn Unit Testing from Scratch (2026)

Published:  |  Category: Java  |  Reading time: ~15 min
JUnit Tutorial: Learn Unit Testing from Scratch (2026)

JUnit is the foundation of Java testing culture. It provides the annotations, assertions, and test runners that make automated testing practical and reliable. After writing tens of thousands of tests across multiple projects, I have found that teams with strong testing discipline ship faster, regress less, and sleep better at night. JUnit 5 (Jupiter) is the current standard, offering a modern API built on Java 8+ features.

This tutorial covers test lifecycle, assertions, parameterized tests, extensions, mock integration, and AssertJ for fluent assertions. You will learn to write tests that are fast, isolated, and meaningful.

JUnit Jupiter Basics

JUnit 5 consists of three modules: Jupiter (the programming model), Vintage (backward compatibility with JUnit 4), and Platform (the test engine launcher). Test classes contain methods annotated with @Test. Assertions from org.junit.jupiter.api.Assertions use lambda-friendly messages that are lazily evaluated.

Test methods must be package-private or public, return void, and take no parameters (unless parameterized). Name tests descriptively — testShouldRefundExpiredOrder conveys the scenario far better than test1.

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

class OrderServiceTest {
    private final OrderService service = new OrderService();

    @Test
    void shouldCalculateTotalWithDiscount() {
        Order order = new Order(List.of(
            new Item("Laptop", 1, BigDecimal.valueOf(1000)),
            new Item("Mouse", 2, BigDecimal.valueOf(25))
        ));
        BigDecimal total = service.calculateTotal(order);
        assertEquals(BigDecimal.valueOf(1050), total);
        assertAll(
            () -> assertNotNull(order.getId()),
            () -> assertTrue(total.compareTo(BigDecimal.ZERO) > 0)
        );
    }
}

Test Lifecycle Annotations

@BeforeEach and @AfterEach run before and after each test method — use them for test setup and cleanup. @BeforeAll and @AfterAll run once per test class and must be static. The @DisplayName annotation provides readable test names in reports and IDE outputs.

@Nested inner classes create hierarchical test structures. This is particularly useful for testing a class with different states: a top-level class for general behavior, nested classes for authenticated vs. unauthenticated scenarios, and further nesting for edge cases.

class PaymentServiceTest {
    private PaymentService service;
    private PaymentGateway gatewayMock;

    @BeforeEach
    void setUp() {
        gatewayMock = mock(PaymentGateway.class);
        service = new PaymentService(gatewayMock);
    }

    @Nested
    @DisplayName("when payment is authorized")
    class Authorized {
        @Test
        @DisplayName("completes the transaction")
        void completesTransaction() { ... }
        
        @Test
        @DisplayName("sends confirmation email")
        void sendsConfirmation() { ... }
    }
}

Parameterized Tests

Parameterized tests run the same test logic with different inputs. @ValueSource provides primitive literals, @CsvSource supplies comma-separated pairs, @MethodSource references a factory method returning Stream, and @EnumSource iterates enum values. This eliminates repetitive test methods.

The @ParameterizedTest annotation replaces @Test. Each invocation reports independently, so you can see exactly which input failed. Use @NullSource and @EmptySource to test null/empty cases without extra methods.

@ParameterizedTest
@CsvSource({
    "123456789012, 1234567890123456, VALID",
    "123456789013, 1234567890123456, INVALID_DOC",
    "123456789012, 1234567890, INVALID_CARD"
})
void validatesPayment(String document, String cardNumber, PaymentStatus expected) {
    PaymentRequest request = new PaymentRequest(document, cardNumber);
    assertEquals(expected, validator.validate(request));
}

@ParameterizedTest
@MethodSource("provideOrderStatusTransitions")
void validatesStatusTransition(OrderStatus from, OrderStatus to, boolean allowed) {
    assertEquals(allowed, order.canTransition(from, to));
}

Mocking with Mockito

Unit tests isolate the class under test by replacing dependencies with mocks. Mockito is the dominant mocking framework in the Java ecosystem. Use @Mock for mock creation, @InjectMocks to inject mocks into the tested object, and @ExtendWith(MockitoExtension.class) to integrate with JUnit 5.

Mock behavior with when().thenReturn() for stubs and verify() for interaction testing. Use ArgumentCaptor to inspect arguments passed to mocks. Avoid mocking value objects or types you do not own — prefer real instances for simple types.

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
    @Mock
    private OrderRepository repository;
    @Mock
    private PaymentGateway paymentGateway;
    @InjectMocks
    private OrderService service;

    @Test
    void createsOrderAndChargesPayment() {
        CreateOrderRequest request = new CreateOrderRequest("CUST1", BigDecimal.TEN);
        when(repository.save(any())).thenAnswer(i -> i.getArgument(0));

        Order result = service.createOrder(request);

        verify(paymentGateway).charge(result.getId(), BigDecimal.TEN);
        assertThat(result.getStatus()).isEqualTo(OrderStatus.PENDING);
    }
}

Advanced Assertions with AssertJ

AssertJ provides a fluent assertion API with rich, readable assertions. Instead of assertEquals(expected, actual), write assertThat(actual).isEqualTo(expected). AssertJ offers soft assertions for collecting multiple failures, extracting fields from collections, and comparing objects by specific fields.

Use extracting() to map collection elements, filteredOn() to filter streams, and usingRecursiveComparison() to compare complex object graphs. AssertJ's error messages include both actual and expected values in a clear diff format.

import static org.assertj.core.api.Assertions.*;

@Test
void filtersAndSortsProducts() {
    List result = catalog.search("laptop", SortBy.PRICE_ASC);

    assertThat(result)
        .hasSizeGreaterThanOrEqualTo(1)
        .allMatch(p -> p.getName().toLowerCase().contains("laptop"))
        .extracting(Product::getPrice)
        .isSorted();
}

@Test
void comparesOrderFieldsRecursively() {
    Order actual = service.create(request);
    assertThat(actual)
        .usingRecursiveComparison()
        .ignoringFields("id", "createdAt")
        .isEqualTo(expected);
}

Test Extensions and Customization

JUnit 5 extensions replace JUnit 4's runners and rules. Implement the Extension interface or extend built-in extensions like ParameterResolver, TestExecutionExceptionHandler, or BeforeAllCallback. The @RegisterExtension annotation lets you configure extensions per-test-class without global registration.

Common custom extensions: database cleanup before tests, test timeout enforcement, temporary folder management, and conditional test execution based on environment variables or OS detection.

public class DatabaseCleanupExtension implements BeforeEachCallback {
    @Override
    public void beforeEach(ExtensionContext context) {
        DatabaseCleaner cleaner = new DatabaseCleaner(
            DriverManager.getConnection(
                System.getenv("TEST_DB_URL"),
                "sa", ""));
        cleaner.clean();
    }
}

@ExtendWith(DatabaseCleanupExtension.class)
@SpringBootTest
class OrderRepositoryTest {
    @Autowired
    private OrderRepository repository;

    @Test
    void findsOrdersByCustomer() {
        List orders = repository.findByCustomerId(42L);
        assertThat(orders).isEmpty(); // Clean DB guaranteed
    }
}

Frequently Asked Questions

What is the difference between JUnit 4 and JUnit 5?

JUnit 5 (Jupiter) has a different package (org.junit.jupiter), requires Java 8+, supports @Nested tests, parameterized tests natively, and has a powerful extension model replacing JUnit 4's runners and rules. JUnit 4 tests run via the Vintage engine.

How do I test code that throws exceptions?

Use assertThrows(ExpectedException.class, () -> codeThatThrows()). It returns the exception for further assertions. For testing no exception, use assertDoesNotThrow(() -> safeCode()). Avoid try-catch patterns in tests.

Should I test private methods?

No. Test through public API. If a private method is complex enough to need direct testing, extract it to a new class as a public method and test it there. Testing through public API ensures your tests validate behavior, not implementation details.

What is test coverage and what percentage should I aim for?

Coverage measures which lines/branches are executed by tests. Aim for 70-80% line coverage on business logic. Chasing 100% leads to brittle tests that test trivial getters/setters. Focus coverage on complex domain logic, not infrastructure or generated code.

Originally published on Ayodhyyya. Last updated June 1, 2026.