Unit Testing Fundamentals in Java
A unit test runs a small piece of code with known input and asserts the result, automatically and repeatedly.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
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.
| Level | Covers | Speed |
|---|---|---|
| Unit | One class or method | Milliseconds |
| Integration | Several components together | Seconds |
| End to end | The whole system | Minutes |
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.appliesDiscountBeforeTaxtells you what broke from the failure report alone, whichtest2never 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
| Test | Do not test |
|---|---|
| Business rules and calculations | Getters and setters with no logic |
| Boundary values and edge cases | The standard library |
| Error paths and validation | Private methods directly |
| Behaviour a caller depends on | Implementation details |
| Bugs you have fixed | Framework 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
test1that 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
- Write tests for a method that validates an email address, covering both outcomes and the boundaries.
- Convert three near identical tests into one parameterised test.
- Make a class that uses
LocalDate.now()testable and prove it with a fixed clock. - Explain why a test that shares state with another is unreliable.
- 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.