07 · Assertions & Matchers¶
An assertion has two jobs: decide pass or fail, and — when it fails — tell
whoever reads the CI log what went wrong without them opening the code.
assertTrue(list.size() == 3) does the first job and fails the second: the
report says only "expected true but was false". This module is about writing
the second kind of assertion, with Hamcrest, AssertJ and soft assertions.
1. Why the default assertion is not enough¶
// The report says: expected: <true> but was: <false>
assertTrue(order.getItems().size() == 3);
// The report says: expected: <3> but was: <2>
assertEquals(3, order.getItems().size());
// The report says: Expected size: 3 but was: 2 in:
// ["Keyboard", "Mouse"]
assertThat(order.getItems()).hasSize(3);
Only the third told you what was actually in the list. Over a year of CI failures that difference is measured in days of engineer time.
2. JUnit 5 built-ins¶
package com.example.assertions;
import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
class JUnitAssertionsTest {
@Test
void coreAssertions() {
assertEquals(15, new Discounts().percentFor(10000),
"Orders of 10000 and above get the top tier");
assertNotEquals(0, new Discounts().percentFor(1000));
assertTrue("qa@example.com".contains("@"), "Email must contain @");
assertNull(null);
assertNotNull(List.of());
assertSame(Boolean.TRUE, Boolean.TRUE);
assertArrayEquals(new int[]{1, 2, 3}, new int[]{1, 2, 3});
assertIterableEquals(List.of("a", "b"), List.of("a", "b"));
assertLinesMatch(List.of("id=\\d+"), List.of("id=42")); // regex per line
}
@Test
void groupedAssertions() {
Discounts discounts = new Discounts();
assertAll("discount tiers",
() -> assertEquals(0, discounts.percentFor(999.0)),
() -> assertEquals(5, discounts.percentFor(1000.0)),
() -> assertEquals(10, discounts.percentFor(5000.0)),
() -> assertEquals(15, discounts.percentFor(10000.0)));
}
@Test
void exceptions() {
IllegalArgumentException thrown = assertThrows(
IllegalArgumentException.class,
() -> new Discounts().percentFor(-1));
assertEquals("Order amount cannot be negative", thrown.getMessage());
assertDoesNotThrow(() -> new Discounts().percentFor(0));
}
@Test
void timing() {
assertTimeout(Duration.ofSeconds(2), () -> new Discounts().percentFor(500));
}
static class Discounts {
int percentFor(double amount) {
if (amount < 0) throw new IllegalArgumentException("Order amount cannot be negative");
if (amount >= 10000) return 15;
if (amount >= 5000) return 10;
if (amount >= 1000) return 5;
return 0;
}
}
}
assertAll is the one to internalise. Without it, the first failing
boundary hides the other three, so you fix one, re-run for three minutes,
and discover the next — four times over.
3. Hamcrest¶
Hamcrest reads as a sentence and produces "Expected: … but: …" messages. It
is also what RestAssured's .body(...) uses, so you already know it.
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
assertThat(price, is(closeTo(249.99, 0.001)));
assertThat(title, containsStringIgnoringCase("selenium"));
assertThat(username, allOf(notNullValue(), not(emptyString()), hasLength(8)));
assertThat(statusCode, anyOf(is(200), is(201), is(204)));
assertThat(browsers, hasItems("chrome", "firefox"));
assertThat(browsers, hasSize(3));
assertThat(results, everyItem(greaterThan(0)));
assertThat(config, hasEntry("browser", "chrome"));
assertThat(errorMessage, startsWith("Your username"));
A failure reads:
4. AssertJ — the modern default¶
One entry point, assertThat, and the IDE autocompletes every assertion
valid for that type.
package com.example.assertions;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.*;
class AssertJTest {
record Product(String name, double price, boolean inStock) { }
@Test
void strings() {
String flash = "You logged into a secure area!";
assertThat(flash)
.isNotBlank()
.startsWith("You logged")
.containsIgnoringCase("SECURE")
.doesNotContain("error")
.hasSizeGreaterThan(10)
.matches(".*area!$");
}
@Test
void collections() {
List<Product> cart = List.of(
new Product("Keyboard", 49.99, true),
new Product("Mouse", 19.99, true),
new Product("Monitor", 199.99, false));
assertThat(cart)
.hasSize(3)
.extracting(Product::name)
.containsExactly("Keyboard", "Mouse", "Monitor");
assertThat(cart)
.filteredOn(Product::inStock)
.hasSize(2)
.extracting(Product::name)
.containsExactlyInAnyOrder("Mouse", "Keyboard");
assertThat(cart)
.allSatisfy(p -> assertThat(p.price()).isPositive())
.anyMatch(p -> p.price() > 100);
assertThat(cart)
.extracting(Product::name, Product::inStock)
.contains(tuple("Monitor", false));
}
@Test
void numbersAndMaps() {
assertThat(249.99).isCloseTo(250.0, within(0.05));
assertThat(15).isBetween(0, 20).isNotNegative();
Map<String, String> config = Map.of("browser", "chrome", "headless", "true");
assertThat(config)
.containsEntry("browser", "chrome")
.containsKeys("headless")
.doesNotContainKey("password");
}
@Test
void exceptions() {
assertThatThrownBy(() -> { throw new IllegalStateException("driver not started"); })
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("not started");
assertThatNoException().isThrownBy(() -> Integer.parseInt("42"));
}
@Test
void withContext() {
int actual = 3;
assertThat(actual)
.as("cart should hold %d items after adding the bundle", 5)
.isEqualTo(5);
}
}
.as(...) is how you attach the business meaning. Write it for any
assertion whose failure would leave a colleague guessing.
5. Soft assertions¶
A hard assertion stops the test at the first failure. When you are verifying one page against six requirements, you want all six results.
// AssertJ
import org.assertj.core.api.SoftAssertions;
@Test
void secureAreaMeetsAllRequirements() {
SoftAssertions softly = new SoftAssertions();
softly.assertThat(page.getHeading()).as("REQ-03 heading").isEqualTo("Secure Area");
softly.assertThat(page.getFlashMessage()).as("REQ-03 flash").contains("You logged into");
softly.assertThat(page.isLogoutVisible()).as("REQ-04 logout button").isTrue();
softly.assertThat(page.getPageTitle()).as("page title").isEqualTo("The Internet");
softly.assertAll(); // reports EVERY failure, then fails the test
}
org.assertj.core.error.AssertJMultipleFailuresError:
Multiple Failures (2 failures)
-- failure 1 --
[REQ-03 heading] expected: "Secure Area" but was: "Secure area"
-- failure 2 --
[REQ-04 logout button] expected: true but was: false
// TestNG
import org.testng.asserts.SoftAssert;
SoftAssert softAssert = new SoftAssert();
softAssert.assertEquals(page.getHeading(), "Secure Area", "REQ-03 heading");
softAssert.assertTrue(page.isLogoutVisible(), "REQ-04 logout button");
softAssert.assertAll();
A SoftAssert without assertAll() always passes
Forget the final call and the test reports green no matter how many
checks failed. This is the most dangerous defect a test suite can have,
because it is invisible. Make "every SoftAssert gets an assertAll()"
a review checklist item, or use AssertJ's
@ExtendWith(SoftAssertionsExtension.class) with an injected
SoftAssertions parameter, which calls it for you.
Use soft assertions for independent checks on one state. Use hard assertions when a failure invalidates everything after it — there is no point checking the cart total when the page did not load.
6. Custom assertions¶
When a check appears in six tests, give it a name.
import org.assertj.core.api.AbstractAssert;
public class OrderAssert extends AbstractAssert<OrderAssert, Order> {
public OrderAssert(Order actual) { super(actual, OrderAssert.class); }
public static OrderAssert assertThat(Order actual) { return new OrderAssert(actual); }
public OrderAssert isShippable() {
isNotNull();
if (!actual.isPaid() || actual.getItems().isEmpty()) {
failWithMessage("Expected order <%s> to be shippable but paid=<%s>, items=<%d>",
actual.getId(), actual.isPaid(), actual.getItems().size());
}
return this;
}
}
// In a test
OrderAssert.assertThat(order).isShippable();
The failure message now states the domain rule and every value that mattered — which is what a defect report needs anyway.
7. Testing traps¶
Trap 1 — reversed argument order between frameworks
JUnit: assertEquals(expected, actual). TestNG:
assertEquals(actual, expected). Mixed up, every failure message accuses
the wrong side. AssertJ and Hamcrest have no such ambiguity — one more
reason to prefer them.
Trap 2 — assertEquals on doubles
assertEquals(0.1 + 0.2, 0.3) fails: binary floating point gives
0.30000000000000004. Always use a delta —
assertEquals(0.3, sum, 0.0001) or isCloseTo(0.3, within(0.0001)).
Trap 3 — assertion-free tests
A Selenium test that navigates, clicks and ends passes as long as nothing
throws. It verifies nothing. Grep your suite for test methods with no
assert in them; there are usually more than you expect.
Trap 4 — over-specified assertions
assertEquals("Showing 1-10 of 47 results", banner) fails every time the
fixture data changes, for no defect. Assert the part the requirement
actually constrains: assertThat(banner).matches("Showing 1-10 of \\d+ results").
Trap 5 — assertTrue(a.equals(b))
Passes and fails identically to assertEquals(a, b) but prints no
values. Never wrap a comparison in assertTrue.
Cheat sheet¶
| Check | AssertJ |
|---|---|
| Equality | assertThat(x).isEqualTo(y) |
| Null | assertThat(x).isNull() / .isNotNull() |
| Boolean | assertThat(flag).isTrue() |
| String contains | assertThat(s).contains("abc") |
| Case-insensitive | assertThat(s).containsIgnoringCase("ABC") |
| Regex | assertThat(s).matches("id=\\d+") |
| Blank | assertThat(s).isNotBlank() |
| Size | assertThat(list).hasSize(3) |
| Contains, order-free | assertThat(list).containsExactlyInAnyOrder(a, b) |
| Field projection | assertThat(list).extracting(Product::name).contains("Mouse") |
| Filter then assert | assertThat(list).filteredOn(Product::inStock).hasSize(2) |
| Every element | assertThat(list).allSatisfy(p -> ...) |
| Numeric tolerance | assertThat(d).isCloseTo(250.0, within(0.05)) |
| Range | assertThat(n).isBetween(1, 10) |
| Map entry | assertThat(map).containsEntry("k", "v") |
| Exception | assertThatThrownBy(...).isInstanceOf(X.class).hasMessageContaining("y") |
| Add context | .as("REQ-04 logout button") |
| Soft (AssertJ) | SoftAssertions softly = ...; softly.assertAll(); |
| Soft (TestNG) | SoftAssert sa = new SoftAssert(); sa.assertAll(); |
| Group (JUnit) | assertAll(() -> ..., () -> ...) |
How It Actually Works¶
The rich failure message from assertThat(order.getItems()).hasSize(3)
comes from AssertJ building a fluent assertion object that carries the
actual value with it, not from any special exception-formatting magic.
assertThat(x) simply wraps x in a typed assertion class (ListAssert,
StringAssert, etc.); .hasSize(3) then has direct access to both the
real x and the expected 3 in the same method call, so when the check
fails it can construct a message by literally printing the collection's
contents (toString() on each element) alongside the size mismatch —
information a generic assertTrue(boolean) never had, because by the time
assertTrue receives its argument, the boolean has already been computed
and every intermediate value (the list, its size) is gone. This is the
general principle behind every "good" assertion API: push the comparison
inside the assertion call so it still has both operands in scope when
building the failure message, rather than reducing everything to a bare
boolean before the assertion ever sees it.
SoftAssertions/SoftAssert change when a failure is thrown rather than
how it's detected: a normal assertion throws immediately on failure via
AssertionError, unwinding the test method right there, so only the first
failure in a method is ever seen. A soft assertion instead catches that
AssertionError internally and appends it to a list, letting execution
continue to the next assertion — assertAll() (or softly.assertAll())
is the one line that finally throws, aggregating every buffered failure
into a single combined error message. This is exactly why a SoftAssert
without a trailing assertAll() "always passes": the failures were
recorded, but nothing ever converts the recorded list back into a thrown
exception, so the test method returns normally and the runner marks it a
pass.
Exercise¶
- Add AssertJ to your
pom.xmland rewrite everyassertTruein your suite as a typed AssertJ assertion. For each one, note whether the failure message improved. - Take the four discount boundaries and write them three ways: four separate
@Testmethods, one method withassertAll, and one withSoftAssertions. Break the 5000 boundary and compare the three reports. - Delete the
softly.assertAll()from your soft-assertion test, keep the deliberate break, and confirm the test passes. Write two sentences on how you would stop this reaching your main branch. - Write an assertion on a
List<Product>usingextracting,filteredOnandtuplethat would have caught a bug where out-of-stock items are still shown as purchasable. - Assert
0.1 + 0.2 == 0.3withassertEqualsand watch it fail. Fix it two ways (JUnit delta, AssertJwithin) and note the exact value printed. - Write a custom
PageAssertwithhasFlashMessageContaining(String)andshowsLogoutButton(), then rewrite one Module 01 test with it. - Audit your whole suite: list every test method containing no assertion, and either add one or explain in a comment why the test is legitimate (a smoke navigation test may be — say so explicitly).