Writing Your First Test — @Test and Assertions

8 min read

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 null

Prefer 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

⚠️ Common mistakes

  • Reversed argument order from TestNG habit. Jupiter is assertEquals(expected, actual). TestNG is assertEquals(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 assertEquals for 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 assertThrows for exception tests. A common mistake is wrapping the throwing call in a try/catch and calling Assert.fail() in the catch. assertThrows is 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 that assertThrows should replace it.

🎯 Practice task

Build a test class that exercises real validation logic. 20–30 minutes.

  1. Create a Product class with fields String name, double price, int stock. Add a constructor that throws IllegalArgumentException if price is negative or name is blank.
  2. Create ProductTest.java with:
    • A test that creates a valid product and asserts all three fields with assertEquals and assertNotNull.
    • A test that calls assertThrows when price is -1.0, and asserts the exception message contains "price".
    • A test that calls assertThrows when name is blank "".
    • A test using assertDoesNotThrow for a boundary case: price = 0.0 should be valid.
  3. Add a getDiscountedPrice(double percent) method to Product. Write a test with assertEquals(89.99, product.getDiscountedPrice(10.0), 0.01).
  4. 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.
  5. 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.

// tip to track lessons you complete and pick up where you left off across devices.