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

TypeUse WhenExample
Built-in assertionsSimple comparisonsassertEquals(200, statusCode)
Custom assertionsDomain-specific validation with clear messagesassertThat(user).isActive().hasRole("admin")
Soft assertionsValidating multiple independent propertiesChecking all fields on a profile page
Hamcrest matchersComplex compositions of conditionsassertThat(list, everyItem(hasProperty("active", is(true))))

Exercises

Exercise 1: Domain Assertions

  1. Create custom assertions for an Order entity: isCompleted(), hasTotalGreaterThan(), containsProduct()
  2. Write tests using your custom assertions
  3. Compare the failure messages with equivalent built-in assertions
  4. Verify that custom messages provide actionable debugging information

Exercise 2: Soft Assertion Suite

  1. Write a test that validates 8 properties of a user profile page using soft assertions
  2. Introduce 3 failures and verify all are reported
  3. Compare behavior with hard assertions (only first failure reported)
  4. Identify scenarios where soft assertions are preferred vs hard assertions

Exercise 3: Playwright Custom Matchers

  1. Create 3 custom Playwright matchers: toBeLoggedIn, toHaveCartItems(n), toShowError(message)
  2. Use these matchers in a test suite for an e-commerce flow
  3. Verify that failure messages are clear and actionable
  4. Share your matchers as a reusable module for the team