Unit Testing Fundamentals in Java

A unit test runs a small piece of code with known input and asserts the result, automatically and repeatedly.

What a unit test is

A unit test exercises one small piece of behaviour in isolation and checks the outcome automatically. It runs in milliseconds, needs no database or network, and gives the same answer every time.

LevelCoversSpeed
UnitOne class or methodMilliseconds
IntegrationSeveral components togetherSeconds
End to endThe whole systemMinutes

Most tests should be unit tests, with fewer integration tests and a small number of end to end tests. The reason is practical rather than dogmatic: a fast test that pinpoints one class is the one people actually run.

The code under test

public final class PriceCalculator {

    private static final double TAX_RATE = 0.18;

    public double payable(double amount, int discountPercent) {
        if (amount < 0) {
            throw new IllegalArgumentException("Amount must not be negative");
        }
        if (discountPercent < 0 || discountPercent > 100) {
            throw new IllegalArgumentException("Discount must be between 0 and 100");
        }
        double discounted = amount - (amount * discountPercent / 100);
        return discounted + (discounted * TAX_RATE);
    }
}

The tests

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

class PriceCalculatorTest {

    private final PriceCalculator calculator = new PriceCalculator();

    @Test
    void addsTaxWhenThereIsNoDiscount() {
        double result = calculator.payable(1000, 0);
        assertEquals(1180.0, result, 0.001);
    }

    @Test
    void appliesDiscountBeforeTax() {
        double result = calculator.payable(1000, 10);
        assertEquals(1062.0, result, 0.001);
    }

    @Test
    void rejectsNegativeAmount() {
        IllegalArgumentException error = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.payable(-1, 0));

        assertTrue(error.getMessage().contains("negative"));
    }
}
Note the test names. appliesDiscountBeforeTax tells you what broke from the failure report alone, which test2 never will. A test name should read as a sentence describing the behaviour.

Arrange, act, assert

@Test
void marksNoteAsPublished() {
    // Arrange
    Note note = new Note("Java testing", Status.DRAFT);

    // Act
    Note published = note.publish();

    // Assert
    assertEquals(Status.PUBLISHED, published.status());
    assertEquals(Status.DRAFT, note.status());     // the original is unchanged
}

Three phases, in order, with one behaviour per test. When a test needs two act steps, it is usually two tests.

Assertions

assertEquals(expected, actual);
assertEquals(expected, actual, 0.001);          // a delta for floating point
assertNotEquals(unwanted, actual);
assertTrue(condition, "message shown on failure");
assertFalse(condition);
assertNull(value);
assertNotNull(value);
assertSame(expected, actual);                    // identity, not equality
assertArrayEquals(expectedArray, actualArray);
assertIterableEquals(expectedList, actualList);

assertThrows(IllegalStateException.class, () -> service.publish(null));
assertDoesNotThrow(() -> service.publish(validNote));
assertTimeout(Duration.ofMillis(200), () -> service.search("java"));

assertAll("note",
        () -> assertEquals("Java testing", note.title()),
        () -> assertEquals(0, note.views()),
        () -> assertFalse(note.published()));

assertAll reports every failure rather than stopping at the first, which is useful when checking several properties of one result.

Lifecycle

class NoteServiceTest {

    private NoteService service;

    @BeforeAll
    static void beforeEverything() { }      // once, must be static

    @BeforeEach
    void createService() {
        service = new NoteService(new InMemoryRepository());   // fresh per test
    }

    @AfterEach
    void cleanUp() { }

    @AfterAll
    static void afterEverything() { }
}

Each test gets a new instance of the test class by default, so state does not leak between tests. Building fixtures in @BeforeEach keeps that independence.

Parameterised tests

@ParameterizedTest
@ValueSource(ints = {-1, 101, 500})
void rejectsInvalidDiscount(int discount) {
    assertThrows(IllegalArgumentException.class,
            () -> calculator.payable(1000, discount));
}

@ParameterizedTest
@CsvSource({
        "1000, 0,  1180.0",
        "1000, 10, 1062.0",
        "500,  50, 295.0"
})
void calculatesPayable(double amount, int discount, double expected) {
    assertEquals(expected, calculator.payable(amount, discount), 0.001);
}

One test method, many cases, each reported separately. This is the cleanest way to cover boundaries without repeating the method body.

Naming and organising

@DisplayName("Note publishing")
class NotePublishingTest {

    @Nested
    @DisplayName("when the note is a draft")
    class WhenDraft {

        @Test
        @DisplayName("becomes published")
        void becomesPublished() { }
    }

    @Test
    @Disabled("Pending the scheduling feature")
    void schedulesForLater() { }
}

What to test

TestDo not test
Business rules and calculationsGetters and setters with no logic
Boundary values and edge casesThe standard library
Error paths and validationPrivate methods directly
Behaviour a caller depends onImplementation details
Bugs you have fixedFramework code

Test through the public API. A test that reaches into internals breaks on every refactor and tells you nothing about correctness.

Boundaries deserve attention

@Test
void acceptsTheExtremesOfTheAllowedRange() {
    assertDoesNotThrow(() -> calculator.payable(0, 0));
    assertDoesNotThrow(() -> calculator.payable(1000, 100));
}

@Test
void rejectsJustOutsideTheRange() {
    assertThrows(IllegalArgumentException.class, () -> calculator.payable(1000, 101));
}

Most defects live at the edges: zero, one, empty, full, one below and one above a limit.

Making code testable

// Hard to test: dependencies created inside, and the clock is fixed to now
public class ReportService {
    private final Database database = new Database("jdbc:...");

    public Report generate() {
        return new Report(database.load(), LocalDate.now());
    }
}

// Easy to test: dependencies injected, including the clock
public class ReportService {

    private final NoteRepository repository;
    private final Clock clock;

    public ReportService(NoteRepository repository, Clock clock) {
        this.repository = repository;
        this.clock = clock;
    }

    public Report generate() {
        return new Report(repository.findAll(), LocalDate.now(clock));
    }
}
@Test
void usesTheSuppliedDate() {
    Clock fixed = Clock.fixed(Instant.parse("2026-08-21T00:00:00Z"), ZoneOffset.UTC);
    ReportService service = new ReportService(new InMemoryRepository(), fixed);

    assertEquals(LocalDate.of(2026, 8, 21), service.generate().date());
}
Testability is a design property, not a testing technique. Code that takes its dependencies as constructor arguments is easy to test; code that constructs them internally is not, and no amount of test tooling fully fixes that.

Coverage, honestly

Coverage measures which lines ran, not whether the assertions were meaningful. A test that calls a method and asserts nothing still counts. Use coverage to find untested areas, never as a target to be maximised.

Common mistakes

  • Tests that depend on each other, or on execution order.
  • Tests that use the real clock, the network or a shared database.
  • Asserting nothing, or asserting only that no exception occurred.
  • Names such as test1 that explain nothing when they fail.
  • One test asserting ten unrelated things.
  • Testing private methods through reflection instead of through the public API.
  • Logic inside a test that itself needs testing.

Best practices

  • One behaviour per test, named so the failure explains itself.
  • Follow arrange, act, assert.
  • Keep tests independent and repeatable in any order.
  • Inject dependencies, including a Clock.
  • Cover boundaries and error paths, not just the happy case.
  • Add a failing test for every bug before fixing it.
  • Treat test code with the same care as production code.

Practice

  1. Write tests for a method that validates an email address, covering both outcomes and the boundaries.
  2. Convert three near identical tests into one parameterised test.
  3. Make a class that uses LocalDate.now() testable and prove it with a fixed clock.
  4. Explain why a test that shares state with another is unreliable.
  5. Write a failing test for a bug you have encountered, then fix the code.

Conclusion

A unit test states an expectation about one behaviour and checks it automatically. Keep tests fast, independent and clearly named, design classes so their dependencies can be supplied, and test the boundaries where defects actually live.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →
Java

Introduction to Java

Java is a statically typed, object oriented language that compiles to bytecode and runs on a virtual machine, which is what makes it portable.

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.