For AI agents: the complete documentation index is available at https://nhanbe.id.vn/llms.txt, the full documentation bundle is available at https://nhanbe.id.vn/llms-full.txt, and this page is available as Markdown at https://nhanbe.id.vn/aem/advanced/unit-testing-junit-mockito-and-aem-mocks.md.

Unit Testing: JUnit, Mockito and AEM Mocks

27 – Unit Testing: JUnit, Mockito and AEM Mocks

Target: 3–4 years experienced AEM Developer Not one single syllabus point — the practical skill behind several of them. Files 05, 06, 07, 09 and 10 each closed with a chunk of test code, because "how do you unit test a Sling Model / a service / a servlet / a process step / a job consumer" is genuinely one of the most common follow-ups in an AEM interview. This file is where all of that gets built up properly, from the Java fundamentals underneath it. Project domain: a global energy technology company's marketing site.


Before we start — why this file exists, and why it's slow on purpose

You've already seen test code five times in this repository. Every one of those files assumed you could read @Test, assertEquals, Mockito.mock() and AemContext without a detour. That was a fair assumption for the AEM-specific parts, but if your Java is still a work in progress, those five test classes were probably the parts you skimmed.

This file is the detour. It builds JUnit and Mockito up from the actual question — what is a unit test, and what is it doing for you — before it gets anywhere near AEM. Once that foundation is solid, AemContext stops looking like a wall of unfamiliar method calls and starts looking like what it is: a small, fast, fake AEM instance that you can create and throw away thousands of times a day.

Why this matters more than it looks like it should, for your specific situation. You are moving from support to development. Support work is reactive — you find out something is broken because a ticket says so. Development work has a chance to be proactive — you find out something would have broken before it ships, because a test told you. Being fluent in this file is one of the fastest ways to make that shift visible to an interviewer, because it is a skill you can demonstrate in five minutes of live coding, which very few other AEM topics allow.

And a second reason it matters for this specific job market. At Indian service companies working AEM Developer roles, Cloud Manager's quality gate genuinely blocks a pipeline on failing tests and on insufficient coverage of new code (file 14). "I can write a Sling Model" is now table stakes. "I can write a Sling Model and the test that proves it handles a half-filled dialog" is what a 3-4 year developer is expected to produce without being asked twice.

So: slow down here if you need to. Every later section assumes the vocabulary in sections 1 and 2 is solid.


1. Introduction

1.1 What a unit test actually is

Strip away the tooling and a unit test is a small, boring idea:

A unit test is a piece of code that runs a small piece of your other code, and automatically checks whether the result is what you expect.

That's it. No repository, no browser, no AEM instance. You call a method with a known input, and you assert that what came back is what should have come back. If it isn't, the test fails loudly, right now, on your machine, instead of quietly, three weeks from now, in production.

A support-desk analogy, since that's where you're coming from. Think about the last time a recurring incident got a permanent fix instead of a workaround. Usually what happened is someone wrote down the exact steps that reproduced it, so that after the fix shipped, anyone could run those same steps and confirm it stayed fixed. A unit test is that reproduction case, except it runs itself, every time, in milliseconds, without a human remembering to check.

A "unit" is deliberately small. One method, one class, one decision — not a whole page render, not a whole workflow. The moment a test needs a running AEM instance, a real repository, or a browser, it has stopped being a unit test and become an integration test or a UI test, which are different tools with different jobs (section 2.13 and file 14 come back to this).

1.2 What a unit test is actually for — two jobs, not one

Job one — a regression net. Once a test exists and passes, it keeps passing automatically, forever, every time anyone runs the build. If someone touches the code six months from now and breaks the behaviour the test describes, the test fails immediately, in their own build, before it reaches a reviewer or a QA pass. That is the job most people already know about.

Job two, and the one that surprises people — a design pressure. Code that is hard to unit test is very often badly structured, and the difficulty of writing the test is the first symptom of that, arriving before anything else notices. This is not a coincidence and it is worth being able to say in an interview.

Recall file 05's opening example — the old way of reading two properties needed a live Node, which needed a live JCR session, which meant you could not test it without a running repository. The Sling Model version needed neither. The same change that made the code shorter and safer also made it trivially testable, and that is not two separate improvements — testability was a symptom of the better design, not an added feature bolted onto it.

"A unit test isn't just insurance against regressions, though it is that. It's also a forcing function — if a class is hard to test, that's usually telling you it's reaching for too many things at once: a live repository, a request, a static singleton. The Sling Model story from file 05 is exactly this: moving off the raw JCR API made the code both shorter and testable, in the same change, because they were the same problem."

1.3 The shape every test has: Arrange, Act, Assert

Nearly every unit test — in any language, on any framework — follows the same three-part shape, usually abbreviated AAA:

@Test
void discountAppliesAboveThreshold() {

    // ARRANGE — set up the world the test needs
    ShoppingCart cart = new ShoppingCart();
    cart.addItem("TX-4000", 250.00);

    // ACT — do the one thing being tested
    double total = cart.getTotalWithDiscount();

    // ASSERT — check the result is what it should be
    assertEquals(225.00, total, 0.01);
}

Arrange builds the inputs and any collaborators the code under test needs. Act calls the one method being tested — ideally exactly one line. Assert checks the outcome.

Why keeping these separate matters, beyond tidiness. A test with the act and the assert tangled together is hard to read six months later, and a test that arranges too much is usually testing more than one thing at once — which means when it fails, you don't immediately know which part broke. Keeping the shape visible is what makes a failing test a fast diagnosis instead of a second investigation.

Every test example in this file, and in files 05, 06, 07, 09 and 10, follows this shape whether or not the comments say so. Once you can see it, test code stops looking like a wall of unfamiliar syntax and starts looking like three short, ordinary paragraphs.

1.4 A real project example to adapt

"On our project every Sling Model, every OSGi service, and every servlet has a corresponding JUnit 5 test using AEM Mocks — that's io.wcm.testing.aem-mock. We don't chase a coverage percentage; the Cloud Manager quality gate checks coverage on new code, but the tests we actually value are the ones covering the cases authors and users create by accident — a half-filled dialog, a missing payload, a service that's down. We keep a shared library of JSON content fixtures under src/test/resources, exported from CRXDE, so writing a new model test is mostly about picking or extending a fixture rather than building content by hand in code."

That covers the tooling, the philosophy on coverage, and the fixture workflow — three likely follow-ups, pre-empted.


2. Core Concepts

2.1 JUnit 5 — the annotations that structure a test class

JUnit is the framework that discovers your test methods, runs them, and reports which passed and which failed. Version 5 — often called JUnit Jupiter — is what current AEM projects use, and its annotations live in org.junit.jupiter.api.

@Test marks a method as a test. JUnit finds every @Test -annotated method in a class and runs each one as its own independent test.

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

class DiscountCalculatorTest {

    @Test
    void appliesTenPercentAboveThreshold() {
        assertEquals(225.00, DiscountCalculator.apply(250.00), 0.01);
    }
}

@BeforeEach runs before every single @Test method in the class. This is where you rebuild anything the tests need fresh, so one test's leftovers can never leak into the next.

@AfterEach runs after every test — used far less often, mostly to release something a test opened by hand (a temp file, a manually created resolver).

import org.junit.jupiter.api.BeforeEach;

class DiscountCalculatorTest {

    private ShoppingCart cart;

    @BeforeEach
    void setUp() {
        // A FRESH cart before every test. If tests shared one instance,
        // a test that added an item would leak into the next test's
        // "empty cart" assumption -- a classic flaky-test cause.
        cart = new ShoppingCart();
    }

    @Test
    void emptyCartHasZeroTotal() {
        assertEquals(0.0, cart.getTotal());
    }
}

Why @BeforeEach rather than doing setup once for the whole class: JUnit 5 creates a new instance of the test class for every test method by default. That is deliberate — it is what guarantees tests cannot see each other's state. @BeforeEach fits that model: fresh object, fresh setup, every time.

@DisplayName gives a test a human-readable name for test reports, independent of the method name:

@Test
@DisplayName("returns an empty list when the author added no FAQ rows")
void returnsEmptyListWhenAuthorAddedNoRows() { ... }

This is cosmetic but genuinely useful once a test suite has hundreds of methods and a build report needs to be skimmed at speed.

@Nested groups related tests inside an inner class, which is a way of expressing "these five tests all concern this one scenario" without fifty flat, similarly-named methods:

class FaqModelTest {

    @Nested
    class WhenNoRowsExist {
        @Test
        void listIsEmpty() { ... }

        @Test
        void isReadyReturnsFalse() { ... }
    }

    @Nested
    class WhenOneRowIsIncomplete {
        @Test
        void incompleteRowIsFiltered() { ... }
    }
}

@BeforeEach methods in an outer class still run before every @Test in a nested class — nesting groups tests for readability, it doesn't change the lifecycle.

@ParameterizedTest runs the same test body multiple times with different inputs, which avoids five nearly-identical test methods that differ only in one number:

import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

@ParameterizedTest
@CsvSource({
        "0, false",     // zero: not eligible
        "99, false",    // just under
        "100, true",    // exactly at threshold
        "250, true"     // well over
})
void discountEligibility(double amount, boolean expectedEligible) {
    assertEquals(expectedEligible, DiscountCalculator.isEligible(amount));
}

@ValueSource(strings = {...}) or @ValueSource(ints = {...}) covers the simpler case of one parameter; @MethodSource points at a method that supplies more complex objects when a CSV row isn't expressive enough. The value of a parameterized test is that boundary conditions — zero, one below a threshold, exactly at a threshold — get tested explicitly and visibly, rather than being implied by a single happy-path number.

2.2 Assertions — how a test actually decides pass or fail

All of the standard assertions live in org.junit.jupiter.api.Assertions, and are almost always static-imported so call sites read cleanly:

import static org.junit.jupiter.api.Assertions.*;
AssertionChecksTypical use
assertEquals(expected, actual)Two values are equalThe overwhelming majority of assertions
assertTrue(condition) / assertFalse(condition)A booleanisReady(), isValid() flags
assertNull(value) / assertNotNull(value)Presence or absenceConfirming adaptTo succeeded or failed
assertThrows(Type.class, () -> {...})An exception is thrown, and of the right typeA method that must reject bad input
assertAll(() -> ..., () -> ...)Several assertions, and reports every failure, not just the firstChecking several fields of one object
assertSame(a, b) / assertNotSame(a, b)Reference identity (==), not equalityRare — confirming the exact same object, e.g. caching

assertEquals on a double needs a delta, because floating-point comparisons are not exact:

assertEquals(225.00, total, 0.01);   // equal within 0.01

assertThrows is how you test that bad input is correctly rejected, which matters a great deal for the servlet and workflow sections later in this file:

@Test
void throwsWhenPayloadTypeIsWrong() {
    WorkflowException ex = assertThrows(WorkflowException.class,
            () -> process.execute(uuidPayloadWorkItem, session, metadata));

    assertTrue(ex.getMessage().contains("JCR_PATH"));
}

The lambda inside assertThrows is the act step; the assertion itself is JUnit checking that the act step threw, and threw the right type. If it throws a different type, or doesn't throw at all, the assertion fails.

assertAll deserves its own mention, because the instinct without it is to write several separate @Test methods, or several assertions in one test where only the first failure is ever reported (a stack trace stops execution at the first failed assertion). assertAll runs every one of its lambdas and reports every failure in one go:

@Test
void modelExposesAllExpectedFields() {
    CtaModel cta = adapt("/content/cta-full");

    assertAll(
        () -> assertEquals("Request a quote", cta.getLinkText()),
        () -> assertEquals("/content/energy/contact.html", cta.getLinkUrl()),
        () -> assertTrue(cta.isReady())
    );
}

If two of those three fail, the test report shows both — which saves a "fix one, rerun, find the next one" cycle.

2.3 JUnit 4 versus JUnit 5 — because older AEM projects use JUnit 4

Not every AEM codebase you will touch has been modernised. Older 6.x-era projects, and some archetypes generated a few years back, still use JUnit 4. Recognising it — and being able to say what changed — is a real interview question, not a formality.

The package changed entirely. JUnit 4 lives in org.junit; JUnit 5 lives in org.junit.jupiter.api. Seeing import org.junit.Test; versus import org.junit.jupiter.api.Test; is the fastest way to tell which one you're looking at.

JUnit 4JUnit 5
Test annotation@Test (org.junit.Test)@Test (org.junit.jupiter.api.Test)
Before each test@Before@BeforeEach
After each test@After@AfterEach
Before the whole class@BeforeClass (must be static)@BeforeAll (static, unless @TestInstance(PER_CLASS))
Running with an extension (e.g. Mockito)@RunWith(MockitoJUnitRunner.class)@ExtendWith(MockitoExtension.class)
Expecting an exception@Test(expected = MyException.class)assertThrows(MyException.class, () -> {...})
Assertions classorg.junit.Assertorg.junit.jupiter.api.Assertions
Parameterized testsA separate, clunkier @RunWith(Parameterized.class)@ParameterizedTest — much more ergonomic
Nested/grouped testsNot supported@Nested

The one worth walking through, because it's a genuine improvement, not just a rename: JUnit 4's @Test(expected = ...) fails the whole test if the exception is thrown on the wrong line — you can't assert anything about the exception itself, and you can't check what happened before the throwing line still ran correctly. assertThrows isolates exactly the one call expected to throw, gives you the exception object back to inspect (its message, its cause), and lets you assert on state before and after it separately.

// JUnit 4 -- the whole method must throw somewhere. No inspection.
@Test(expected = WorkflowException.class)
public void throwsOnWrongPayloadType() throws WorkflowException {
    process.execute(uuidWorkItem, session, metadata);
}

// JUnit 5 -- isolates the one call, and you get the exception to inspect
@Test
void throwsOnWrongPayloadType() {
    WorkflowException ex = assertThrows(WorkflowException.class,
            () -> process.execute(uuidWorkItem, session, metadata));
    assertTrue(ex.getMessage().contains("JCR_PATH"));
}

The interview answer:

"JUnit 4 is org.junit, @Before / @After, and @RunWith for extensions like Mockito. JUnit 5 — Jupiter — is org.junit.jupiter.api, @BeforeEach / @AfterEach, and @ExtendWith. The change I'd actually highlight rather than just list is exception testing: JUnit 4's @Test(expected = ...) just says 'this method throws somewhere,' with no way to inspect the exception or check state around it. JUnit 5's assertThrows isolates exactly the call that should throw and gives you the exception back to make assertions on. If I open an older AEM project and see org.junit.Test and @RunWith, that tells me it's JUnit 4 before I've read a single test body."

2.4 Why you mock at all — isolating the thing under test

Here is the problem mocking solves, stated plainly before any Mockito syntax.

Say you're testing an OSGi service that calls another service:

public class OrderService {

    private final ProductDataService productDataService;

    public double calculateShippingCost(String productId) {
        Specification spec = productDataService.getSpecification(productId);
        return spec.getWeight() * 2.5;
    }
}

To test calculateShippingCost, you need a ProductDataService. But the real one calls an external API over HTTP. If your test uses the real one:

  • The test needs network access, and fails if the API is briefly down — for a reason that has nothing to do with your shipping calculation.
  • The test is slow — an HTTP round trip instead of a method call.
  • The test is testing two things at once: your calculation, and the API integration. If it fails, you don't immediately know which.

A mock is a fake stand-in for a collaborator, which you control completely. Instead of a real ProductDataService making a real HTTP call, you hand OrderService a fake one that you've told, in advance, exactly what to return.

ProductDataService fakeService = Mockito.mock(ProductDataService.class);
Mockito.when(fakeService.getSpecification("TX-4000"))
       .thenReturn(new Specification(120.0));   // told what to return

OrderService orderService = new OrderService(fakeService);
double cost = orderService.calculateShippingCost("TX-4000");

assertEquals(300.0, cost, 0.01);

Now the test is fast, has no network dependency, and tests exactly one thing: the shipping calculation. If it fails, the bug is in calculateShippingCost, full stop — the mock cannot be the cause, because it did precisely what it was told to do.

"The point of mocking is isolation. When I'm testing one class, I don't want a failure somewhere in one of its collaborators to also fail this test — that makes debugging slower and the test suite less trustworthy, because a failure could mean 'my code is broken' or 'something three services away is having a bad day,' and you can't tell which from the test report alone. A mock removes that ambiguity: it does exactly what I told it to do, so if the test fails, the bug is in the class under test."

2.5 Mockito basics — the vocabulary

Mockito.mock(SomeClass.class) creates a fake instance of that type. Every method on it, by default, does nothing and returns a default value (null, 0, false, or an empty collection) until you tell it otherwise.

when(...).thenReturn(...) — tell a mock what to return when a specific method is called with specific arguments:

when(productDataService.getSpecification("TX-4000"))
        .thenReturn(new Specification(120.0));

verify(...) — after the act step, confirm a method on the mock was actually called, and how many times:

verify(analyticsService).trackLookup("TX-4000");           // called exactly once
verify(analyticsService, times(2)).trackLookup("TX-4000"); // called exactly twice
verify(analyticsService, never()).trackLookup("TX-9999");  // never called with this argument

This is the tool for testing side effects — things a method does that don't show up in its return value. assertEquals checks what came back; verify checks what happened along the way.

doThrow(...).when(mock).method(...) — make a mock throw an exception instead of returning normally, which is how you test failure handling without needing a real failure to occur:

doThrow(new SocketTimeoutException()).when(productDataService).syncProduct(anyString());

Note the order is reversed from when(...).thenReturn(...) — this is a Mockito quirk worth knowing rather than fighting: doThrow is used for void methods, because you cannot call when(voidMethod()) — there is no return value to hang the stub off. The do...().when(mock).... form works for any method, void or not, and is the one to reach for whenever a stub needs to throw.

any(), anyString(), eq(...) — argument matchers, used when the exact argument doesn't matter, or when mixing a matcher with a literal value:

when(productDataService.getSpecification(anyString()))
        .thenReturn(new Specification(100.0));   // any product ID returns this

// mixing a matcher with a literal argument requires EVERY argument to be a matcher
verify(auditLog).record(eq("lookup"), anyString(), any(Calendar.class));

The rule to remember: if you use one matcher (any(), anyString(), eq()) in a call, every argument in that call must be a matcher — you cannot mix a raw literal with a matcher in the same call. This causes a genuinely confusing InvalidUseOfMatchersException the first few times it happens.

ArgumentCaptor — for when you need to inspect what was passed to a mock, not just confirm it was called:

ArgumentCaptor<Specification> captor = ArgumentCaptor.forClass(Specification.class);
verify(cache).put(eq("TX-4000"), captor.capture());

Specification captured = captor.getValue();
assertEquals(120.0, captured.getWeight(), 0.01);

This matters when a method builds an object internally and hands it to a collaborator — verify alone can confirm the call happened, but only the captor lets you assert on the contents of what was actually built and passed.

2.6 @Mock, @InjectMocks, and MockitoExtension

Writing Mockito.mock(X.class) for every collaborator, in every test, gets repetitive. Mockito's annotations remove the ceremony:

import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private ProductDataService productDataService;   // Mockito creates this

    @InjectMocks
    private OrderService orderService;                // and wires it in here

    @Test
    void calculatesShippingFromWeight() {
        when(productDataService.getSpecification("TX-4000"))
                .thenReturn(new Specification(120.0));

        assertEquals(300.0, orderService.calculateShippingCost("TX-4000"), 0.01);
    }
}

@ExtendWith(MockitoExtension.class) is what makes the @Mock and @InjectMocks annotations actually do anything — without it, they're just unread labels, exactly like an unread @ValueMapValue on a class Sling never scans (file 05, section 1.3). The extension is what reads them and does the wiring, before each test runs.

@Mock creates a mock of the annotated type — equivalent to calling Mockito.mock(ProductDataService.class) yourself, but without the boilerplate, and re-created fresh before every test automatically.

@InjectMocks creates a real instance of the class under test, and tries to inject the @Mock -annotated fields into its constructor, setters, or fields — whichever it can find. It is convenient, but has a real limitation worth knowing: it works by best-effort reflection, and it fails silently if it can't figure out how to wire something. For a class with one obvious constructor taking exactly the mocked types, it works well. For anything more elaborate, constructing the object yourself in @BeforeEach is more reliable and, honestly, clearer to read.

"I use @Mock and @InjectMocks for straightforward cases — a class with one constructor and a couple of collaborators. For anything with multiple constructors, optional dependencies, or fields that need more than mocks, I construct the object explicitly in @BeforeEach instead, because @InjectMocks failing to wire something doesn't give you an error — it just leaves that field null, and you find out from a confusing NullPointerException three lines into your test rather than at setup."

2.7 When not to mock

Mocking is powerful, and that makes it easy to reach for by default. Two situations where it actively hurts:

One — don't mock what you don't own, when a real, fast, in-memory alternative exists. Mocking ResourceResolver, ValueMap, or a whole Resource tree by hand — stubbing getResource(), getValueMap(), getChild() one call at a time — produces a test that is both enormous and lies about what it's testing, because you've effectively re-implemented a fake JCR by hand, one when() at a time, and every one of those stubs is an assumption you could get subtly wrong. This is exactly the gap AEM Mocks fills (section 2.9) — it provides a real, if lightweight, resource tree, so you assert against actual content instead of a hand-stubbed approximation of it.

Two — over-mocking makes a test meaningless. If a test mocks so much that the only code path actually being exercised is a single if statement, ask whether the test is verifying real behaviour or just verifying that Mockito was configured the way you configured it. A test that mocks every single collaborator's every single method, and then asserts that a mocked method was called, is close to asserting that Mockito works — which it does; that isn't the risk you're trying to cover.

"Two things I actively avoid. First, hand-mocking things like Resource or ValueMap node by node — it's a lot of brittle setup and it's really just a worse, hand-built version of what AEM Mocks already gives you for free. Second, mocking so many collaborators that the test only exercises a couple of lines of real logic — at that point I'd rather ask whether the class under test has taken on too many dependencies, because that's usually the actual problem the awkward test is pointing at."

2.8 What AEM Mocks actually is

Everything so far — JUnit, Mockito — is plain Java, useful for any project. AEM Mocks, the library io.wcm.testing.aem-mock, is the AEM-specific piece: it gives you a lightweight, in-memory stand-in for a running Sling and AEM instance, so code that would normally need a real repository, a real request, and real OSGi container can be tested without any of them.

What it provides, concretely:

  • An in-memory content repository you can load test content into.
  • A working adaptTo mechanism, so resource.adaptTo(MyModel.class) genuinely runs the Sling Models framework, injectors and all.
  • A mock request and response, close enough to the real Sling API that servlet code runs against them unmodified.
  • A tiny OSGi-like container, so you can register your own services and activate real @Component classes with real configuration, exactly as file 06 exercises.
  • Mock Page and PageManager objects, so @ScriptVariable private Page currentPage; resolves to something real.

The one thing it deliberately does not give you is a full AEM instance — no author UI, no real JCR indexing, no dispatcher, no actual HTTP layer. That is the entire point: it is fast — a whole test class runs in well under a second — precisely because it isn't the real thing. Trading that realism away is what makes it possible to run thousands of these tests on every commit.

2.9 AemContext and AemContextExtension

AemContext is the object that holds this fake AEM world for one test. You typically create one as a field:

@ExtendWith(AemContextExtension.class)
class FaqModelTest {

    private final AemContext context = new AemContext();

    // ...
}

@ExtendWith(AemContextExtension.class) does for AemContext what MockitoExtension does for @Mock — it hooks into the JUnit 5 lifecycle so the context is properly reset between tests. Combined with JUnit 5 creating a fresh test class instance per test method (section 2.1), this means context is genuinely new for every single test — no leftover content or registered services can leak from one test into the next.

AemContext can be constructed with a specific resource resolver type if a test needs a closer-to-real repository — for instance, genuine JCR query behaviour that a plain in-memory map can't reproduce. Most Sling Model, service, and servlet tests never need this; the default lightweight resolver is enough, because you're testing your own logic, not Oak's indexing.

2.10 context.load().json(...) — loading test content, and how to get the fixture

context.load().json(...) reads a JSON file from your test resources and creates the equivalent content tree inside the mock repository, at the path you specify:

context.load().json("/faq/content.json", "/content/faq");

That one line replaces manually building a Resource tree node by node — which is exactly the hand-mocking this file warned against in section 2.7.

Where does the JSON fixture come from? The practical, everyday technique is to export it from real content, rather than writing it by hand:

  1. Author the content you want to test — a component instance, filled in the way you want to test (empty, partial, fully filled).
  2. In CRXDE Lite, or simply in a browser, request that node's path with a .json (or .infinity.json for the full subtree, unlimited depth) suffix — for example /content/energy/global/en/products/jcr:content/root/faq.infinity.json.
  3. Save the response body as a file under src/test/resources.
  4. Trim it down to what the test actually needs — a fixture with forty irrelevant properties is harder to read than one with exactly the five the test cares about, and every property in there is one more thing a future reader has to figure out the relevance of.
{
  "jcr:primaryType": "nt:unstructured",
  "sling:resourceType": "energy/components/faq",
  "heading": "Frequently asked questions",
  "faqs": {
    "jcr:primaryType": "nt:unstructured",
    "item0": {
      "jcr:primaryType": "nt:unstructured",
      "question": "What is a transformer in electricity?",
      "answer": "<p>It changes the voltage level...</p>"
    },
    "item1": {
      "jcr:primaryType": "nt:unstructured",
      "question": "What do electrical transformers do?",
      "answer": "<p>They transfer energy between circuits...</p>"
    }
  }
}

Keep a small library of fixtures, one per meaningful scenario — content.json for the full case, content-empty.json for nothing configured, content-partial.json for a half-filled row — rather than one enormous fixture with every case buried inside it. Section 8 and file 05 both lean on exactly this pattern.

2.11 context.addModelsForClasses(...) — and the nested-model trap

context.addModelsForClasses(...) registers your @Model -annotated classes with the mock Sling Models framework, so resource.adaptTo(MyModel.class) actually works inside the test — mirroring the real Sling-Model-Packages scanning from file 05.

context.addModelsForClasses(FaqModel.class, FaqItemModel.class);

The trap, already flagged in file 05 and worth repeating here because it is exactly the kind of thing that costs twenty confused minutes: if FaqModel uses @ChildResource to inject a List<FaqItemModel>, then FaqItemModel must also be registered, or every entry in that list comes back null (or the list itself comes back empty, depending on how the injection is written) — with no error telling you why. The parent model adapts fine; only the nested adaptation silently fails, because as far as the mock Sling Models framework is concerned, FaqItemModel was never a class it knows how to build. Registering only the model you're directly calling adaptTo on, and forgetting every nested one it depends on, is one of the most common AEM Mocks mistakes there is.

2.12 context.registerService(...) and context.registerInjectActivateService(...)

Two different tools for two different jobs, and mixing them up is a common source of confusing test failures.

context.registerService(SomeInterface.class, instance) puts an object straight into the mock service registry, with no lifecycle involved. Use this to supply a mock collaborator that some other component or model needs:

context.registerService(ProductDataService.class, Mockito.mock(ProductDataService.class));

The object you hand it can be anything — usually a Mockito mock, sometimes a small hand-written stand-in.

context.registerInjectActivateService(new RealImpl(), configMap), covered fully in file 06, does something different and more elaborate: it takes a real @Component -annotated class, injects its @Reference fields from whatever is already registered in the context, applies the configuration map as if it came from an @ObjectClassDefinition, and calls @Activate — genuinely running your component's real activation code:

ProductDataService service = context.registerInjectActivateService(
        new ProductDataServiceImpl(), config);

The rule for choosing between them: if you want a fake stand-in for a dependency your code needs, use registerService. If the thing you are actually testing is an OSGi component itself — you want its real @Activate, its real @Reference injection, its real business logic running — use registerInjectActivateService. Confusing the two is why "I called registerService on the class I'm trying to test and nothing worked" is a real recurring mistake — registerService never calls @Activate, so none of the component's setup ever ran.

2.13 Request, response, and page objects for servlet and model tests

For anything that needs a request context — servlets (file 07) and request-adaptable Sling Models (file 05) — AemContext exposes the pieces:

context.currentResource("/content/energy/global/en/products/tx-4000");
context.requestPathInfo().setSelectorString("specs");
context.requestPathInfo().setExtension("json");

servlet.doGet(context.request(), context.response());

assertEquals(200, context.response().getStatus());

context.request() and context.response() are the mock SlingHttpServletRequest and SlingHttpServletResponse — real enough that a servlet's doGet / doPost can be called on them directly and behave as it would in production.

context.currentResource(path) sets what the request is "visiting" — the equivalent of a browser hitting that URL.

context.requestPathInfo() gives you a mutable view to set selectors, extension and suffix — exactly the request-shape details a servlet often branches on.

context.currentPage(path) sets the current page, for anything needing @ScriptVariable private Page currentPage; or a request-adaptable model that reads page context.

These pieces compose. A test for a request-adaptable Sling Model sets currentResource and currentPage, then adapts the request (not the resource) to the model class. A servlet test sets currentResource, requestPathInfo, and calls the servlet method directly. Same underlying context, assembled differently for what's under test.


3. Internal Working

3.1 What actually happens when a test runs — the full sequence

sequenceDiagram participant J as JUnit 5 runner participant E as AemContextExtension participant C as AemContext (mock repo, mock OSGi) participant T as Your test method participant M as Class under test J->>J: discover @Test methods J->>E: before each test method E->>C: reset / create the mock context J->>T: create a NEW test class instance T->>T: @BeforeEach runs (load().json, registerService,...) J->>T: run the @Test method T->>M: ARRANGE done, now ACT (call the method under test) M->>C: adaptTo() / @Reference lookups resolve against the mock context M-->>T: return value T->>T: ASSERT — compare actual vs expected J->>T: report pass or fail T->>T: @AfterEach runs, if present J->>E: after each test method — discard the context

Four points worth drawing out of that:

A fresh test instance, and a fresh context, for every single test method. That is what makes tests independent — nothing you did in one test's @BeforeEach or test body can be seen by another test, ever, by construction rather than by discipline.

@BeforeEach runs inside the new instance, every time, which is why it's the right place for setup rather than a constructor or a static field — a static field would be shared across every test in the class, silently reintroducing the shared-state problem @BeforeEach exists to prevent.

Everything your code does through adaptTo, @Reference, or the resource resolver resolves against the mock context, not against a real repository or a real OSGi container — which is the entire mechanism that makes this fast.

The assert step is where the test actually earns its keep. Everything before it is setup; a test with elaborate arrange and act steps but a trivial or missing assert has not actually tested anything.

3.2 How Mockito actually creates a "fake" object

Worth a mental model, because "how does mock() even work" is a real, if less common, interview question, and understanding it explains a real limitation.

Mockito.mock(SomeClass.class) generates a new class at runtime — a subclass (for a class) or an implementation (for an interface) that overrides every method to do nothing by default, and consult Mockito's internal bookkeeping for stubbed behaviour (when/thenReturn) or a return value to record (verify) whenever a method is actually called. This is done through bytecode generation, historically via a library called ByteBuddy (and CGLIB before that).

The practical consequence: Mockito needs to be able to generate a subclass of the type you're mocking. Interfaces are trivial. Concrete classes generally work too, with current Mockito's "inline mock maker," including final classes and methods — which used to be a real limitation in older Mockito versions and occasionally still trips people up on an older project pinned to an old Mockito release. A static method cannot be mocked this way at all — there is no instance to subclass — which is exactly why file 06's advice to depend on interfaces rather than concrete implementations pays off doubly: it is not just good OSGi hygiene, it is also what makes a collaborator trivially mockable.

3.3 What breaks if you get the fundamentals wrong

This section is the "why it matters" for everything above, made explicit.

Sharing state between tests (a static field, or resetting nothing in @BeforeEach) produces flaky tests — ones that pass or fail depending on what ran before them, and in which order. These are worse than a test that simply always fails, because they erode trust in the whole suite: a developer who sees an intermittent failure starts re-running the build instead of investigating, and eventually starts ignoring red builds altogether.

Testing implementation details instead of behaviour (asserting exactly how many times a private helper was called, rather than what the public method returned) produces tests that break every time you refactor, even when behaviour is unchanged — which teaches the team that tests are an obstacle rather than a safety net, and that lesson is very hard to unlearn once it takes hold.

Over-mocking, covered in section 2.7, produces tests that pass even when the real integration between two classes is broken, because neither ever spoke to a real version of the other.

Chasing a coverage percentage (section 4, advanced questions, and story 1) produces exactly the trap this file spends real time on: tests that execute a line without checking anything meaningful about it, which move the coverage number without moving the actual safety net at all.


8. Coding Examples

8.1 JUnit 5 fundamentals, in one file

package com.energy.core.util;

import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

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

@DisplayName("DiscountCalculator")
class DiscountCalculatorTest {

    private ShoppingCart cart;

    // Runs before EVERY @Test -- a fresh cart, so no test can see
    // another test's leftover items.
    @BeforeEach
    void setUp() {
        cart = new ShoppingCart();
    }

    @Test
    @DisplayName("an empty cart totals to zero")
    void emptyCartTotalsToZero() {
        assertEquals(0.0, cart.getTotal(), 0.01);
    }

    @Nested
    @DisplayName("when the cart is above the discount threshold")
    class AboveThreshold {

        @BeforeEach
        void addQualifyingItem() {
            cart.addItem("TX-4000", 250.00);
        }

        @Test
        void tenPercentIsApplied() {
            assertEquals(225.00, cart.getTotalWithDiscount(), 0.01);
        }
    }

    @ParameterizedTest
    @CsvSource({
            "0, false",
            "99.99, false",
            "100.00, true",
            "250.00, true"
    })
    @DisplayName("eligibility at and around the threshold")
    void discountEligibility(double amount, boolean expectedEligible) {
        assertEquals(expectedEligible, DiscountCalculator.isEligible(amount));
    }

    @Test
    void rejectsANegativeAmount() {
        // assertThrows isolates exactly this call, and hands back
        // the exception so we can inspect it -- not just "it threw something".
        IllegalArgumentException ex = assertThrows(
                IllegalArgumentException.class,
                () -> DiscountCalculator.apply(-10.0));

        assertTrue(ex.getMessage().contains("negative"));
    }

    @Test
    void reportsEveryFailedCheckAtOnce() {
        cart.addItem("TX-4000", 250.00);

        // assertAll runs every lambda and reports ALL failures together,
        // rather than stopping at the first one.
        assertAll(
                () -> assertEquals(1, cart.getItemCount()),
                () -> assertTrue(cart.getTotal() > 0),
                () -> assertFalse(cart.isEmpty())
        );
    }
}

8.2 JUnit 4 versus JUnit 5, side by side

// ---------- JUnit 4 (older AEM projects) ----------
package com.energy.core.util;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import static org.junit.Assert.assertEquals;

@RunWith(MockitoJUnitRunner.class)          // JUnit 4's way of enabling Mockito
public class DiscountCalculatorTestJUnit4 {

    private ShoppingCart cart;

    @Before                                  // NOT @BeforeEach
    public void setUp() {
        cart = new ShoppingCart();
    }

    @Test
    public void emptyCartTotalsToZero() {
        assertEquals(0.0, cart.getTotal(), 0.01);
    }

    @Test(expected = IllegalArgumentException.class)   // no exception INSPECTION
    public void rejectsANegativeAmount() {
        DiscountCalculator.apply(-10.0);
        // if this line is reached, the whole method failing to
        // throw is the only signal -- you can't check the MESSAGE
    }
}
// ---------- JUnit 5 (current) ----------
package com.energy.core.util;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;

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

@ExtendWith(MockitoExtension.class)          // the JUnit 5 replacement for @RunWith
class DiscountCalculatorTestJUnit5 {

    private ShoppingCart cart;

    @BeforeEach                              // NOT @Before
    void setUp() {
        cart = new ShoppingCart();
    }

    @Test
    void emptyCartTotalsToZero() {
        assertEquals(0.0, cart.getTotal(), 0.01);
    }

    @Test
    void rejectsANegativeAmount() {
        // assertThrows: isolates the ONE call, gives back the exception
        IllegalArgumentException ex = assertThrows(
                IllegalArgumentException.class,
                () -> DiscountCalculator.apply(-10.0));

        assertTrue(ex.getMessage().contains("negative"));   // real inspection
    }
}

The line-by-line diffs to point at: the import package, @Before to @BeforeEach, @RunWith to @ExtendWith, and @Test(expected=...) to assertThrows with real exception inspection.

8.3 Mockito fundamentals, in one file

package com.energy.core.services.impl;

import com.energy.core.services.ProductDataService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.junit.jupiter.MockitoExtension;

import java.net.SocketTimeoutException;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    private ProductDataService productDataService;
    private CacheService cacheService;
    private OrderService orderService;

    @BeforeEach
    void setUp() {
        // Plain Mockito.mock() -- no annotation ceremony needed for this shape.
        productDataService = mock(ProductDataService.class);
        cacheService = mock(CacheService.class);
        orderService = new OrderService(productDataService, cacheService);
    }

    @Test
    void calculatesShippingFromWeight() {
        // ARRANGE -- tell the mock what to return
        when(productDataService.getSpecification("TX-4000"))
                .thenReturn(new Specification(120.0));

        // ACT
        double cost = orderService.calculateShippingCost("TX-4000");

        // ASSERT -- the return value
        assertEquals(300.0, cost, 0.01);
    }

    @Test
    void anyProductIdReturnsTheStubbedDefault() {
        // anyString() -- the exact ID doesn't matter for this test
        when(productDataService.getSpecification(anyString()))
                .thenReturn(new Specification(100.0));

        assertEquals(250.0, orderService.calculateShippingCost("ANYTHING-AT-ALL"), 0.01);
    }

    @Test
    void degradesGracefullyWhenUpstreamTimesOut() {
        // doThrow(...).when(mock).method(...) -- the form for anything
        // that must throw, including void methods.
        doThrow(new SocketTimeoutException())
                .when(productDataService).refreshCache();

        // ACT -- must NOT propagate the exception
        assertDoesNotThrow(() -> orderService.warmCache());
    }

    @Test
    void cachesTheResultAfterALookup() {
        when(productDataService.getSpecification("TX-4000"))
                .thenReturn(new Specification(120.0));

        orderService.calculateShippingCost("TX-4000");

        // verify -- a SIDE EFFECT, not a return value: was the cache
        // actually written to?
        verify(cacheService).put(eq("TX-4000"), any(Specification.class));
    }

    @Test
    void cachesExactlyTheSpecificationThatWasLookedUp() {
        Specification spec = new Specification(120.0);
        when(productDataService.getSpecification("TX-4000")).thenReturn(spec);

        orderService.calculateShippingCost("TX-4000");

        // ArgumentCaptor -- inspect WHAT was passed, not just THAT it was
        ArgumentCaptor<Specification> captor = ArgumentCaptor.forClass(Specification.class);
        verify(cacheService).put(eq("TX-4000"), captor.capture());

        assertEquals(120.0, captor.getValue().getWeight(), 0.01);
    }
}

8.4 A Sling Model test — building directly on file 05's FaqModel

This is the same FaqModel / FaqItemModel pair from file 05, section 8.2 — reused here rather than re-explained, because the point of this section is the testing discipline, not the model itself.

package com.energy.core.models;

import com.energy.core.services.ProductDataService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.apache.sling.api.resource.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;

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

@ExtendWith(AemContextExtension.class)
class FaqModelTest {

    private final AemContext context = new AemContext();

    @BeforeEach
    void setUp() {
        context.load().json("/faq/content.json", "/content");

        // BOTH models -- the nested-model trap from section 2.11.
        // Forgetting FaqItemModel here makes every row silently
        // fail to adapt, with the parent model still adapting fine.
        context.addModelsForClasses(FaqModel.class, FaqItemModel.class);

        // Any @OSGiService the model needs gets a mock stand-in.
        context.registerService(ProductDataService.class,
                Mockito.mock(ProductDataService.class));
    }

    @Nested
    @DisplayName("the EMPTY case -- author added no rows")
    class WhenEmpty {

        @Test
        void listComesBackEmptyNotNull() {
            FaqModel model = adapt("/content/faq-empty");
            assertNotNull(model, "the model itself must still adapt");
            assertTrue(model.getFaqs().isEmpty(), "must be empty, never null");
        }

        @Test
        void isReadyReflectsTheEmptyList() {
            assertFalse(adapt("/content/faq-empty").isReady());
        }
    }

    @Nested
    @DisplayName("the PARTIAL case -- one row is half-filled")
    class WhenPartial {

        @Test
        void theIncompleteRowIsFilteredOut() {
            // faq-partial.json has TWO rows in the repository, but
            // one has a blank answer -- this is the case an author
            // will actually create, not a hypothetical.
            FaqModel model = adapt("/content/faq-partial");
            assertEquals(1, model.getFaqs().size());
        }
    }

    @Nested
    @DisplayName("TWO instances on one page")
    class WhenTwoInstancesOnOnePage {

        @Test
        void idsDoNotCollide() {
            // A counter-based id would produce "faq-0" for BOTH --
            // this fixture has two accordions on one page, exactly
            // to catch that class of bug.
            String first = adapt("/content/page/faq-one").getFaqs().get(0).getId();
            String second = adapt("/content/page/faq-two").getFaqs().get(0).getId();
            assertNotEquals(first, second, "duplicate ids break aria-controls");
        }
    }

    private FaqModel adapt(String path) {
        Resource resource = context.resourceResolver().getResource(path);
        assertNotNull(resource, "test content missing at " + path);
        FaqModel model = resource.adaptTo(FaqModel.class);
        assertNotNull(model, "model failed to adapt at " + path);
        return model;
    }
}

What this test class is deliberately not doing: there is no test asserting getHeading() returns the heading string that was set in the fixture. That would pass, and would prove nothing beyond "Sling's @ValueMapValue injector works," which is Sling's job to have tested, not this project's.

8.5 An OSGi service test — the disabled path and the upstream-failure path

Building on file 06's ProductDataServiceImpl.

package com.energy.core.services.impl;

import com.energy.core.services.ProductDataService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import java.util.HashMap;
import java.util.Map;

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

@ExtendWith(AemContextExtension.class)
class ProductDataServiceImplTest {

    private final AemContext context = new AemContext();

    private Map<String, Object> baseConfig() {
        Map<String, Object> config = new HashMap<>();
        config.put("apiEndpoint", "https://test.local/v1");
        config.put("connectTimeout", 1000);
        config.put("socketTimeout", 1000);
        config.put("enabled", true);
        return config;
    }

    @Test
    @org.junit.jupiter.api.DisplayName("DISABLED path: no call is even attempted")
    void disabledServiceReturnsEmptyWithoutCallingUpstream() {
        Map<String, Object> config = baseConfig();
        config.put("enabled", false);

        ProductDataService service = context.registerInjectActivateService(
                new ProductDataServiceImpl(), config);

        assertTrue(service.getSpecifications("TX-4000").isEmpty());
        assertFalse(service.isAvailable());
        // This test is deliberately not asserting anything about HTTP --
        // the whole point of the enabled flag is that nothing is attempted.
    }

    @Test
    @org.junit.jupiter.api.DisplayName("never throws for an unknown or unreachable product")
    void neverThrowsRegardlessOfUpstreamOutcome() {
        ProductDataService service = context.registerInjectActivateService(
                new ProductDataServiceImpl(), baseConfig());

        // The contract (file 06, section 8.1's Javadoc) promises an EMPTY
        // result, never an exception, for an unknown product or a failing
        // upstream. Calling with a product ID guaranteed not to exist
        // against a real endpoint exercises exactly that promise.
        assertDoesNotThrow(() -> {
            var result = service.getSpecifications("DOES-NOT-EXIST");
            assertNotNull(result);
        });
    }
}

Why this is the pair of tests that actually matter, per story 3: the happy path — a real product ID, a real response — is what manual QA already exercises on every release. Nobody manually flips the enabled flag to false or manually takes the upstream API down before every deploy. These two tests are the only thing standing between "the contract says it degrades" and "the contract says it degrades, and something actually checks."

8.6 A servlet test — bad input and malformed input

Building on file 07's ProductSpecsServlet.

package com.energy.core.servlets;

import com.energy.core.services.ProductDataService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;

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

@ExtendWith(AemContextExtension.class)
class ProductSpecsServletTest {

    private final AemContext context = new AemContext();
    private ProductSpecsServlet servlet;

    @BeforeEach
    void setUp() {
        context.load().json("/servlets/content.json", "/content");
        context.registerService(ProductDataService.class,
                Mockito.mock(ProductDataService.class));
        servlet = context.registerInjectActivateService(new ProductSpecsServlet());
        context.currentResource("/content/product");
    }

    @Test
    void missingSuffixReturnsBadRequest() {
        // No suffix at all -- the servlet requires a product id there.
        servlet.doGet(context.request(), context.response());
        assertEquals(400, context.response().getStatus());
    }

    @Test
    void pathTraversalInTheSuffixIsRejected() {
        // A MALFORMED / hostile suffix -- exactly the input a fuzzer
        // or a careless integration would send, not something a normal
        // author-driven flow would produce, but the servlet is public.
        context.requestPathInfo().setSuffix("/../../etc/passwd");

        servlet.doGet(context.request(), context.response());

        assertEquals(400, context.response().getStatus());
    }

    @Test
    void validSuffixReturnsJson() {
        context.requestPathInfo().setSuffix("/TX-4000");

        servlet.doGet(context.request(), context.response());

        assertEquals(200, context.response().getStatus());
        assertTrue(context.response().getContentType().contains("application/json"));
    }
}

Two of these three tests exist to prove bad input is rejected safely. That ratio is deliberate: a public-facing servlet's most valuable tests are the ones proving it fails closed on input nobody designed it for, not the one proving it works when fed exactly what it expects.

8.7 A workflow process step test — missing payload and wrong payload type

Building on file 09's MarkReviewedProcess. There's no AEM-Mocks-specific workflow support, so this mixes plain Mockito for the workflow API objects with an AemContext resolver so assertions can check real content.

package com.energy.core.workflows;

import com.adobe.granite.workflow.WorkflowException;
import com.adobe.granite.workflow.WorkflowSession;
import com.adobe.granite.workflow.exec.WorkItem;
import com.adobe.granite.workflow.exec.Workflow;
import com.adobe.granite.workflow.exec.WorkflowData;
import com.adobe.granite.workflow.metadata.MetaDataMap;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.apache.sling.api.resource.ResourceResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;

@ExtendWith(AemContextExtension.class)
class MarkReviewedProcessTest {

    private final AemContext context = new AemContext();

    private MarkReviewedProcess process;
    private WorkItem workItem;
    private WorkflowSession workflowSession;
    private MetaDataMap metaDataMap;
    private WorkflowData workflowData;

    @BeforeEach
    void setUp() {
        context.load().json("/workflows/content.json", "/content");

        process = new MarkReviewedProcess();

        // The workflow API objects have no AEM-Mocks equivalent --
        // they're mocked directly with Mockito.
        workItem = Mockito.mock(WorkItem.class);
        workflowSession = Mockito.mock(WorkflowSession.class);
        metaDataMap = Mockito.mock(MetaDataMap.class);
        workflowData = Mockito.mock(WorkflowData.class);
        Workflow workflow = Mockito.mock(Workflow.class);

        when(workItem.getWorkflowData()).thenReturn(workflowData);
        when(workItem.getWorkflow()).thenReturn(workflow);
        when(workflow.getWorkflowData()).thenReturn(workflowData);

        // The step gets its ResourceResolver from the workflow session --
        // route that straight through to the real (mock-repository-backed)
        // resolver, so content assertions after execute() are genuine.
        ResourceResolver resolver = context.resourceResolver();
        when(workflowSession.adaptTo(ResourceResolver.class)).thenReturn(resolver);
    }

    @Test
    void throwsWhenPayloadTypeIsNotAPath() {
        when(workflowData.getPayloadType()).thenReturn("JCR_UUID");
        when(workflowData.getPayload()).thenReturn("some-uuid-not-a-path");

        WorkflowException ex = assertThrows(WorkflowException.class,
                () -> process.execute(workItem, workflowSession, metaDataMap));

        assertTrue(ex.getMessage().contains("JCR_PATH"));
    }

    @Test
    void throwsWhenThePayloadNoLongerExists() {
        // A long-paused workflow whose payload was deleted while it waited
        // on a human step -- file 09's exact scenario.
        when(workflowData.getPayloadType()).thenReturn("JCR_PATH");
        when(workflowData.getPayload()).thenReturn("/content/does-not-exist");

        WorkflowException ex = assertThrows(WorkflowException.class,
                () -> process.execute(workItem, workflowSession, metaDataMap));

        assertTrue(ex.getMessage().contains("no longer exists"));
    }

    @Test
    void marksARealPayloadAsReviewed() {
        when(workflowData.getPayloadType()).thenReturn("JCR_PATH");
        when(workflowData.getPayload()).thenReturn("/content/product");
        when(metaDataMap.get("PROCESS_ARGS", String.class)).thenReturn("technical");

        assertDoesNotThrow(() -> process.execute(workItem, workflowSession, metaDataMap));

        // Assert on REAL content, via the same resolver the step used.
        String status = context.resourceResolver()
                .getResource("/content/product/jcr:content")
                .getValueMap()
                .get("reviewStatus", String.class);

        assertEquals("technical-approved", status);
    }
}

The two failure-path tests are the valuable ones. The happy-path test at the end is worth having too, but it's the wrong-type and missing-payload cases that catch the mistakes a code change is actually likely to introduce — someone forgetting the type check, or removing the null guard on the resolved resource, six months from now.

8.8 A job consumer test — FAILED versus CANCEL, and idempotency

File 10 already carries a complete version of this test against ProductSyncJobConsumer (its section 8.5) — reproduced here in slightly condensed form as the reference example for this file's specific angle: the mechanics of doThrow, verify, and times that make the FAILED/CANCEL distinction and the idempotency check actually work.

package com.energy.core.jobs;

import com.energy.core.services.ProductDataService;
import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.apache.sling.event.jobs.Job;
import org.apache.sling.event.jobs.consumer.JobConsumer;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mockito;

import java.net.SocketTimeoutException;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;

@ExtendWith(AemContextExtension.class)
class ProductSyncJobConsumerTest {

    private final AemContext context = new AemContext();
    private ProductDataService productDataService;
    private ProductSyncJobConsumer consumer;

    @BeforeEach
    void setUp() {
        productDataService = Mockito.mock(ProductDataService.class);
        context.registerService(ProductDataService.class, productDataService);
        consumer = context.registerInjectActivateService(new ProductSyncJobConsumer());
    }

    @Test
    void cancelsWhenThereIsNothingToDo() {
        // A permanently empty batch: retrying changes nothing. CANCEL.
        assertEquals(JobConsumer.JobResult.CANCEL, consumer.process(jobWith(null)));
    }

    @Test
    void retriesOnATransientTimeout() throws Exception {
        // doThrow(...).when(mock).voidMethod(...) -- the form for a
        // VOID method, because there's no return value to hang a
        // when(...).thenThrow(...) stub off.
        doThrow(new SocketTimeoutException())
                .when(productDataService).syncProduct(anyString());

        assertEquals(JobConsumer.JobResult.FAILED,
                consumer.process(jobWith(new String[]{"TX-4000"})));
    }

    @Test
    void doesNotRetryOnPermanentlyInvalidData() throws Exception {
        doThrow(new IllegalArgumentException("bad id"))
                .when(productDataService).syncProduct(anyString());

        assertEquals(JobConsumer.JobResult.CANCEL,
                consumer.process(jobWith(new String[]{"???"})));
    }

    @Test
    void isSafeToRunTwice() throws Exception {
        // Sling Jobs are AT-LEAST-ONCE: a node can do the work and crash
        // before recording completion, and this WILL run again on restart.
        // The test documents the contract, not just a code path.
        Job job = jobWith(new String[]{"TX-4000"});

        assertEquals(JobConsumer.JobResult.OK, consumer.process(job));
        assertEquals(JobConsumer.JobResult.OK, consumer.process(job));

        // verify(..., times(2)) -- confirming the SIDE EFFECT happened
        // exactly twice, because the underlying service call is itself
        // idempotent. If it weren't, this is where a guard would need
        // its own assertion: verify(..., times(1)) after two process()
        // calls, proving the second call was a safe no-op.
        verify(productDataService, times(2)).syncProduct("TX-4000");
    }

    private Job jobWith(String[] productIds) {
        Job job = Mockito.mock(Job.class);
        when(job.getProperty("productIds", String[].class)).thenReturn(productIds);
        when(job.getProperty("batchNumber", Integer.class)).thenReturn(1);
        return job;
    }
}

8.9 Exporting a JSON fixture — the CRXDE workflow

Not code, but a sequence worth having memorised since it's the first step of nearly every AEM Mocks test:

1. Author the content you need in a real (dev/local) AEM instance --
   fill in a component exactly the way the scenario needs (empty,
   partial, two instances on one page).

2. Find the component instance's path, e.g.:
   /content/energy/global/en/products/jcr:content/root/faq

3. Request it with a JSON suffix in a browser or curl:
   .../faq.infinity.json          (the whole subtree, unlimited depth)
   .../faq.json                   (just this node, one level)

4. Save the response body as a .json file under:
   core/src/test/resources/faq/content.json

5. Trim it: remove properties the test doesn't care about, rename
   the root path if needed, and give the file a name describing
   the SCENARIO -- content-empty.json, content-partial.json --
   not a generic content.json reused for every case.

Why .infinity.json and not a hand-typed structure: it captures the real shape a component actually produces in the repository, including any properties added by the dialog's hidden fields or by AEM itself (jcr:primaryType, sling:resourceType) — details that are easy to get subtly wrong typing a fixture from memory, and that are exactly the kind of thing section 2.7 warns against re-implementing by hand.


9. Common Mistakes

The mistakeWhat actually happensThe fix
Asserting a getter returns the field it was just givenPasses, proves nothing — would still pass if the getter were subtly wrongTest the scenario (empty, partial, boundary), not the field
Chasing a coverage percentageTests that execute a line without checking anything meaningfulWrite the empty/partial/failure-path tests you'd write anyway
Forgetting to register a @ChildResource nested modelThe list of nested models comes back null/empty, no erroraddModelsForClasses(Parent.class, Child.class) — both
Using registerService on the class under testIts @Activate never runs; configuration is never appliedregisterInjectActivateService for the thing being tested
Mixing a raw literal with a Mockito matcher in one callInvalidUseOfMatchersExceptionIf one argument uses a matcher, every argument in that call must
Using when(...).thenThrow(...) on a void methodDoesn't compile — no return value to hang the stub ondoThrow(...).when(mock).method(...)
Sharing mutable state across tests (a static field)Flaky tests that pass or fail depending on run orderFresh state in @BeforeEach; a new AemContext /instance per test
Testing only the happy pathThe bug that ships is always in a path nobody testedPrioritise empty, partial, disabled, and malformed-input cases
Hand-mocking Resource / ValueMap node by nodeLarge, brittle setup that's really a fake repository you built yourselfcontext.load().json(...)
Over-mocking every collaboratorThe test barely exercises any real logicMock only what's genuinely external or slow; reconsider the class's dependencies if it needs this many mocks
Using JUnit 4's @Test(expected=...) when inspection is neededCan't check the exception's message or state around the throwing lineassertThrows in JUnit 5
@InjectMocks silently failing to wire a fieldNullPointerException deep inside the test, not at setupConstruct the object explicitly in @BeforeEach for anything non-trivial
Treating an integration or UI test as a substitute for unit testsSlow feedback; a failure doesn't point at a specific unitUnit test the logic; reserve integration/UI tests for what genuinely needs a live instance
Believing HTL/dialog XML/validation JS have a unit-testing storyThere's no "unit" to isolate — none of it is JavaPush logic into a testable model/service; accept the remaining gap honestly
Not testing idempotency on a job consumerA retried job silently duplicates a side effect in productionCall process() twice in one test and assert the effect, not just the return value

10. Best Practices

On what to test. The empty case, the partial case, two instances on one page, the disabled path, the upstream-failure path, bad and malformed input, a missing or wrong-type payload, and the FAILED-versus-CANCEL decision on a job consumer. Never a getter proving it returns its own field.

On JUnit. Prefer JUnit 5 for anything new. @BeforeEach for fresh state, @ParameterizedTest for boundary values instead of five near-identical methods, assertThrows when a failure path needs its exception inspected, assertAll when several related things should all be checked and reported together.

On mocking. Mock genuinely external or slow collaborators — an HTTP-backed service, a real repository connection you don't want to spin up. Don't hand-mock what AEM Mocks already gives you for free. Don't mock so much that the test stops exercising real logic.

On AEM Mocks. One small JSON fixture per scenario, named for the scenario. Register every model class a test's adaptTo chain touches, including nested ones. Use registerService for a fake dependency, registerInjectActivateService for the real component under test.

On coverage. Treat the Cloud Manager gate as a floor, not a design goal. If a scenario-driven test suite doesn't clear it, look at what's genuinely uncovered before adding anything — it's usually a real gap worth closing on its own merits.

On honesty. Say plainly what isn't unit-testable — HTL, dialog XML, client-side validation JS — rather than implying a coverage story that doesn't exist. Push logic out of those layers and into a testable model or service instead of trying to test around the gap.

On speed. Keep unit tests fast — AEM Mocks exists precisely so a whole suite runs in seconds. Reserve integration and UI tests, which are slower by design, for what genuinely needs a live instance or a real browser.


11. Debugging Tips

A model's @ChildResource list comes back empty in a test, but the fixture clearly has the rows. Check addModelsForClasses — the nested model type almost certainly isn't registered alongside the parent. This is the single most common AEM Mocks test failure and the first thing to check.

An injected @OSGiService or @Reference field is null inside a test. Either registerService (for a plain @OSGiService / @Reference dependency) was never called for that interface, or registerInjectActivateService was used on a dependency instead of registerService — the two are not interchangeable (section 2.12).

InvalidUseOfMatchersException on a verify or when call. One argument used a matcher (any(), anyString(), eq()) and another didn't. Make every argument in that call a matcher, using eq() to wrap the literal ones.

A stubbed method never seems to return what was configured. Check whether the method is void — when(...).thenReturn(...) doesn't compile for a void method, and a leftover when(mock.voidMethod()) line (which does compile, oddly, because Java resolves it as a call) silently does nothing useful. Use doThrow / doAnswer / doNothing for void methods.

A test passes alone but fails when run with the rest of the suite. Shared mutable state — a static field, a singleton not reset between tests, or (in older JUnit 4 code) a @BeforeClass doing setup meant to be per-test. Confirm the test class doesn't rely on anything created outside @BeforeEach.

@InjectMocks produced an object with a null field that should have been mocked. @InjectMocks wires by best-effort reflection and fails silently when it can't figure out how. Construct the object explicitly in @BeforeEach, passing the mocks to its constructor yourself.

A servlet or model test behaves differently locally than in CI. Check for anything reading real system state — the machine's timezone or locale affecting a Calendar /date assertion is the classic case. Pin the values a test needs explicitly rather than relying on the environment.

SymptomLikely causeWhere to look
@ChildResource list empty despite fixture contentNested model not registeredaddModelsForClasses(Parent.class, Child.class)
Injected service is nullregisterService not called, or called for the wrong typeSection 2.12
InvalidUseOfMatchersExceptionMixed literal and matcher in one callWrap literals in eq(...)
Stub silently does nothingUsed when/thenReturn on a void methoddoThrow / doAnswer / doNothing
Flaky, order-dependent testsShared static/mutable stateMove setup into @BeforeEach
@InjectMocks field is nullReflection couldn't figure out the wiringConstruct manually

12. Performance Notes

AEM Mocks is fast because it isn't real AEM. A whole test class with a dozen tests, each loading a fixture and adapting a model, typically runs in well under a second. That speed is the entire reason unit tests can run on every commit, whereas an integration test against a real instance cannot.

A fresh AemContext per test is cheap. Because it's an in-memory structure, not a real repository connection, creating one thousands of times a day costs nothing meaningful. Don't share a context across tests to "save time" — the isolation it buys is worth far more than the negligible cost of recreating it.

Over-mocking slows down test writing, not test running. A test with fifteen stubbed collaborators takes longer to set up and longer to read than the runtime cost ever justifies. If a class needs that many mocks to test, the real performance problem is the class's design, not the test.

Prefer many small, focused fixtures over one large shared one. A shared fixture used by ten unrelated tests means a change for test eleven's needs risks silently altering what tests one through ten are actually exercising — which shows up as an unrelated test failing for a reason nobody expects, and costs far more time to untangle than writing a second small fixture would have.

Don't reach for an integration test where a unit test with AEM Mocks would do. Integration tests need a real, running, deployed AEM instance — minutes, not milliseconds — and a failure there is much harder to pin down to a specific cause. Reserve them for what genuinely can't be faked: real replication, a real dispatcher, a real multi-instance interaction (file 14).


15. Comparison Tables

JUnit 4 vs JUnit 5

JUnit 4JUnit 5
Packageorg.junitorg.junit.jupiter.api
Per-test setup@Before@BeforeEach
Per-test teardown@After@AfterEach
Enabling Mockito@RunWith(MockitoJUnitRunner.class)@ExtendWith(MockitoExtension.class)
Exception testing@Test(expected = X.class)assertThrows(X.class, () -> {...})
Grouped/nested testsNot supported@Nested
Parameterized testsSeparate, clunkier runner@ParameterizedTest

Mock vs Stub vs Spy — the vocabulary, since interviewers sometimes probe the distinction

What it isMockito equivalent
StubA fake that returns canned answers, no verificationA mock() used only with when/thenReturn
MockA fake whose interactions are verifiedThe same mock(), used with verify(...)
SpyA real object, with some methods selectively overriddenMockito.spy(realInstance)

In everyday Mockito usage the same mock() object often plays both "stub" and "mock" roles in one test — the terms describe how it's used, not two different Mockito APIs.

Unit vs Integration vs UI test

Unit testIntegration test (it.tests)UI test (ui.tests)
Needs a real AEM instanceNoYesYes, plus a browser
SpeedMillisecondsSeconds to minutesSlowest
IsolatesOne classSeveral real components togetherThe whole rendered page
ToolJUnit + Mockito + AEM MocksJUnit against a deployed instanceSelenium or similar
Where it runsEvery local buildCloud Manager pipeline, stageCloud Manager pipeline, stage

registerService vs registerInjectActivateService

registerServiceregisterInjectActivateService
Runs @ActivateNoYes
Injects @Reference fieldsNoYes
Applies configurationNoYes, via the config map argument
Use forA fake dependencyThe real component under test

JobResult.FAILED vs JobResult.CANCEL (from file 10, restated here because testing this decision is this file's concern)

FAILEDCANCEL
MeaningTransient — retry may succeedPermanent — retrying changes nothing
Test withA mocked timeout/network exceptionMissing/malformed job properties, or an illegal-argument-style exception

Next topic

28-Code-Quality-SonarQube-and-Best-Practices — how the Cloud Manager quality gate actually scores a pull request, the SonarQube and OakPAL rules that matter most in practice, and the coding standards and review habits that keep a codebase from accumulating the kind of debt this file's testing discipline can't catch on its own.


Topic 27 of the AEM Interview Preparation repository. Teaching style, energy-sector project domain.