CodeGym /Courses /Spring Test /AssertJ: collections, objects, Optional

AssertJ: collections, objects, Optional

Spring Test
Level 3, Lesson 1
Available

1. Collections and nested objects in the backend

If until now you have been writing exercises in the spirit of “let’s check that 2 + 2 = 4”, then collections and nested objects may seem “complicated”. But in the backend this is not exotic at all, it is the basic norm: the API returns a list of articles, the service returns a set of attachments, the repository looks up an entity and returns “either found or not”. So tests almost immediately deal not with a single value, but with an entire data structure. And here it is especially important not to lose readability.

Imagine a typical piece of our ContentHub: the public article feed returns a page (page) with a list, the editor sees their own drafts as a list too, an article contains a nested category, and a rejected article has a rejection reason — in other statuses it may not exist at all. If you write assertions on such material using manual loops, indexes, and “let’s check the zero element”, the test quickly starts to resemble a cheap TV series: lots of episodes, little meaning, and the finale is unexpected and usually sad.

Here is an example of an “old-school” style that seems to work, but is hard to read and weak at explaining what exactly we want to prove:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class LegacyCollectionAssertionTest {

    @Test
    void slugs_are_checked_with_loop() {
        List<String> slugs = List.of("java-basics", "spring-intro");

        // We walk through the list manually: the test starts looking like an algorithm, not a rule.
        for (int i = 0; i < slugs.size(); i++) {
            // Bad signal: we compare to true instead of expressing the intent of the check.
            assertEquals(true, slugs.get(i).contains("-"));
        }
    }
}

Even if this test is green, it says almost nothing. Why is the hyphen important? Should the slug’ies be in a specific order? Can there be a space? We checked “something”, and a week later we ourselves will forget what exactly.

AssertJ lets you describe expectations closer to the meaning: instead of manually walking the collection, we formulate the rule it must satisfy. And that rule is visible right in the code:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class AssertJCollectionAssertionTest {

    @Test
    void all_slugs_look_like_urls() {
        List<String> slugs = List.of("java-basics", "spring-intro");

        assertThat(slugs)
                // Rule for each element of the collection: all slugs contain a hyphen.
                .allMatch(s -> s.contains("-"))
                // Another rule: all slugs do not contain spaces.
                .allMatch(s -> !s.contains(" "));
    }
}

The example now looks a little “toy-like”, but next we will move on to lists of objects and nested structures — and there the benefit will become even more obvious.

2. Collections: size, presence, order, contents

When you test a collection, the first thing to understand is what exact question you are asking. The beginner’s problem is usually not that they do not know the right AssertJ method, but that they try to answer several different questions with one method. A collection is a multi-layered thing: there is size, element presence, order, exact contents. If these meanings get mixed together, the test will either lie or fail for reasons unrelated to the actual bug.

It is convenient to keep a simple “question map” in your head. The same list can be checked with different methods, depending on the meaning:

What you want to prove (meaning) How it sounds in plain English Typical AssertJ method Comment for your brain
Size “How many elements?” hasSize(n), isEmpty() Size without contents proves almost nothing, but sometimes it is an important minimum contract.
Element presence “Is the required value there?” contains(x), containsAnyOf(...) Order is usually not important here.
Order + contents “The elements are exactly these and in this order” containsExactly(...) This is a strict check, good for deterministic results.
Exact contents without order “Exactly these elements, but order does not matter” containsExactlyInAnyOrder(...), containsOnly(...) Often this is more correct than trying to guess the order.

Now let’s look at a few small examples from the ContentHub domain. Let’s start with the simplest case: a list of publication statuses in our workflow. If order matters (for example, we are checking a transition chain), use a strict ordered assertion:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class StatusOrderTest {

    @Test
    void status_flow_has_expected_order() {
        List<String> flow = List.of("DRAFT", "IN_REVIEW", "PUBLISHED");

        assertThat(flow)
                // Size is a supporting check: useful, but by itself it proves very little.
                .hasSize(3)
                // And this is the contract for order and contents.
                .containsExactly("DRAFT", "IN_REVIEW", "PUBLISHED");
    }
}

If order is not part of the contract (for example, we just received a set of categories, and the service does not promise sorting), it is better not to pretend that the order is stable. Then the test will check the contents, but will not fail because of “noise”:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class CategoryCodesTest {

    @Test
    void category_codes_are_present_regardless_of_order() {
        List<String> codes = List.of("java", "spring", "testing");

        assertThat(codes)
                .containsExactlyInAnyOrder("java", "spring", "testing");
    }
}

A very typical real-world story: people write containsExactly, the tests are green, then the sorting in the implementation changes (or the database starts returning elements differently), and the tests fail. Developers start “fixing” the tests, although there is no real bug. It is like complaining to the weather because it does not read your weekend plan.

Presence checks also come in different forms. Sometimes we care about one specific element, and sometimes we care that something forbidden is not present in the list (for example, not showing articles in the DRAFT status in the public API). That is also expressed naturally:

import org.junit.jupiter.api.Test;

import java.util.List;

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

class PublicVisibilityTest {

    @Test
    void public_list_does_not_contain_drafts() {
        List<String> statuses = List.of("PUBLISHED", "PUBLISHED", "ARCHIVED");

        assertThat(statuses)
                // Check that the forbidden status is absent (the semantic part of the scenario).
                .doesNotContain("DRAFT")
                // And minimally ensure that published items are actually present.
                .contains("PUBLISHED");
    }
}

Notice a small rule: do not turn one test into a vacuum cleaner for all possible collection checks. If the scenario is about public visibility, then “no drafts” and “published items are present” is usually enough. Checking size, order, and every value by index is already test perfectionism that does not pay off.

3. Lists of objects: extracting and tuple

So far we have been checking collections of strings. But in ContentHub, collections almost always consist of objects: articles, attachments, categories. And here a beginner is often tempted: “Okay, I’ll do a for loop and inside I’ll call assertThat(...).isEqualTo(...)”. It will work. It will not always read well. And most importantly, indexes will appear very quickly, and the test will turn into a quest: “guess where which article is in the list”.

AssertJ provides an excellent tool for such cases — extracting. The idea is simple: we take a list of objects and “extract” from each object only the field — or several fields — that matter in the scenario. After that, we get a collection of simple values and verify it with the usual methods containsExactly, contains, containsOnly, and so on.

To avoid depending on real production code right in the lecture text, let’s create a tiny article model. It is very similar to the ContentHub domain, but small and clear:

import java.util.Optional;

record Category(String code) { }

record Article(String slug, String title, Category category, Optional<String> rejectionReason) { }

Now an example: we have a list of articles, and we want to prove that the feed contains specific slug values in a specific order. Instead of a loop, we use extracting(...):

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class ExtractingSingleFieldTest {

    @Test
    void slugs_are_returned_in_expected_order() {
        List<Article> articles = List.of(
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty()),
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty())
        );

        assertThat(articles)
                // "Extract" only slug from each Article — then we compare a list of strings.
                .extracting(Article::slug)
                .containsExactly("java-basics", "spring-intro");
    }
}

The main thing here is readability: the test literally says “these articles have these slugs”. No indexes, no articles.get(0), no manual logic.

A bit more complex: we want to verify not one field, but a pair of fields. For example, we want to make sure that each slug matches the correct category. For this it is convenient to use tuple(...). In essence, we are saying: “we have a table with two columns, let’s compare the rows of this table.” For a test this is a very convenient form, because it remains about the meaning, not the path to the value.

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.groups.Tuple.tuple;

class ExtractingMultipleFieldsTest {

    @Test
    void slug_and_category_are_consistent() {
        List<Article> articles = List.of(
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty()),
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty())
        );

        assertThat(articles)
                // Extract two "columns" at once: slug and category code (nested object).
                .extracting(Article::slug, a -> a.category().code())
                // Compare as a table: each row is tuple( slug, categoryCode ).
                .containsExactly(
                        tuple("java-basics", "java"),
                        tuple("spring-intro", "spring")
                );
    }
}

Notice a -> a.category().code(): this is exactly the case where a nested object becomes part of the check, but we do not start poking into it manually with a loop.

Sometimes it is useful to use extracting not to compare “all elements”, but to check a meaningful subset. For example, the editor sees their articles in arbitrary order, and what matters is that among them there is an article with the required slug. Then contains becomes the ideal form:

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class ContainsByExtractingTest {

    @Test
    void editor_articles_contain_expected_slug() {
        List<Article> articles = List.of(
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty()),
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty())
        );

        assertThat(articles)
                .extracting(Article::slug)
                .contains("java-basics");
    }
}

An important nuance: when you write extracting(...), you are effectively declaring which fields are important for the scenario. This protects you from unnecessary brittleness. If tomorrow Article gets an updatedAt field, the test will not break — because we did not compare the whole object, we only checked the fields significant for the scenario.

4. Nested data: checking what matters

Nested objects are the place where tests often start to “grow”. At some point you have an article, it has a category, the category has a code and a name, the article has attachments, the attachment has a file name… and the test suddenly turns into an essay on the topic “list all the fields of the object”. Almost always this is unnecessary. A good test does not have to prove that the object is “fully correct”; it must prove what matters in the specific scenario.

To illustrate, let’s add attachments to our mini-model:

import java.util.List;
import java.util.Optional;

record ArticleAttachment(long id, String originalFilename) { }

record ArticleWithAttachments(
        String slug,
        Category category,
        List<ArticleAttachment> attachments,
        Optional<String> rejectionReason
) { }

Now suppose we are checking that an article belongs to the java category, and that it has two attachments with the expected file names. We can write this as directly as possible, without magic:

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class NestedObjectAssertionTest {

    @Test
    void article_has_category_and_attachments() {
        var article = new ArticleWithAttachments(
                "java-basics",
                new Category("java"),
                List.of(new ArticleAttachment(10L, "intro.pdf"), new ArticleAttachment(11L, "code.zip")),
                Optional.empty()
        );

        // Check the nested object precisely: what matters to us is the category code.
        assertThat(article.category().code()).isEqualTo("java");

        // For attachments we check only file names and do not tie ourselves to order.
        assertThat(article.attachments())
                .extracting(ArticleAttachment::originalFilename)
                .containsExactlyInAnyOrder("intro.pdf", "code.zip");
    }
}

Two things matter here.

First: a nested object can be checked as a separate entity, but usually one or two characteristics are enough. We checked code, because in ContentHub the category code is a stable thing that participates in filtering and test data.

Second: a nested list of attachments is again a collection, and the same four questions apply to it. In our scenario, the attachment order is usually not part of the contract, so we used containsExactlyInAnyOrder(...).

Sometimes you want to compare the object as a whole. That can be appropriate, but with caution. AssertJ has a powerful feature usingRecursiveComparison(), which walks through the object fields and compares them recursively. This is convenient, but easy to overuse: the test starts failing because of every new field that does not affect the scenario, and you end up fixing tests instead of logic. So the rule is simple: recursive comparison is like hot sauce. In small amounts it improves the dish, in large amounts you cry and do not understand why.

An example of “careful” use: we compare the expected and actual article, but consciously ignore the field that is not related to the scenario (for example, id):

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class RecursiveComparisonTest {

    @Test
    void article_is_equal_except_id() {
        var expected = new ArticleWithAttachments(
                "java-basics",
                new Category("java"),
                List.of(new ArticleAttachment(0L, "intro.pdf")),
                Optional.empty()
        );

        var actual = new ArticleWithAttachments(
                "java-basics",
                new Category("java"),
                List.of(new ArticleAttachment(10L, "intro.pdf")),
                Optional.empty()
        );

        assertThat(actual)
                // Recursively compare all fields (including nested objects and lists).
                .usingRecursiveComparison()
                // But ignore what is generated/not important for the scenario (attachment id).
                .ignoringFields("attachments.id")
                .isEqualTo(expected);
    }
}

Yes, this is already a slightly more “adult” technique, but it is often used in real tests, especially when part of the data is generated automatically. The main thing is not to turn it into a universal hammer: if you need to prove “slug and category”, prove exactly that, not the whole universe at once.

5. Assertions for Optional

Optional is an honest admission in code: “the value may be absent, and that is not an error by itself.” In ContentHub, Optional will appear all the time. For example, an article search may return a result or not; a published article may have publishedAt, a draft may not; a rejected article has a rejectionReason, the others are empty. And tests should treat this as a normal part of the model, not as a “hindrance”.

The most common mistake is to call optional.get() right away. It is like opening a box without checking whether there is actually a gift inside. AssertJ provides more expressive checks that first confirm presence or absence, and then the value.

Let’s start with an empty Optional: an article is not found by slug (or the rejection reason is absent because the article was not rejected). We explicitly assert that:

import org.junit.jupiter.api.Test;

import java.util.Optional;

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

class OptionalEmptyTest {

    @Test
    void rejection_reason_is_empty_for_draft() {
        Optional<String> rejectionReason = Optional.empty();

        assertThat(rejectionReason).isEmpty();
    }
}

Now for the case where the value exists. We check not only its presence, but also that the value is exactly this one:

import org.junit.jupiter.api.Test;

import java.util.Optional;

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

class OptionalPresentTest {

    @Test
    void rejection_reason_is_present_for_rejected_article() {
        Optional<String> rejectionReason = Optional.of("too-short");

        assertThat(rejectionReason)
                .isPresent()
                .contains("too-short");
    }
}

At this level you can already write clear checks. But it gets even better when Optional contains not a string, but an object. For example, “we found the article”:

import org.junit.jupiter.api.Test;

import java.util.Optional;

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

class OptionalObjectTest {

    @Test
    void found_article_has_expected_slug() {
        Optional<Article> found = Optional.of(
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty())
        );

        assertThat(found)
                // First AssertJ checks that the Optional is not empty, and only then goes inside.
                .hasValueSatisfying(article ->
                        // Inside, you can make ordinary AssertJ checks on the object.
                        assertThat(article.slug()).isEqualTo("java-basics")
                );
    }
}

Notice how hasValueSatisfying reads: “has a value satisfying the conditions”. And inside, you can calmly make ordinary AssertJ checks on the object without pulling it out.

This is especially nice because the error message is usually more concrete. If the Optional is empty, AssertJ will say that explicitly. If the value exists but the slug is different, the failure will happen in the slug check, not somewhere in a NoSuchElementException from get().

6. Conditional checks: filteredOn and satisfy

Sometimes a collection needs to be checked not just by its contents, but by a rule that applies to all elements or to part of them. For example: “all articles in the public feed are published”, “at least one article belongs to the java category”, “all slugs do not contain spaces”, “there is at least one rejected article with a reason”. All of this can be written with a loop. It can. But the test will quickly become procedural. AssertJ knows how to express such rules declaratively.

allSatisfy is good when you want to apply several assertions to each element of a collection. This is especially useful when the check is about a format or an invariant:

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class AllSatisfyTest {

    @Test
    void all_articles_have_url_friendly_slugs() {
        List<Article> articles = List.of(
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty()),
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty())
        );

        assertThat(articles).allSatisfy(a -> {
            assertThat(a.slug()).doesNotContain(" ");
            assertThat(a.slug()).contains("-");
        });
    }
}

anySatisfy means “at least one”. It is convenient when you want to prove the existence of an element with a specific set of properties, but do not want to depend on order:

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class AnySatisfyTest {

    @Test
    void at_least_one_article_is_in_java_category() {
        List<Article> articles = List.of(
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty()),
                new Article("java-basics", "Java basics", new Category("java"), Optional.empty())
        );

        assertThat(articles).anySatisfy(a ->
                assertThat(a.category().code()).isEqualTo("java")
        );
    }
}

And filteredOn is a way to select a subset and then check that subset. When you read such a test, it looks like a “mini-query”: “filter by condition, then check what remains”. This helps when the rule applies not to all elements, but to a logical group:

import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

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

class FilteredOnTest {

    @Test
    void articles_with_rejection_reason_are_selected() {
        List<Article> articles = List.of(
                new Article("java-basics", "Java basics", new Category("java"), Optional.of("too-short")),
                new Article("spring-intro", "Spring intro", new Category("spring"), Optional.empty())
        );

        assertThat(articles)
                // Keep only the elements where the reason is actually present.
                .filteredOn(a -> a.rejectionReason().isPresent())
                // Check that one such value remains after filtering.
                .hasSize(1);
    }
}

Here the selection criterion is simply the presence of rejectionReason. When the rule also involves the article status, the technique does not change: first select the needed subset, then verify it.

It is important to remember: these methods are not for “beauty for beauty’s sake”. They are there so the test reads like a rule, not like an algorithm. As soon as your test starts to look like a small program with branches and temporary variables, it almost always becomes less reliable and explains failures worse.

7. Typical mistakes when working with AssertJ

Mistake #1: checking a collection only by size.
hasSize(3) is sometimes needed, but often it is a very weak proof. A collection of three elements can be completely wrong in meaning. Usually size is a supporting check, and the main checks should fix the contents, the presence of key elements, or the invariants that matter for the scenario.

Mistake #2: use contains(...) when order actually matters.
If the scenario depends on sorting or the sequence of statuses, contains(...) proves nothing about order. Such a test will be green even if the order is broken, while you were trying to protect the sorting. In these cases, it is more honest to write containsExactly(...) right away and explicitly fix the order as part of the contract.

Mistake #3: use containsExactly(...) when order is actually not promised.
The opposite problem: you accidentally “nailed down” an order that is not part of the requirements. Such a test becomes brittle and starts failing on harmless refactorings, a change in the default sorting, or a switch to another implementation. If order is not important, prefer containsExactlyInAnyOrder(...) or a check for key elements.

Mistake #4: write manual loops and indexes where extracting(...) exists.
A loop in a test almost always hides the meaning of the check. It forces the reader to “execute” the code in their head instead of simply reading the expectation. extracting(...) usually gives a shorter, more honest, and more concrete test, especially when the collection contains objects.

Mistake #5: call optional.get() without checking.
This is a classic: the test fails with NoSuchElementException, and then you spend a long time figuring out why the Optional was empty. AssertJ provides expressive checks isEmpty(), isPresent(), contains(...), hasValueSatisfying(...). With them, the failure will be concrete: “Optional was empty”, not “something inside threw an exception”.

1
Task
Spring Test, level 3, lesson 1
Locked
Article collection and `extracting(...)`
Article collection and `extracting(...)`
1
Task
Spring Test, level 3, lesson 1
Locked
`Optional` for the Rejection Reason
`Optional` for the Rejection Reason
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION