Unit Testing with JUnit
Automated verification — proving your code works
After this lesson, you will be able to:
- Explain why manual testing is insufficient for professional software
- Write JUnit 5 test methods using
@Test,assertEquals,assertTrue,assertFalse, andassertThrows - Apply the Arrange-Act-Assert pattern to structure test methods
- Describe the Red-Green-Refactor TDD cycle
- Decide what to test by reading a method signature (normal cases, edge cases, error cases)
- Run tests with Gradle and interpret the results
“It Worked on My Machine”
You have been testing your code all quarter by running it, typing some input, and eyeballing the output. That approach has a shelf life. In 2012, Knight Capital Group lost $440 million in 45 minutes because old test code was accidentally deployed to production servers. In 1996, the Ariane 5 rocket self-destructed 36 seconds after launch because reused code was never tested in the new environment. A single automated test could have caught either failure before it reached the real world.
Manual testing breaks down for three reasons: it does not scale (you cannot re-check 50 methods by hand after every change), it is not repeatable (you forget edge cases between runs), and it leaves no record (your teammate has no idea what you checked). Automated tests solve all three problems. You write the test once, and it runs every time you build.
From CSCD 110: In Python, you probably tested by running your script and checking the printed output. Java’s compiled nature and static types already catch some errors at compile time. But type-checking alone does not prove that your
depositmethod actually increases the balance by the right amount. That is what unit tests verify.
What Is a Unit Test?
A unit test is a small, automated method that tests one specific behavior. The word “unit” means the smallest testable piece — usually a single method call with a single set of inputs.
Every unit test follows the AAA pattern:
- Arrange — Set up the objects and data you need
- Act — Call the method you are testing
- Assert — Check that the result matches your expectation
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
class StudentTest {
@Test
void getName_afterConstruction_returnsName() {
// Arrange: create a Student
Student student = new Student("Alice", 3.8, 1001);
// Act: call the method under test
String result = student.getName();
// Assert: verify the result
assertEquals("Alice", result);
}
}
Three things to notice. First, @Test is an annotation — a marker that tells JUnit “this method is a test; discover it and run it.” You never call test methods from main(). Second, assertEquals is a static import from org.junit.jupiter.api.Assertions. The static import lets you write assertEquals(...) instead of Assertions.assertEquals(...). Third, the test method name follows a convention: methodName_condition_expectedResult. When this test fails at 10:47 PM, the name alone tells you what broke.
JUnit 5 Assertion Methods
Each assertion checks a specific postcondition — something that must be true after the method runs.
| Assertion | What It Checks |
|---|---|
assertEquals(expected, actual) |
Values are equal |
assertEquals(expected, actual, delta) |
Doubles are equal within tolerance |
assertTrue(condition) |
Condition is true |
assertFalse(condition) |
Condition is false |
assertThrows(ExType.class, () -> ...) |
Code throws the expected exception |
assertNotNull(value) |
Value is not null |
Common Pitfall: When comparing
doublevalues, always use a delta (tolerance). Floating-point arithmetic is imprecise:0.1 + 0.2evaluates to0.30000000000000004, not0.3. The delta1e-9means “these are equal if they differ by less than 0.000000001.”// WRONG — may fail due to floating-point imprecision assertEquals(0.3, 0.1 + 0.2); // RIGHT — passes because values are within tolerance assertEquals(0.3, 0.1 + 0.2, 1e-9);
In the Arrange-Act-Assert pattern, which phase would contain assertEquals(150.0, account.getBalance(), 1e-9);?
Testing Exceptions with assertThrows
When your code has precondition checks that throw exceptions, you need to verify those guards actually work. The assertThrows method takes two arguments: the exception type you expect, and a lambda expression containing the code that should throw.
@Test
void constructor_nullName_throwsException() {
assertThrows(IllegalArgumentException.class, () -> {
new Student(null, 3.5, 1002);
});
}
The () -> { ... } syntax is a lambda — think of it as packaging code into an envelope and handing it to JUnit. JUnit opens the envelope inside a try-catch block, runs the code, and checks whether the expected exception was thrown. Without the lambda, Java’s eager evaluation would throw the exception before assertThrows even starts, and JUnit would never get a chance to catch it.
Key Insight: Every
if (x == null) throw ...in your source code should have a matchingassertThrows(...)in your tests. Every value your guard rejects should have a test that proves it gets rejected. Every value your guard allows should have a test that proves the method works correctly.
What to Test: Reading the Method Signature
You do not need to guess what to test. The method signature tells you almost everything:
- Parameters = what you can vary
- Parameter types = what edge cases exist
- Return type = what you need to verify
Consider the Student class from Lesson 4.4:
public Student(final String name, final double gpa, final int id)
For each parameter type, there is a standard set of values that frequently cause bugs:
String parameters: null, "" (empty), " " (blank), "Alice" (typical valid value)
double parameters: 0.0, negative (-1.0), small positive (0.01), typical (3.5), boundary (4.0 for GPA)
int parameters: negative (-1), zero (0), one (1), typical (1001), Integer.MAX_VALUE
For each interesting value, write one test. That is the whole algorithm: read the signature, list the interesting values, write one test per value.
You are testing a method public void setGpa(final double gpa) that should reject values outside the range 0.0–4.0. Which set of test values covers the most important cases?
Complete Example: Testing the Student Class
Here is a Student class with precondition checks, followed by its test class. This is the running example from Lesson 4.4, now verified with JUnit 5.
public class Student {
private final String name;
private final double gpa;
private final int id;
public Student(final String name, final double gpa, final int id) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name cannot be null or blank");
}
if (gpa < 0.0 || gpa > 4.0) {
throw new IllegalArgumentException("gpa must be between 0.0 and 4.0");
}
if (id <= 0) {
throw new IllegalArgumentException("id must be positive");
}
this.name = name;
this.gpa = gpa;
this.id = id;
}
public String getName() { return name; }
public double getGpa() { return gpa; }
public int getId() { return id; }
@Override
public String toString() {
return "Student{name='" + name + "', gpa=" + gpa + ", id=" + id + "}";
}
@Override
public boolean equals(final Object obj) {
if (this == obj) { return true; }
if (!(obj instanceof Student)) { return false; }
Student other = (Student) obj;
return this.id == other.id;
}
@Override
public int hashCode() {
return Integer.hashCode(id);
}
}
And the test class:
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import static org.junit.jupiter.api.Assertions.*;
@DisplayName("Student")
class StudentTest {
private Student defaultStudent;
@BeforeEach
void setUp() {
defaultStudent = new Student("Alice", 3.8, 1001);
}
@Nested
@DisplayName("Constructor")
class ConstructorTests {
@Test
@DisplayName("Valid parameters create a Student")
void validParams_createsStudent() {
assertNotNull(defaultStudent);
assertEquals("Alice", defaultStudent.getName());
assertEquals(3.8, defaultStudent.getGpa(), 1e-9);
assertEquals(1001, defaultStudent.getId());
}
@Test
@DisplayName("Null name throws IllegalArgumentException")
void nullName_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> new Student(null, 3.5, 1002));
}
@Test
@DisplayName("Blank name throws IllegalArgumentException")
void blankName_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> new Student(" ", 3.5, 1002));
}
@Test
@DisplayName("Negative GPA throws IllegalArgumentException")
void negativeGpa_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> new Student("Bob", -0.1, 1002));
}
@Test
@DisplayName("GPA above 4.0 throws IllegalArgumentException")
void gpaAboveFour_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> new Student("Bob", 4.1, 1002));
}
@Test
@DisplayName("Zero ID throws IllegalArgumentException")
void zeroId_throwsException() {
assertThrows(IllegalArgumentException.class,
() -> new Student("Bob", 3.0, 0));
}
}
@Nested
@DisplayName("equals and hashCode")
class EqualsTests {
@Test
@DisplayName("Same ID means equal")
void sameId_areEqual() {
Student other = new Student("Bob", 3.2, 1001);
assertEquals(defaultStudent, other);
}
@Test
@DisplayName("Different ID means not equal")
void differentId_areNotEqual() {
Student other = new Student("Alice", 3.8, 9999);
assertNotEquals(defaultStudent, other);
}
@Test
@DisplayName("Equal objects have same hashCode")
void equalObjects_sameHashCode() {
Student other = new Student("Bob", 3.2, 1001);
assertEquals(defaultStudent.hashCode(), other.hashCode());
}
@Test
@DisplayName("Null returns false, not an exception")
void equalsNull_returnsFalse() {
assertFalse(defaultStudent.equals(null));
}
}
}
Notice the structure: @BeforeEach creates a shared default object so tests do not repeat setup code. @Nested classes group tests by topic — Constructor, equals/hashCode. @DisplayName gives human-readable output. Each test has one clear purpose, and the name tells you exactly what it verifies.
The Red-Green-Refactor Cycle
Test-Driven Development (TDD) flips the usual order: you write the test before the code. It follows a strict three-step cycle:
-
RED — Write a test that fails. The test describes a behavior you want but have not implemented yet. Run it and watch it fail. The failure proves the test is actually checking something.
-
GREEN — Write the minimum code to make the test pass. Do not worry about elegance. Do not add features the test does not require. Just make it green.
-
REFACTOR — Now that you have passing tests as a safety net, clean up your code. Remove duplication. Improve names. Extract helper methods. Run the tests after every change to confirm you did not break anything.
┌──────────┐ ┌──────────┐ ┌──────────┐
│ RED │────►│ GREEN │────►│ REFACTOR │
│ (write a │ │ (make it │ │ (clean │
│ failing │ │ pass) │ │ up) │
│ test) │ │ │ │ │
└──────────┘ └──────────┘ └────┬─────┘
▲ │
└─────────────────────────────────┘
The Trick: First make it work, then make it right. In the GREEN phase, ugly code is fine. The REFACTOR phase is where you make it clean. Having tests means you can refactor with confidence — if you break something, a test tells you immediately.
TDD makes large projects feel small. Instead of staring at a blank file wondering where to start, you ask: “What is the simplest thing I can test right now?” Write a test for that. Make it pass. Pick the next behavior. Repeat. The project builds itself one piece at a time, and you always have working code you can submit.
Running Tests with Gradle
In course projects, you run tests through Gradle:
# Run all tests
./gradlew test
# Run tests for a specific class
./gradlew test --tests StudentTest
# Clean build cache and re-run
./gradlew clean test
Test results appear in the terminal and are saved as an HTML report at app/build/reports/tests/test/index.html. Open that file in a browser to see a formatted summary of which tests passed and which failed, with failure messages and stack traces.
In TDD's Red-Green-Refactor cycle, what happens during the GREEN phase?
Quick Reference
| Annotation | Purpose |
|---|---|
@Test |
Marks a method as a test |
@BeforeEach |
Runs setup code before each test method |
@DisplayName("...") |
Human-readable name in test output |
@Nested |
Groups related tests in an inner class |
| Assertion | When to Use |
|---|---|
assertEquals(expected, actual) |
Exact value match (int, String) |
assertEquals(expected, actual, 1e-9) |
Double comparison with tolerance |
assertTrue(condition) / assertFalse(condition) |
Boolean checks |
assertThrows(ExType.class, () -> ...) |
Verify precondition guards |
assertNotNull(value) |
Object exists |
Summary
Unit tests are automated methods that verify your code works correctly. Each test follows the Arrange-Act-Assert pattern: set up objects, call the method, check the result. JUnit 5 provides @Test to mark test methods and assertion methods like assertEquals, assertTrue, and assertThrows to check postconditions.
You do not need to guess what to test. The method signature tells you: parameter types reveal the edge cases (null, empty, zero, boundary values), and the return type tells you which assertion to use. Every precondition check in your source code should have a matching assertThrows in your test class.
TDD — writing the test before the code — follows the Red-Green-Refactor cycle. It turns large, intimidating projects into a sequence of small, achievable steps. You always have passing tests as a safety net, so you can refactor with confidence.
Next lesson: Interfaces and Comparable — defining contracts that let Java sort your custom objects.