The previous lesson got your project running. This one makes it useful. A test that calls assertEquals(4, 2 + 2) is a proof of concept — it shows the framework runs. A test that calls assertThrows(IllegalArgumentException.class, () -> new User("", "alice@test.com")) is a real quality gate. This lesson covers the complete assertion toolkit you'll use on every project, with examples drawn from the kind of code QA engineers actually test.
@Test — the basics
Any method annotated with @Test is discovered and executed by JUnit:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserValidationTest {
@Test
void shouldRejectEmptyName() {
assertThrows(IllegalArgumentException.class, () ->
new User("", "alice@test.com", "admin")
);
}
@Test
void shouldCreateUserWithValidData() {
User user = new User("Alice", "alice@test.com", "admin");
assertNotNull(user);
assertEquals("Alice", user.getName());
assertEquals("alice@test.com", user.getEmail());
assertEquals("admin", user.getRole());
}
}@Test has no attributes for expected exceptions, timeouts, or enabled/disabled state — those are handled by separate annotations in Jupiter, keeping @Test clean. Unlike TestNG's @Test(expectedExceptions = ...), Jupiter uses assertThrows in the method body, which gives you access to the exception object for further inspection.
Equality assertions
These cover the vast majority of test assertions:
// Primitives and objects
assertEquals(200, response.getStatusCode());
assertEquals("Alice", user.getName());
assertNotEquals("Bob", user.getName());
// Doubles — always use a delta for floating-point comparisons
assertEquals(99.99, product.getPrice(), 0.001);
// Collections
assertEquals(List.of("admin", "viewer"), user.getRoles());
assertIterableEquals(List.of("Alice", "Bob"), userService.getAllNames());The argument order in Jupiter is (expected, actual) — the same as JUnit 4. TestNG reverses this to (actual, expected), which trips up engineers who switch between the two. If you see expected: <Bob> but was: <Alice> and the names are backwards from what you wrote, you've hit this.
Boolean and null assertions
assertTrue(user.isActive());
assertFalse(user.isDeleted());
assertNull(user.getDeletedAt()); // value must be null
assertNotNull(user.getCreatedAt()); // value must not be nullPrefer these over assertEquals(true, user.isActive()) — the output is cleaner and the intent is obvious.
Exception assertions — assertThrows
This is one of Jupiter's most useful improvements over JUnit 4. assertThrows catches the expected exception and returns it, so you can make further assertions on its message or type:
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> userService.createUser("", "alice@test.com")
);
assertEquals("Name cannot be empty", ex.getMessage());The lambda argument is the "executable" — the code that should throw. If it doesn't throw, or throws a different exception type, the test fails. This pattern replaces both JUnit 4's @Test(expected = ...) and TestNG's @Test(expectedExceptions = ...), with the advantage that you get the exception back.
The companion assertDoesNotThrow checks that code runs cleanly:
assertDoesNotThrow(() -> userService.createUser("Alice", "alice@test.com"));Timeout assertions
For tests that call real systems, you can enforce a time limit without putting a Thread.sleep in your test:
import java.time.Duration;
// Waits for the executable to finish, then fails if it took too long
assertTimeout(Duration.ofSeconds(2), () ->
apiClient.getUsers()
);
// Preemptively aborts the executable after 2 seconds
assertTimeoutPreemptively(Duration.ofSeconds(2), () ->
apiClient.getUsers()
);The difference: assertTimeout always lets the code finish (useful when the thread can't safely be interrupted), then checks how long it took. assertTimeoutPreemptively runs the code in a separate thread and kills it when the deadline passes — better for real CI time-boxing. Use assertTimeoutPreemptively when you need a hard stop.
Custom failure messages
Every assertion accepts an optional message as the last argument. Use it to give context, not to restate what the assertion already says:
// Useful: says why this specific value matters
assertEquals(201, response.getStatusCode(),
"Expected 201 Created for a new resource, not 200 OK");
// Useless: just repeats what JUnit already says
assertEquals(201, response.getStatusCode(), "status code should be 201");For expensive-to-compute messages, use the Supplier<String> overload — the message is only generated when the test actually fails:
assertEquals(201, response.getStatusCode(),
() -> "Unexpected status. Response body: " + response.body());The assertion API at a glance
JUnit 5 Assertion Methods
| Assert… | Use when… | |
|---|---|---|
| Equality | assertEquals / assertNotEquals | Comparing int, String, double, List, or any Object with equals() |
| Boolean / Null | assertTrue / assertFalse / assertNull / assertNotNull | Checking a boolean condition or a null/non-null reference |
| Exception | assertThrows / assertDoesNotThrow | Verifying that a method does or doesn't throw a specific exception type |
| Collection & Timeout | assertIterableEquals / assertTimeout / assertTimeoutPreemptively | Comparing ordered collections, or enforcing a maximum execution time |
⚠️ Common mistakes
- Reversed argument order from TestNG habit. Jupiter is
assertEquals(expected, actual). TestNG isassertEquals(actual, expected). Both compile. When a test fails, the output will say "expected: <your-actual-value> but was: <your-expected-value>" and you'll stare at it for five minutes. Pick a framework, lock in the order, and add an IDE live template if needed. - Using
assertEqualsfor floating-point without a delta.assertEquals(0.1 + 0.2, 0.3)fails because floating-point arithmetic is inexact. Always use the three-argument form:assertEquals(0.3, 0.1 + 0.2, 1e-9). - Not using
assertThrowsfor exception tests. A common mistake is wrapping the throwing call in a try/catch and callingAssert.fail()in the catch.assertThrowsis shorter, clearer, and gives you the exception object for free. If you see try/catch in a test method, it is almost always a sign thatassertThrowsshould replace it.
🎯 Practice task
Build a test class that exercises real validation logic. 20–30 minutes.
- Create a
Productclass with fieldsString name,double price,int stock. Add a constructor that throwsIllegalArgumentExceptionifpriceis negative ornameis blank. - Create
ProductTest.javawith:- A test that creates a valid product and asserts all three fields with
assertEqualsandassertNotNull. - A test that calls
assertThrowswhenpriceis -1.0, and asserts the exception message contains "price". - A test that calls
assertThrowswhennameis blank"". - A test using
assertDoesNotThrowfor a boundary case: price = 0.0 should be valid.
- A test that creates a valid product and asserts all three fields with
- Add a
getDiscountedPrice(double percent)method toProduct. Write a test withassertEquals(89.99, product.getDiscountedPrice(10.0), 0.01). - Force a failure. Change one expected value to something wrong. Run
mvn test. Find the message in the output, read "expected: <X> but was: <Y>". Revert. - Stretch. Add a custom message to the price assertion:
"Price after 10% discount on 99.99 should be 89.99". Trigger the failure and confirm the message appears in the output.
Next lesson: the complete lifecycle model — @BeforeAll, @BeforeEach, @AfterEach, and @AfterAll.