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:
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 undersrc/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.
@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).
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:
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:
@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:
@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:
assertEquals on a double needs a delta, because floating-point comparisons are not exact:
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:
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:
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.
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.
The interview answer:
"JUnit 4 is
org.junit,@Before/@After, and@RunWithfor extensions like Mockito. JUnit 5 — Jupiter — isorg.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'sassertThrowsisolates exactly the call that should throw and gives you the exception back to make assertions on. If I open an older AEM project and seeorg.junit.Testand@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:
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.
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:
verify(...) — after the act step, confirm a method on the mock was actually called, and how many times:
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:
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:
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:
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:
@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
@Mockand@InjectMocksfor 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@BeforeEachinstead, because@InjectMocksfailing 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
ResourceorValueMapnode 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
adaptTomechanism, soresource.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
@Componentclasses with real configuration, exactly as file 06 exercises. - Mock
PageandPageManagerobjects, 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) 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:
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:
- Author the content you want to test — a component instance, filled in the way you want to test (empty, partial, fully filled).
- In CRXDE Lite, or simply in a browser, request that node's path with a
.json(or.infinity.jsonfor the full subtree, unlimited depth) suffix — for example/content/energy/global/en/products/jcr:content/root/faq.infinity.json. - Save the response body as a file under
src/test/resources. - 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.
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.
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:
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:
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.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
8.2 JUnit 4 versus JUnit 5, side by side
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
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.
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.
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.
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.
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.
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:
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
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.
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
Mock vs Stub vs Spy — the vocabulary, since interviewers sometimes probe the distinction
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
registerService vs registerInjectActivateService
JobResult.FAILED vs JobResult.CANCEL (from file 10, restated here because testing this decision is this file's concern)
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.