Why Custom Assertions?
Built-in assertions are generic by design. assertEquals(expected, actual) works for any comparison, but the failure message — expected "active" but was "suspended" — lacks context. What was being checked? A user status? A payment state? An order status?
Custom assertions add domain context: assertThat(user).isActive() produces the message: Expected user "alice@example.com" to be active, but status was SUSPENDED (deactivated on 2024-01-15). This message tells the developer exactly what went wrong without opening the test code.
Building Custom Assertions in Java
AssertJ Custom Assertions
public class UserAssert extends AbstractAssert<UserAssert, User> {
public UserAssert(User actual) {
super(actual, UserAssert.class);
}
public static UserAssert assertThat(User user) {
return new UserAssert(user);
}
public UserAssert isActive() {
isNotNull();
if (!actual.isActive()) {
failWithMessage("Expected user <%s> to be active but status was <%s>",
actual.getEmail(), actual.getStatus());
}
return this;
}
public UserAssert hasRole(String role) {
isNotNull();
if (!actual.getRole().equals(role)) {
failWithMessage("Expected user <%s> to have role <%s> but had <%s>",
actual.getEmail(), role, actual.getRole());
}
return this;
}
public UserAssert hasPermission(String permission) {
isNotNull();
if (!actual.getPermissions().contains(permission)) {
failWithMessage("Expected user <%s> to have permission <%s> but permissions were %s",
actual.getEmail(), permission, actual.getPermissions());
}
return this;
}
}
// Usage — reads like a specification
UserAssert.assertThat(user)
.isActive()
.hasRole("admin")
.hasPermission("manage_users");
Hamcrest Custom Matchers
public class IsActiveUser extends TypeSafeMatcher<User> {
@Override
protected boolean matchesSafely(User user) {
return user.isActive();
}
@Override
public void describeTo(Description description) {
description.appendText("an active user");
}
@Override
protected void describeMismatchSafely(User user, Description description) {
description.appendText("was ").appendText(user.getStatus().toString())
.appendText(" (email: ").appendText(user.getEmail()).appendText(")");
}
public static Matcher<User> isActiveUser() {
return new IsActiveUser();
}
}
// Usage
assertThat(user, isActiveUser());
assertThat(users, everyItem(isActiveUser()));
Custom Assertions in JavaScript
Playwright Custom Matchers
// playwright.config.ts
import { expect } from '@playwright/test';
expect.extend({
async toBeLoggedIn(page) {
const isLoggedIn = await page.locator('.user-menu').isVisible();
return {
pass: isLoggedIn,
message: () => isLoggedIn
? 'Expected page to not be logged in, but user menu was visible'
: 'Expected page to be logged in, but user menu was not found'
};
},
async toHaveProductCount(page, expected) {
const count = await page.locator('.product-card').count();
return {
pass: count === expected,
message: () => `Expected ${expected} products but found ${count}`
};
}
});
// Usage
await expect(page).toBeLoggedIn();
await expect(page).toHaveProductCount(5);
Jest/Vitest Custom Matchers
expect.extend({
toBeValidEmail(received) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const pass = emailRegex.test(received);
return {
pass,
message: () => `Expected "${received}" to be a valid email address`
};
}
});
expect(user.email).toBeValidEmail();
Soft Assertions
Standard assertions stop test execution on the first failure. Soft assertions collect all failures and report them at the end.
Java (AssertJ SoftAssertions)
@Test
void shouldValidateUserProfile() {
SoftAssertions softly = new SoftAssertions();
softly.assertThat(user.getName()).isEqualTo("Alice");
softly.assertThat(user.getEmail()).contains("@");
softly.assertThat(user.getRole()).isEqualTo("admin");
softly.assertThat(user.isActive()).isTrue();
softly.assertAll(); // Reports ALL failures, not just the first
}
// Output: 2 failures:
// 1) Expected role "admin" but was "user"
// 2) Expected active to be true but was false
Playwright Soft Assertions
test('validate dashboard elements', async ({ page }) => {
await page.goto('/dashboard');
await expect.soft(page.locator('.welcome')).toHaveText('Welcome, Admin');
await expect.soft(page.locator('.stats')).toBeVisible();
await expect.soft(page.locator('.recent-orders')).toHaveCount(5);
await expect.soft(page.locator('.notifications')).toBeVisible();
// All assertions are checked; all failures reported together
});
When to Use Each Type
| Type | Use When | Example |
|---|---|---|
| Built-in assertions | Simple comparisons | assertEquals(200, statusCode) |
| Custom assertions | Domain-specific validation with clear messages | assertThat(user).isActive().hasRole("admin") |
| Soft assertions | Validating multiple independent properties | Checking all fields on a profile page |
| Hamcrest matchers | Complex compositions of conditions | assertThat(list, everyItem(hasProperty("active", is(true)))) |
Exercises
Exercise 1: Domain Assertions
- Create custom assertions for an Order entity: isCompleted(), hasTotalGreaterThan(), containsProduct()
- Write tests using your custom assertions
- Compare the failure messages with equivalent built-in assertions
- Verify that custom messages provide actionable debugging information
Exercise 2: Soft Assertion Suite
- Write a test that validates 8 properties of a user profile page using soft assertions
- Introduce 3 failures and verify all are reported
- Compare behavior with hard assertions (only first failure reported)
- Identify scenarios where soft assertions are preferred vs hard assertions
Exercise 3: Playwright Custom Matchers
- Create 3 custom Playwright matchers: toBeLoggedIn, toHaveCartItems(n), toShowError(message)
- Use these matchers in a test suite for an e-commerce flow
- Verify that failure messages are clear and actionable
- Share your matchers as a reusable module for the team