Skip to main content
Back to Blog
Comparison
2026-06-15

AssertJ vs Hamcrest vs JUnit Assertions (2026)

AssertJ vs Hamcrest vs JUnit assertions compared for Java in 2026: fluent syntax, readability, error messages, IDE autocomplete, and which to choose.

AssertJ vs Hamcrest vs JUnit Assertions

For Java tests in 2026, AssertJ is the recommended assertion library. Its fluent assertThat(actual).isEqualTo(expected) chain reads naturally left to right, gives the richest IDE autocomplete (the next assertion is discoverable by pressing dot), and produces the clearest failure messages. JUnit 5 (Jupiter) assertions are the built-in baseline — fine for simple checks and always available with zero extra dependencies. Hamcrest pioneered the matcher style (assertThat(actual, is(equalTo(expected)))) and is still bundled with some tools, but its right-to-left matcher composition and weaker autocomplete have led most teams to prefer AssertJ.

This guide compares the three on syntax, readability, error output, collection and exception assertions, and tooling, then recommends when to use each. For installable, agent-ready Java testing skills, see the QASkills directory.

Quick comparison

AspectAssertJHamcrestJUnit 5 assertions
StyleFluent chain assertThat(x).isEqualTo(y)Matcher assertThat(x, is(y))Static assertEquals(y, x)
Dependencyassertj-corehamcrestBuilt into junit-jupiter
IDE autocompleteExcellent (chain discovery)LimitedLimited
Failure messagesVery descriptive, diff-awareGood ("expected/but was")Basic
Collection assertionsRich (contains, extracting, etc.)Decent via matchersSparse
Exception assertionsFluent assertThatThrownByNone nativelyassertThrows
Custom assertionsFirst-class, generatableCustom matchers (verbose)None
Recommended for new codeYesLegacy/nicheSimple cases / no deps

JUnit 5 built-in assertions

JUnit 5's org.junit.jupiter.api.Assertions is the zero-dependency baseline. Methods are static and take the expected value first, actual second.

import static org.junit.jupiter.api.Assertions.*;

assertEquals(42, calculator.add(40, 2));
assertTrue(user.isActive());
assertNull(cache.get("missing"));
assertThrows(IllegalArgumentException.class, () -> service.parse("bad"));

// Group independent assertions so all are reported, not just the first failure
assertAll("user",
    () -> assertEquals("Ada", user.name()),
    () -> assertEquals(36, user.age()));

This covers basic equality, truthiness, nullity, exceptions, and grouped assertions. The limitations show up fast: collection assertions are sparse, failure messages are terse (expected: <42> but was: <41>), and there is no fluent chaining or autocomplete to guide you toward the right assertion. For simple unit tests with no other dependencies, it is perfectly adequate.

Hamcrest matchers

Hamcrest introduced the matcher pattern that JUnit 4 popularized via assertThat(actual, matcher). The matcher is a composable object describing the expectation.

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;

assertThat(calculator.add(40, 2), is(equalTo(42)));
assertThat(names, hasItem("Ada"));
assertThat(names, containsInAnyOrder("Ada", "Linus"));
assertThat(user.name(), startsWith("A"));
assertThat(score, allOf(greaterThan(0), lessThanOrEqualTo(100)));

Hamcrest's strengths are composability (allOf, anyOf, not) and reusable custom matchers shared across a suite. Its weaknesses, the reasons momentum shifted to AssertJ, are real: the matcher reads right-to-left and nests awkwardly (is(equalTo(...))), IDE autocomplete cannot guide you because the matcher is a separate argument, and writing a custom matcher means subclassing TypeSafeMatcher with matchesSafely and describeTo boilerplate. Hamcrest is still pulled in transitively by some libraries (and was historically bundled with JUnit), so you may encounter it even if you do not choose it.

AssertJ fluent assertions

AssertJ's assertThat(actual) returns a type-specific assertion object, and you chain assertions off it. Because the chain is a real object, the IDE autocompletes every available assertion the moment you type a dot — discoverability that neither alternative offers.

import static org.assertj.core.api.Assertions.*;

assertThat(calculator.add(40, 2)).isEqualTo(42);

assertThat(names)
    .hasSize(2)
    .contains("Ada")
    .doesNotContain("Bob")
    .containsExactly("Ada", "Linus");

assertThat(user.name())
    .startsWith("A")
    .endsWith("a")
    .hasSize(3);

Chaining lets you make several related assertions in one readable statement. AssertJ's collection support is its standout feature — extracting, filteredOn, anySatisfy, and field-by-field comparison make assertions on lists of objects concise:

assertThat(orders)
    .filteredOn(o -> o.status() == PAID)
    .extracting(Order::amount)
    .containsExactly(4200L, 990L);

// Compare by fields, ignoring object identity
assertThat(actualUser)
    .usingRecursiveComparison()
    .ignoringFields("createdAt")
    .isEqualTo(expectedUser);

Exception testing is fluent too, and lets you assert on the message and cause in the same chain:

assertThatThrownBy(() -> service.parse("bad"))
    .isInstanceOf(IllegalArgumentException.class)
    .hasMessageContaining("invalid")
    .hasNoCause();

Add it with org.assertj:assertj-core (test scope). AssertJ also generates custom assertion classes for your domain types, so you can write assertThat(invoice).isPaid().hasTotal(4200L) — far cleaner than a Hamcrest custom matcher.

AssertJ ships type-specific entry points beyond assertThat, too. assertThatCode(...) asserts that a block does not throw, assertThatNullPointerException() and friends are shorthands for common exception types, and assertThatExceptionOfType(SomeException.class).isThrownBy(() -> ...) reads as a sentence. For optionals it offers assertThat(optional).isPresent().hasValue(x), and for maps assertThat(map).containsEntry(k, v).doesNotContainKey(z). Each returns a strongly typed assertion object, so the IDE only offers methods that make sense for that type — you cannot accidentally call a string assertion on a list. This type-directed autocomplete is a productivity multiplier on large suites and is something neither Hamcrest's loosely typed matchers nor JUnit's static methods provide.

Failure message quality

This is where AssertJ pulls clearly ahead. Compare a failing list assertion:

  • JUnit: expected: <[Ada, Linus]> but was: <[Ada, Bob]> — you eyeball the diff.
  • Hamcrest: Expected: iterable containing ["Ada","Linus"] but: item 1: was "Bob" — better, points at the index.
  • AssertJ: describes exactly which elements were expected, found, missing, and unexpected, with the comparison strategy named. For usingRecursiveComparison, it lists each differing field path and both values.

When a test fails at 2 a.m. in CI, the assertion library's error message is the difference between a five-second fix and a debugging session. AssertJ's messages are the most actionable of the three.

Soft assertions: collecting failures

By default, the first failing assertion aborts the test, so you never learn whether the later assertions would also have failed. When you are validating several independent properties of one result, that is annoying — you fix one, rerun, hit the next. AssertJ's soft assertions collect all failures and report them together at the end.

import org.assertj.core.api.SoftAssertions;

SoftAssertions softly = new SoftAssertions();
softly.assertThat(user.name()).isEqualTo("Ada");
softly.assertThat(user.age()).isEqualTo(36);
softly.assertThat(user.email()).endsWith("@example.com");
softly.assertAll();   // throws once, listing every failure

If the name and email are both wrong, you see both in a single run. There is also a JUnit 5 extension (@ExtendWith(SoftAssertionsExtension.class) with an injected SoftAssertions parameter) that calls assertAll() for you. JUnit's nearest equivalent is assertAll(...) with a list of lambdas, which groups failures similarly; Hamcrest has no built-in soft-assertion mechanism. This is another area where AssertJ's richer API saves real debugging time.

Numeric, string, and date assertions compared

The everyday assertions reveal how much more expressive AssertJ is for common types. A few representative comparisons:

// Numeric tolerance
assertThat(0.1 + 0.2).isCloseTo(0.3, within(0.0001));   // AssertJ
assertThat(0.1 + 0.2, closeTo(0.3, 0.0001));            // Hamcrest
assertEquals(0.3, 0.1 + 0.2, 0.0001);                   // JUnit (delta overload)

// String content
assertThat(slug).containsIgnoringCase("Mock").doesNotContain(" ");  // AssertJ chain
assertThat(slug, allOf(containsStringIgnoringCase("Mock"), not(containsString(" ")))); // Hamcrest

// Dates / times
assertThat(created).isBefore(now).isAfterOrEqualTo(start);  // AssertJ, fluent

For floating-point tolerance all three can do it, but AssertJ's isCloseTo(value, within(delta)) reads most clearly. For strings and dates, AssertJ's chaining lets you express several constraints in one statement, whereas Hamcrest forces you to nest matchers in allOf and JUnit offers only the primitive assertEquals/assertTrue. The gap widens the more specific your assertion becomes.

Migrating from JUnit/Hamcrest to AssertJ

You do not have to convert a whole suite at once. Because the libraries coexist, adopt AssertJ for new tests and convert old ones opportunistically. The mechanical mapping is straightforward: assertEquals(expected, actual) becomes assertThat(actual).isEqualTo(expected) (note the argument order flips — actual first in AssertJ), assertTrue(x) becomes assertThat(x).isTrue(), and a Hamcrest assertThat(actual, hasItem(x)) becomes assertThat(actual).contains(x). The one habit to build is the reversed argument order, since JUnit and Hamcrest put expected first while AssertJ puts the actual value first. Standardize the static import (import static org.assertj.core.api.Assertions.*) in new files and let the old assertions age out.

Can you mix them?

Yes. AssertJ, Hamcrest, and JUnit assertions coexist in the same project and even the same test class because they live in different packages. A common pattern is JUnit for assertThrows plus AssertJ for everything else, or AssertJ as the default with Hamcrest matchers reused where they already exist. Be careful with the static assertThat import collision — AssertJ's assertThat and Hamcrest's MatcherAssert.assertThat have different signatures, so import them explicitly and avoid wildcard-importing both. For a new project, standardizing on one (AssertJ) keeps tests consistent and readable.

When to pick each

Pick AssertJ when: you want the most readable, discoverable, and maintainable assertions — which is almost always for new code. The fluent chain, rich collection support, fluent exception testing, and best-in-class failure messages make it the default recommendation in 2026.

Pick JUnit 5 assertions when: you have a tiny test suite, want zero extra dependencies, or are doing the simplest equality/exception checks. They are always available and need no setup.

Pick Hamcrest when: you are maintaining an existing Hamcrest codebase, or a tool you use (e.g. some Spring or REST-assured paths) expects Hamcrest matchers and reusing them avoids churn. It is a legacy/compatibility choice rather than a greenfield one.

Verdict

AssertJ is the recommended assertion library for Java in 2026: the fluent API reads naturally, the IDE guides you to the right assertion, collection and exception testing are excellent, and the failure messages are the clearest of the three. Keep JUnit 5 assertions for trivial checks and as the always-available fallback. Reserve Hamcrest for legacy code and tools that expect its matchers. You can mix all three, but standardizing on AssertJ yields the most consistent, debuggable tests. Compare more tooling on our comparison hub, and browse installable Java testing skills in the skills directory.

Frequently Asked Questions

Is AssertJ better than Hamcrest?

For most teams in 2026, yes. AssertJ's fluent chain reads left to right, the IDE autocompletes the next assertion after a dot, and its failure messages are more descriptive. Hamcrest's matcher style reads right to left, nests awkwardly, and offers weaker autocomplete, which is why momentum has shifted toward AssertJ for new code.

Should I use JUnit assertions or AssertJ?

Use JUnit 5 assertions for trivial equality, truthiness, and exception checks when you want zero extra dependencies. Use AssertJ when you want readable chained assertions, rich collection support, fluent exception testing, and clearer failure messages. Many teams keep assertThrows from JUnit and use AssertJ for everything else.

Can I use AssertJ and Hamcrest in the same project?

Yes. They live in different packages and coexist freely, even in the same test class. Watch the static assertThat import collision — AssertJ's Assertions.assertThat and Hamcrest's MatcherAssert.assertThat have different signatures, so import them explicitly rather than wildcard-importing both.

How do I assert on a thrown exception with AssertJ?

Use assertThatThrownBy(() -> code()) and chain .isInstanceOf(SomeException.class), .hasMessageContaining("text"), and .hasCause(...). This lets you assert on the exception type, message, and cause in one fluent statement, which is more expressive than JUnit's assertThrows that returns the exception for separate assertions.

Does AssertJ support asserting on collections of objects?

Yes, and it is one of its strongest features. extracting(Type::field) pulls a property from each element, filteredOn(predicate) narrows the collection, and anySatisfy/allSatisfy run nested assertions per element. usingRecursiveComparison() compares objects field by field, which is far more concise than writing element-by-element checks manually.

Is Hamcrest still maintained in 2026?

Hamcrest still exists and is bundled or pulled in transitively by some tools, but its development is slow and the community has largely moved to AssertJ for fluent assertions. It remains a reasonable compatibility choice for existing Hamcrest codebases or tools that expect its matchers, but it is not the recommended default for new projects.

AssertJ vs Hamcrest vs JUnit Assertions (2026) | QASkills.sh