> 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.

# 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**:

```java
@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.

```java
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).

```java
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:

```java
@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:

```java
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:

```java
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:

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

| Assertion                                          | Checks                                                                | Typical use                                           |
| -------------------------------------------------- | --------------------------------------------------------------------- | ----------------------------------------------------- |
| `assertEquals(expected, actual)`                   | Two values are equal                                                  | The overwhelming majority of assertions               |
| `assertTrue(condition)` / `assertFalse(condition)` | A boolean                                                             | `isReady()`, `isValid()` flags                        |
| `assertNull(value)` / `assertNotNull(value)`       | Presence or absence                                                   | Confirming `adaptTo` succeeded or failed              |
| `assertThrows(Type.class, () -> {...})`            | An exception is thrown, and of the right type                         | A method that must reject bad input                   |
| `assertAll(() -> ..., () -> ...)`                  | Several assertions, **and reports every failure**, not just the first | Checking several fields of one object                 |
| `assertSame(a, b)` / `assertNotSame(a, b)`         | Reference identity (`==`), not equality                               | Rare — confirming the exact same object, e.g. caching |

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

```java
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:

```java
@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:

```java
@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 4                                              | JUnit 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 class                         | `org.junit.Assert`                                   | `org.junit.jupiter.api.Assertions`                         |
| Parameterized tests                      | A separate, clunkier `@RunWith(Parameterized.class)` | `@ParameterizedTest` — much more ergonomic                 |
| Nested/grouped tests                     | Not 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.

```java
// 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:

```java
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.

```java
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:

```java
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:

```java
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:

```java
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:

```java
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:

```java
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:

```java
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:

```java
@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:

```java
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.

```json
{
  "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.

```java
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:

```java
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:

```java
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:

```java
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

```java
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

```java
// ---------- 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
    }
}
```

```java
// ---------- 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

```java
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.

```java
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`.

```java
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`.

```java
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.

```java
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.

```java
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:

```text
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 mistake                                                       | What actually happens                                                     | The fix                                                                                                      |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Asserting a getter returns the field it was just given            | Passes, proves nothing — would still pass if the getter were subtly wrong | Test the scenario (empty, partial, boundary), not the field                                                  |
| Chasing a coverage percentage                                     | Tests that execute a line without checking anything meaningful            | Write the empty/partial/failure-path tests you'd write anyway                                                |
| Forgetting to register a `@ChildResource` nested model            | The list of nested models comes back null/empty, no error                 | `addModelsForClasses(Parent.class, Child.class)` — both                                                      |
| Using `registerService` on the class under test                   | Its `@Activate` never runs; configuration is never applied                | `registerInjectActivateService` for the thing being tested                                                   |
| Mixing a raw literal with a Mockito matcher in one call           | `InvalidUseOfMatchersException`                                           | If one argument uses a matcher, every argument in that call must                                             |
| Using `when(...).thenThrow(...)` on a `void` method               | Doesn't compile — no return value to hang the stub on                     | `doThrow(...).when(mock).method(...)`                                                                        |
| Sharing mutable state across tests (a static field)               | Flaky tests that pass or fail depending on run order                      | Fresh state in `@BeforeEach`; a new `AemContext` /instance per test                                          |
| Testing only the happy path                                       | The bug that ships is always in a path nobody tested                      | Prioritise empty, partial, disabled, and malformed-input cases                                               |
| Hand-mocking `Resource` / `ValueMap` node by node                 | Large, brittle setup that's really a fake repository you built yourself   | `context.load().json(...)`                                                                                   |
| Over-mocking every collaborator                                   | The test barely exercises any real logic                                  | Mock 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 needed   | Can't check the exception's message or state around the throwing line     | `assertThrows` in JUnit 5                                                                                    |
| `@InjectMocks` silently failing to wire a field                   | NullPointerException deep inside the test, not at setup                   | Construct the object explicitly in `@BeforeEach` for anything non-trivial                                    |
| Treating an integration or UI test as a substitute for unit tests | Slow feedback; a failure doesn't point at a specific unit                 | Unit test the logic; reserve integration/UI tests for what genuinely needs a live instance                   |
| Believing HTL/dialog XML/validation JS have a unit-testing story  | There's no "unit" to isolate — none of it is Java                         | Push logic into a testable model/service; accept the remaining gap honestly                                  |
| Not testing idempotency on a job consumer                         | A retried job silently duplicates a side effect in production             | Call `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.

| Symptom                                             | Likely cause                                               | Where to look                                    |
| --------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------ |
| `@ChildResource` list empty despite fixture content | Nested model not registered                                | `addModelsForClasses(Parent.class, Child.class)` |
| Injected service is null                            | `registerService` not called, or called for the wrong type | Section 2.12                                     |
| `InvalidUseOfMatchersException`                     | Mixed literal and matcher in one call                      | Wrap literals in `eq(...)`                       |
| Stub silently does nothing                          | Used `when/thenReturn` on a `void` method                  | `doThrow` / `doAnswer` / `doNothing`             |
| Flaky, order-dependent tests                        | Shared static/mutable state                                | Move setup into `@BeforeEach`                    |
| `@InjectMocks` field is null                        | Reflection couldn't figure out the wiring                  | Construct 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 4                              | JUnit 5                               |
| -------------------- | ------------------------------------ | ------------------------------------- |
| Package              | `org.junit`                          | `org.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 tests | Not supported                        | `@Nested`                             |
| Parameterized tests  | Separate, clunkier runner            | `@ParameterizedTest`                  |

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

|      | What it is                                              | Mockito equivalent                          |
| ---- | ------------------------------------------------------- | ------------------------------------------- |
| Stub | A fake that returns canned answers, no verification     | A `mock()` used only with `when/thenReturn` |
| Mock | A fake whose interactions are verified                  | The same `mock()`, used with `verify(...)`  |
| Spy  | A real object, with some methods selectively overridden | `Mockito.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 test                   | Integration test (`it.tests`)     | UI test (`ui.tests`)          |
| ------------------------- | --------------------------- | --------------------------------- | ----------------------------- |
| Needs a real AEM instance | No                          | Yes                               | Yes, plus a browser           |
| Speed                     | Milliseconds                | Seconds to minutes                | Slowest                       |
| Isolates                  | One class                   | Several real components together  | The whole rendered page       |
| Tool                      | JUnit + Mockito + AEM Mocks | JUnit against a deployed instance | Selenium or similar           |
| Where it runs             | Every local build           | Cloud Manager pipeline, stage     | Cloud Manager pipeline, stage |

**`registerService` vs `registerInjectActivateService`**

|                             | `registerService` | `registerInjectActivateService`  |
| --------------------------- | ----------------- | -------------------------------- |
| Runs `@Activate`            | No                | Yes                              |
| Injects `@Reference` fields | No                | Yes                              |
| Applies configuration       | No                | Yes, via the config map argument |
| Use for                     | A fake dependency | The real component under test    |

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

|           | `FAILED`                           | `CANCEL`                                                                 |
| --------- | ---------------------------------- | ------------------------------------------------------------------------ |
| Meaning   | Transient — retry may succeed      | Permanent — retrying changes nothing                                     |
| Test with | A mocked timeout/network exception | Missing/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._
