Adobe AEM

Unit Testing in AEM: JUnit 5, AEM Mocks & Mockito — The Complete Guide

26 min read

A practical guide to unit testing AEM code — the testing stack the AEM Project Archetype ships, AemContext and resource resolver types, JSON test content, testing Sling Models, OSGi services, servlets, workflow processes and schedulers, Mockito vs AEM Mocks, integration and UI tests in Cloud Manager, and JaCoCo coverage against the quality gate. Includes code, a cheat sheet, best practices, and do's & don'ts.

AEMTestingJUnitMockitoJavaCloud Manager
Unit Testing in AEM: JUnit 5, AEM Mocks & Mockito — The Complete Guide

Most AEM bugs that reach production are not exotic. A Sling Model returns null when an author leaves a field empty, a servlet trusts a selector it shouldn't, an OSGi config default is wrong, a workflow step explodes on a payload it never expected. Each is cheap to catch in a millisecond unit test and expensive to catch after a pipeline run and an author ticket. On Cloud Manager there's an extra incentive: coverage is part of the code quality gate.

This guide covers the whole picture: what's worth unit-testing in AEM, the exact testing stack the AEM Project Archetype generates, how AEM Mocks (AemContext) simulates the repository, OSGi and Sling, how to test Sling Models, OSGi services, servlets, workflow processes and schedulers, when to reach for Mockito instead, where integration and UI tests fit, and how JaCoCo coverage feeds Cloud Manager. Differences between AEM as a Cloud Service and AEM 6.5 are called out as they come up.

It builds on the Backend Development guide and the Component Development guide (the code you'll be testing), the OSGi guide and Sling guide (the frameworks AEM Mocks simulates), and the Workflows guide. If you haven't got a project running locally yet, start with the Local Development Setup guide.

Why unit test AEM code (and what's worth testing)

AEM code has a reputation for being hard to test because it's glued to a repository, an OSGi container and a request pipeline. With AEM Mocks you get an in-memory repository, a mock OSGi container that performs real dependency injection, and mock Sling requests — all inside a plain JUnit test that needs no running AEM instance.

The point of unit tests in AEM isn't to re-test AEM. It's to pin down your logic: the decisions, fallbacks, and edge cases that live in your Java code. A useful rule of thumb:

Worth unit-testingNot worth unit-testing
Sling Model getters, fallbacks, @PostConstruct logicPlain getters that return an injected field unchanged
OSGi service logic and config handlingThat OSGi itself can activate a component
Servlet behavior per selector/extension/parameterDispatcher filters and caching (test those separately)
Workflow process decisions and repository writesThe workflow engine's routing and launchers
Scheduled job logic (what run() does)Whether the cron expression fires at 2 a.m.

Tip: The highest-value tests in an AEM codebase are almost always the "author left it empty" tests. Real content is messier than your happy path, and a model that throws a NullPointerException breaks the whole component on the page.

The testing stack the AEM Project Archetype ships

When you generate a project with the AEM Project Archetype, the core module comes with a complete test setup. As of archetype 58 (September 2026), the parent POM's dependencyManagement pins these test dependencies:

DependencyCoordinatesVersion in archetype 58Role
JUnit 5org.junit:junit-bom (import) → org.junit.jupiter:junit-jupiter5.8.2Test engine, @Test, assertions
Mockitoorg.mockito:mockito-core4.1.0Mocks, stubbing, verification
Mockito + JUnit 5org.mockito:mockito-junit-jupiter4.1.0MockitoExtension, @Mock
AEM Mocksio.wcm:io.wcm.testing.aem-mock.junit55.5.4AemContext, mock AEM/Sling/OSGi/JCR
Core Components mock plugincom.adobe.cq:core.wcm.components.testing.aem-mock-pluginCore Components version (2.28.0)Registers what Core Components need in tests
Context-Aware Config mock pluginorg.apache.sling:org.apache.sling.testing.caconfig-mock-plugin1.4.0CA Config support in AemContext
Sling Models implorg.apache.sling:org.apache.sling.models.impl1.4.14 (test scope, in core)Newer injector support for @Self and @Via
SLF4J Testuk.org.lidalia:slf4j-test1.0.1Assert on log output
JUnit Addonsjunit-addons:junit-addons1.4Legacy JUnit helpers

The production API dependency is where Cloud Service and 6.5 differ:

  • AEM as a Cloud Service — com.adobe.aem:aem-sdk-api, provided scope, versioned to match the SDK release you target.
  • AEM 6.5 — com.adobe.aem:uber-jar at your 6.5 service-pack version. For 6.5.0–6.5.5 the archetype adds the apis classifier; later service packs don't need it.

The generated core module also includes an AppAemContext helper and sample tests for a model, servlet, filter, resource listener and scheduled task — worth reading once.

Note: The archetype's pinned versions are conservative and lag the latest releases. At the time of writing, Maven Central has AEM Mocks 5.7.4, Mockito 5.x and JUnit 6.x. You can upgrade, but deliberately: Mockito 5 requires Java 11 and uses the inline mock maker by default; AEM Mocks 5.5.0–5.7.4 requires Java 11 and supports AEM 6.5.17+ and Cloud Service.

Two dependency details that bite people

AEM Mocks pulls in Sling bundles (Sling Models impl, resource resolver, etc.) that are not part of the SDK API or the uber-jar. To support older AEM versions, those transitive versions are intentionally old — so a newer Sling Models feature can work in AEM and silently fail in your tests. That's exactly why the archetype adds org.apache.sling.models.impl explicitly. wcm.io publishes BOMs that align all of these with a specific AEM version — io.wcm.maven:io.wcm.maven.aem-cloud-dependencies for Cloud Service, and io.wcm.maven:io.wcm.maven.aem-dependencies at your service-pack version for 6.5:

<dependency>
  <groupId>io.wcm.maven</groupId>
  <artifactId>io.wcm.maven.aem-cloud-dependencies</artifactId>
  <version><!-- latest --></version>
  <type>pom</type>
  <scope>import</scope>
</dependency>

The second detail: wcm.io recommends declaring the AEM Mocks test dependencies before the uber-jar / SDK API dependency in your bundle POM, so the mock-side classes win on the test classpath.

AemContext: the heart of every test

AemContext is a per-test sandbox containing a mock repository, a mock OSGi container, a mock Sling request/response pair, and the AEM WCM/DAM APIs (PageManager, Page, TagManager, AssetManager, content policies, and more). You register it with JUnit 5 via the AemContextExtension:

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

import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

@ExtendWith(AemContextExtension.class)
class ExampleTest {

    private final AemContext context = new AemContext();

    @Test
    void createsAPage() {
        context.create().page("/content/mysite/en");
        assertNotNull(context.pageManager().getPage("/content/mysite/en"));
    }
}

The extension sets the context up before each test and tears it down after, so every test starts with an empty repository and a fresh OSGi registry. You can also let the extension inject it as a method parameter instead of a field — void doGet(AemContext context) — which is what the archetype's servlet test does.

A few rules from the wcm.io docs worth following:

  • Keep the field non-static and do setup in @BeforeEach. Static contexts with @BeforeAll are supported (since AEM Mocks 3.0.0), but everything written to the repository or registered in OSGi is then shared across tests in the class.
  • Never instantiate AemContext inside @BeforeEach — you'll get duplicate contexts.
  • Put shared setup in a project-wide factory (the archetype's AppAemContext) using AemContextBuilder, plugins, and an afterSetUp callback.
// static imports: ContextPlugins.CACONFIG (Sling CA Config mock plugin)
//                 ContextPlugins.CORE_COMPONENTS (Core Components mock plugin)
public static AemContext newAemContext() {
    return new AemContextBuilder()
            .plugin(CACONFIG)
            .plugin(CORE_COMPONENTS)
            .afterSetUp(SETUP_CALLBACK)
            .build();
}

private static final AemContextCallback SETUP_CALLBACK = new AemContextCallback() {
    @Override
    public void execute(AemContext context) {
        // project-wide services, sample content, default current page...
    }
};

Important: If you copy the archetype's AppAemContext, note that its newAemContextBuilder(ResourceResolverType) overload accepts a type but builds with a plain new AemContextBuilder() — so the type you pass is ignored. Use new AemContextBuilder(resourceResolverType) if you actually want it honoured.

Resource resolver types: speed vs. fidelity

The single most important choice you make with AEM Mocks is which repository implementation backs the test. You pass it to the constructor (new AemContext(ResourceResolverType.JCR_MOCK)) or the builder. The types come from Apache Sling Mocks:

TypeWhat it isJCR API?SpeedUse when
RESOURCERESOLVER_MOCK (default)In-memory resource tree, no JCR mapping❌ adaptTo(Node.class) returns nullFastestCode that only uses the Sling Resource API — most models and services
RESOURCEPROVIDER_MOCKReal Sling resource resolver over a mocked provider❌Fast, a bit more overheadMultiple resource providers, loading folders of JSON/FileVault XML
JCR_MOCKJCR Mocks in-memory repository + real Sling JCR resource provider✅ (limited)Quite fastCode that uses Node/Session; queries with pre-set results
JCR_OAKReal Jackrabbit Oak on a MemoryNodeStore✅ fullSlow start (seconds on first access)Observation, versioning, real node-type constraints, real JCR-SQL2 queries
NONEReal resolver with no provider—DependsTesting your own ResourceProvider

The tradeoffs matter in practice:

  • RESOURCERESOLVER_MOCK is the right default and doubles as a design check: if your model needs Node or Session, ask whether it really should. It handles binaries, dates and null values slightly differently from JCR mapping.
  • JCR_MOCK gives you the JCR API, but doesn't enforce node type constraints, doesn't support versioning or transactions, ignores observation, and always grants access. Queries return only what you set via MockJcr.setQueryResult(...).
  • JCR_OAK is the real thing — but it needs an extra dependency, org.apache.sling:org.apache.sling.testing.sling-mock-oak, and Lucene indexing isn't included, so full-text queries return nothing.

Because different tests need different fidelity, the JUnit 5 extension offers typed parameter objects — ResourceResolverMockAemContext, JcrMockAemContext, JcrOakAemContext, NoResourceResolverTypeAemContext — so you can pay the Oak startup cost only in the tests that need it:

@Test
void versioningNeedsOak(JcrOakAemContext context) {
    // this test alone runs on a real Oak repository
}

Test content: JSON fixtures and ContentBuilder

Loading JSON with context.load()

For anything bigger than a couple of nodes, put a JSON fixture in src/test/resources, mirroring the test's package (the convention the WKND tutorial uses), and load it under a path:

{
  "jcr:primaryType": "cq:Page",
  "jcr:content": {
    "jcr:primaryType": "cq:PageContent",
    "jcr:title": "Page Title",
    "root": {
      "jcr:primaryType": "nt:unstructured",
      "header": {
        "jcr:primaryType": "nt:unstructured",
        "sling:resourceType": "mysite/components/article-header",
        "title": "Authored Title",
        "body": "Some words here"
      },
      "header-empty": {
        "jcr:primaryType": "nt:unstructured",
        "sling:resourceType": "mysite/components/article-header"
      }
    }
  }
}
context.load().json("/com/mysite/core/models/ArticleHeaderTest.json", "/content/mysite/en/article");

The first argument is a classpath path; the second is where the JSON root lands (parents are created automatically). Exporting real content from a local instance with .infinity.json and trimming it is a fast way to get realistic fixtures. The loader also supports binaryFile(...) for files and fileVaultXml(...) for .content.xml content.

Building content in code

For small, test-specific content, context.create() (the ContentBuilder) is more readable and gives you back the created objects:

Page page = context.create().page("/content/mysite/en/news", "/conf/mysite/settings/wcm/templates/article");
Resource teaser = context.create().resource(page, "teaser",
        "sling:resourceType", "mysite/components/teaser",
        "linkURL", "/content/mysite/en/products");
Asset asset = context.create().asset("/content/dam/mysite/hero.jpg", 1600, 900, "image/jpeg");

The alternative, context.build(), is Sling's fluent ResourceBuilder for creating a hierarchy in one chain.

Setting the request context

Most AEM code reads "where am I" from the request:

context.currentPage("/content/mysite/en");        // also sets currentResource to the page's jcr:content
context.currentResource("/content/mysite/en/jcr:content/root/header");
context.requestPathInfo().setSelectorString("results");
context.requestPathInfo().setExtension("json");
context.requestPathInfo().setSuffix("/content/mysite/en/products");
context.request().addRequestParameter("q", "shoes");
context.runMode("publish");
WCMMode.EDIT.toRequest(context.request());
context.contentPolicyMapping("mysite/components/teaser", "showDescription", true);

currentPage() resolves to the page containing the current resource, so pointing currentResource at a component inside a page is enough for @ScriptVariable Page currentPage to work. AEM Mocks doesn't implement the full editable-template/policy stack; contentPolicyMapping(...) is a shortcut that attaches policy properties to a resource type.

Testing Sling Models

This is where you'll spend most of your testing time. Take a request-adaptable model that reads authored properties, falls back to the page title, and calls an OSGi service:

package com.mysite.core.models;

import javax.annotation.PostConstruct;

import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import org.apache.sling.models.annotations.injectorspecific.OSGiService;
import org.apache.sling.models.annotations.injectorspecific.ScriptVariable;
import org.apache.sling.models.annotations.injectorspecific.ValueMapValue;

import com.day.cq.wcm.api.Page;
import com.mysite.core.services.ReadingTimeService;

@Model(adaptables = SlingHttpServletRequest.class,
       defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class ArticleHeader {

    @ValueMapValue
    private String title;

    @ValueMapValue
    private String body;

    @ScriptVariable
    private Page currentPage;

    @OSGiService
    private ReadingTimeService readingTimeService;

    private int readingMinutes;

    @PostConstruct
    protected void init() {
        readingMinutes = readingTimeService.minutesFor(StringUtils.defaultString(body));
    }

    public String getTitle() {
        return StringUtils.isNotBlank(title) ? title : currentPage.getTitle();
    }

    public int getReadingMinutes() {
        return readingMinutes;
    }
}

And its test, loading the JSON fixture from the previous section and mocking the service with Mockito:

package com.mysite.core.models;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

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

import com.mysite.core.services.ReadingTimeService;

import io.wcm.testing.mock.aem.junit5.AemContext;
import io.wcm.testing.mock.aem.junit5.AemContextExtension;

@ExtendWith({AemContextExtension.class, MockitoExtension.class})
class ArticleHeaderTest {

    private static final String PAGE = "/content/mysite/en/article";

    private final AemContext context = new AemContext();

    @Mock
    private ReadingTimeService readingTimeService;

    @BeforeEach
    void setUp() {
        context.addModelsForClasses(ArticleHeader.class);
        context.registerService(ReadingTimeService.class, readingTimeService);
        context.load().json("/com/mysite/core/models/ArticleHeaderTest.json", PAGE);
    }

    @Test
    void usesAuthoredTitleAndReadingTime() {
        when(readingTimeService.minutesFor("Some words here")).thenReturn(3);
        context.currentResource(PAGE + "/jcr:content/root/header");

        ArticleHeader model = context.request().adaptTo(ArticleHeader.class);

        assertNotNull(model);
        assertEquals("Authored Title", model.getTitle());
        assertEquals(3, model.getReadingMinutes());
        verify(readingTimeService).minutesFor("Some words here");
    }

    @Test
    void fallsBackToPageTitleWhenTitleIsEmpty() {
        context.currentResource(PAGE + "/jcr:content/root/header-empty");

        ArticleHeader model = context.request().adaptTo(ArticleHeader.class);

        assertEquals("Page Title", model.getTitle());
    }
}

The things that make this work:

  • Model registration. addModelsForClasses(...) or addModelsForPackage(...) registers models explicitly. Sling Mocks also auto-registers models declared in Sling-Model-Packages/Sling-Model-Classes manifest headers, but being explicit doesn't depend on how the manifest was built.
  • The adaptable matters. Request-adaptable models are adapted from context.request() after setting currentResource; Resource-adaptable ones from the resource itself. The wrong adaptable returns null.
  • @OSGiService finds whatever you registered with context.registerService(...) — a Mockito mock or a real implementation.
  • @ScriptVariable values like currentPage, currentStyle and pageManager come from AEM Mocks' simulated Sling bindings.

Tip: adaptTo() returns a silent null when a model can't be created. When you're debugging, use context.getService(ModelFactory.class).createModel(context.request(), ArticleHeader.class) instead — the Sling docs recommend it because it throws an exception explaining which injection failed.

@Self, @Via and the delegation pattern

Models that use @Via ("resource", ResourceSuperType, ForcedResourceType, ChildResource) or @Self to wrap another model — the Core Components delegation pattern — depend on the Sling Models implementation version, which isn't in the SDK API. That's why the archetype adds org.apache.sling.models.impl to the test classpath and the Core Components mock plugin to AppAemContext. For ResourceSuperType, also include the component definition with its sling:resourceSuperType in your test content.

The AEM Component Generator can emit a JUnit 5 AemContext test alongside a new component's Sling Model — a good starting skeleton.

Testing OSGi services

AemContext includes OSGi Mocks, which reads the Declarative Services metadata (OSGI-INF/*.xml) generated at build time, injects @References, applies configuration, and calls @Activate. The key method is registerInjectActivateService.

@Component(service = ReadingTimeService.class)
@Designate(ocd = ReadingTimeServiceImpl.Config.class)
public class ReadingTimeServiceImpl implements ReadingTimeService {

    @ObjectClassDefinition(name = "My Site - Reading Time")
    public @interface Config {
        @AttributeDefinition(name = "Words per minute")
        int wordsPerMinute() default 200;
    }

    private int wordsPerMinute;

    @Activate
    @Modified
    protected void activate(Config config) {
        this.wordsPerMinute = Math.max(1, config.wordsPerMinute());
    }

    @Override
    public int minutesFor(String text) {
        if (text == null || text.isBlank()) {
            return 0;
        }
        int words = text.trim().split("\\s+").length;
        return (int) Math.ceil(words / (double) wordsPerMinute);
    }
}
@ExtendWith(AemContextExtension.class)
class ReadingTimeServiceImplTest {

    private final AemContext context = new AemContext();

    @Test
    void usesDefaultConfiguration() {
        ReadingTimeService service = context.registerInjectActivateService(new ReadingTimeServiceImpl());
        assertEquals(1, service.minutesFor("one two three"));
    }

    @Test
    void honoursConfiguredWordsPerMinute() {
        ReadingTimeService service = context.registerInjectActivateService(
                new ReadingTimeServiceImpl(), "wordsPerMinute", 2);
        assertEquals(2, service.minutesFor("one two three"));
    }
}

The variants you'll use:

MethodWhat it does
registerService(Type.class, instance)Registers an existing object (often a Mockito mock). No injection, no activation.
registerInjectActivateService(instance)Injects references, activates with defaults, registers.
registerInjectActivateService(instance, "key", value, ...)Same, with config as key/value pairs.
registerInjectActivateService(instance, Map)Same, with config as a map.
registerInjectActivateService(Type.class, ...)Same, but the mock instantiates the class for you.
getService(Type.class)Looks up a registered service.

Config keys follow the normal component property type mapping, so scheduler_expression() in a Config annotation maps to scheduler.expression. Register dependencies first: if a mandatory @Reference can't be satisfied, OSGi Mocks throws a ReferenceViolationException — which is actually a useful test of your wiring.

Important: registerInjectActivateService needs the generated OSGI-INF metadata on the classpath. Maven (via the bnd plugin) produces it during compilation; a bare IDE build may not. If you see NoScrMetadataException, rebuild the module with Maven, or use an IDE with proper Maven integration.

Mockito: when to mock, when to use AEM Mocks

AEM Mocks gives you realistic fakes of the platform; Mockito gives you controllable stand-ins you can stub and verify. Use each for what it's good at:

  • Prefer AEM Mocks for Resource, ValueMap, Page, Asset, Tag, ResourceResolver, OSGi wiring and requests — build real content instead of stubbing getValueMap().get(...) chains.
  • Prefer Mockito for your own service interfaces, for platform services AEM Mocks doesn't implement (Replicator, QueryBuilder, JobManager, Granite workflow objects), for external clients, and for verifying interactions ("was replicate() called with this path?").

The core API is small:

@Mock private Replicator replicator;
@Captor private ArgumentCaptor<String> pathCaptor;

when(readingTimeService.minutesFor("text")).thenReturn(1);         // stub a return value
doThrow(new ReplicationException("boom"))                          // stub a void method
        .when(replicator).replicate(any(), any(), anyString());

verify(replicator).replicate(any(), eq(ReplicationActionType.ACTIVATE), pathCaptor.capture());
assertEquals("/content/mysite/en", pathCaptor.getValue());
verifyNoMoreInteractions(replicator);

Two things trip people up:

  • MockitoExtension uses strict stubs by default: a when(...) stub your test never uses fails the test with UnnecessaryStubbingException. That's a feature — it catches stale setup. When you deliberately stub in a shared @BeforeEach and override per test, mark it with lenient().when(...), as the WKND tutorial does.
  • Don't mock Resource, ValueMap or Page by hand. It's brittle, verbose, and tests your stub rather than your code. If you need to control an adaptation AEM Mocks doesn't know about, use context.registerAdapter(Resource.class, MyType.class, instance).

Testing servlets

The mock request and response are real objects you configure and inspect. Here's a servlet bound to a resource type, selector and extension, delegating to a service:

@Component(service = Servlet.class)
@SlingServletResourceTypes(
        resourceTypes = "mysite/components/search",
        selectors = "results",
        extensions = "json",
        methods = HttpConstants.METHOD_GET)
public class SearchResultsServlet extends SlingSafeMethodsServlet {

    @Reference
    private SearchService searchService;

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws IOException {
        String query = request.getParameter("q");
        if (query == null || query.isBlank()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Missing q");
            return;
        }
        String suffix = request.getRequestPathInfo().getSuffix();
        String scope = suffix != null ? suffix : "/content/mysite";

        List<String> results = searchService.search(query, scope);
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write("{\"count\":" + results.size() + "}");
    }
}
@ExtendWith({AemContextExtension.class, MockitoExtension.class})
class SearchResultsServletTest {

    private final AemContext context = new AemContext();

    @Mock
    private SearchService searchService;

    private SearchResultsServlet servlet;

    @BeforeEach
    void setUp() {
        context.registerService(SearchService.class, searchService);
        servlet = context.registerInjectActivateService(new SearchResultsServlet());

        context.build().resource("/content/mysite/en/search/jcr:content/search",
                "sling:resourceType", "mysite/components/search").commit();
        context.currentResource("/content/mysite/en/search/jcr:content/search");
        context.requestPathInfo().setSelectorString("results");
        context.requestPathInfo().setExtension("json");
    }

    @Test
    void returnsCountScopedBySuffix() throws Exception {
        context.requestPathInfo().setSuffix("/content/mysite/en/products");
        context.request().addRequestParameter("q", "shoes");
        when(searchService.search("shoes", "/content/mysite/en/products"))
                .thenReturn(List.of("a", "b"));

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

        assertEquals(HttpServletResponse.SC_OK, context.response().getStatus());
        assertEquals("{\"count\":2}", context.response().getOutputAsString());
    }

    @Test
    void missingQueryIsBadRequest() throws Exception {
        servlet.doGet(context.request(), context.response());

        assertEquals(HttpServletResponse.SC_BAD_REQUEST, context.response().getStatus());
        verifyNoInteractions(searchService);
    }
}

Keep the test in the servlet's package so you can call the protected doGet directly, as the archetype's SimpleServletTest does. What this does not prove is that Sling resolves the URL to your servlet — check that with an integration test or the Sling Resolver Simulator. For POST handlers, the mock request also supports setMethod(...), setContent(...) and setParameterMap(...).

Testing workflow processes and schedulers

Workflow processes

AEM Mocks doesn't mock the Granite workflow engine, so combine Mockito for WorkItem, WorkflowData and WorkflowSession, a real SimpleMetaDataMap for arguments, and AemContext for the repository. Given a process that stamps a flag on the payload page (the Workflows guide covers the WorkflowProcess anatomy):

@Component(service = WorkflowProcess.class,
           property = "process.label=My Site - Stamp Review Flag")
public class StampReviewProcess implements WorkflowProcess {

    @Override
    public void execute(WorkItem item, WorkflowSession session, MetaDataMap args)
            throws WorkflowException {
        WorkflowData data = item.getWorkflowData();
        if (!"JCR_PATH".equals(data.getPayloadType())) {
            return;
        }
        String property = args.get("PROCESS_ARGS", "reviewed");
        ResourceResolver resolver = session.adaptTo(ResourceResolver.class);
        Resource content = resolver.getResource(data.getPayload() + "/jcr:content");
        if (content == null) {
            return;
        }
        content.adaptTo(ModifiableValueMap.class).put(property, true);
        try {
            resolver.commit();
        } catch (PersistenceException e) {
            throw new WorkflowException("Could not stamp " + content.getPath(), e);
        }
    }
}
@ExtendWith({AemContextExtension.class, MockitoExtension.class})
class StampReviewProcessTest {

    private final AemContext context = new AemContext();
    private final StampReviewProcess process = new StampReviewProcess();

    @Mock private WorkItem workItem;
    @Mock private WorkflowData workflowData;
    @Mock private WorkflowSession workflowSession;

    @BeforeEach
    void setUp() {
        context.create().page("/content/mysite/en/news");
        when(workItem.getWorkflowData()).thenReturn(workflowData);
    }

    @Test
    void stampsConfiguredPropertyOnPayload() throws Exception {
        when(workflowData.getPayloadType()).thenReturn("JCR_PATH");
        when(workflowData.getPayload()).thenReturn("/content/mysite/en/news");
        when(workflowSession.adaptTo(ResourceResolver.class)).thenReturn(context.resourceResolver());
        SimpleMetaDataMap args = new SimpleMetaDataMap();
        args.put("PROCESS_ARGS", "legalReviewed");

        process.execute(workItem, workflowSession, args);

        ValueMap props = context.resourceResolver()
                .getResource("/content/mysite/en/news/jcr:content").getValueMap();
        assertTrue(props.get("legalReviewed", false));
    }

    @Test
    void ignoresNonPathPayloads() throws Exception {
        when(workflowData.getPayloadType()).thenReturn("JCR_UUID");

        process.execute(workItem, workflowSession, new SimpleMetaDataMap());

        verifyNoInteractions(workflowSession);
    }
}

Because adaptTo returns the context's own resolver, whatever the process writes is immediately assertable. Cover the wrong payload type, a missing payload, and the WorkflowException path.

Schedulers

For a whiteboard-style scheduled task (a Runnable service with scheduler.expression), the valuable test is what run() does with its configuration — not whether the cron fires. Say StaleContentReportTask is a @Component(service = Runnable.class) whose Config annotation declares scheduler_expression() and a rootPath(), with a @Reference ReportService. Register its collaborators, activate it with config, and call run():

@Test
void runUsesConfiguredRootPath() {
    context.registerService(ReportService.class, reportService); // Mockito @Mock
    StaleContentReportTask task = context.registerInjectActivateService(
            new StaleContentReportTask(), "rootPath", "/content/other");

    task.run();

    verify(reportService).generateStaleReport("/content/other");
}

The archetype's SimpleScheduledTaskTest goes lighter still: it mocks the Config annotation with Mockito, calls activate(config) and run() directly, and asserts on log output with slf4j-test. If you use the Scheduler API programmatically (scheduler.EXPR(...), scheduler.schedule(...)), mock Scheduler and verify the calls. Remember Sling's scheduler uses Quartz cron syntax, which starts with a seconds field.

What about HTL?

AEM Mocks doesn't render HTL, so keep logic out of HTL and in Sling Models, where it's unit-testable (see the HTL reference). The archetype's ui.apps build runs the htl-maven-plugin validate goal, so HTL syntax errors fail the build — that's your "compile" check. Verify rendered markup with integration or UI tests against a running instance.

Integration tests and UI tests

Unit tests can't prove that bundles resolve, servlets register at the right path, run-mode configs apply, or pages render. That's what the archetype's other two test modules are for.

it.tests — server-side integration tests

The it.tests module holds Java tests that run over HTTP against real author and publish instances. On Cloud Service it uses com.adobe.cq:aem-cloud-testing-clients; for 6.5 the archetype uses cq-testing-clients-65. These are JUnit 4 tests using the CQ testing rules (CQAuthorPublishClassRule, CQRule) and clients like CQClient — the sample GetPageIT simply asserts that /sites.html returns 200 on author.

Locally you run them against your SDK or 6.5 instances with mvn clean verify -Plocal (the local profile points at localhost:4502/4503 with admin credentials, overridable via -Dit.author.url=... etc.).

On AEM as a Cloud Service, Cloud Manager runs these as the Custom Functional Testing step after the stage deployment, on Adobe infrastructure with at least two author and two publish instances plus the Dispatcher. The rules, per Adobe's Java functional testing docs:

  • The module must produce a single JAR built with the maven-assembly-plugin jar-with-dependencies descriptor, with a Cloud-Manager-TestType: integration-test manifest header (the archetype's POM already does this).
  • Only classes whose names end in IT are executed.
  • The tests live in src/main/java, not src/test/java, so they don't run during the normal build's unit-test phase.
  • Adobe recommends keeping the suite to around 15 minutes; there's a hard timeout beyond that.

ui.tests — browser UI tests

The ui.tests module is a Dockerized browser test suite (Cypress in the archetype). Cloud Manager runs it as the Custom UI Testing step, passing AEM_AUTHOR_URL, AEM_PUBLISH_URL, credentials and REPORTS_PATH as environment variables. Adobe also supports Playwright, WebdriverIO and Selenium WebDriver.

Important: UI tests are opt-in. You must add a testing.properties file containing ui-tests.version=1 next to the ui.tests POM and include it in the built Docker-context archive. Without it, Cloud Manager skips the UI test build and execution. The generated archetype project doesn't include this file.

In short: unit tests and the code quality gate run in every pipeline; custom functional and UI tests are required in production pipelines and opt-in for non-production ones. On AEM 6.5 on-prem none of this exists unless you build it: run it.tests against your own environments in your CI. On AMS with Cloud Manager, the code quality gate works the same way (next section).

Code coverage with JaCoCo and the quality gate

Cloud Manager builds your project with:

mvn --batch-mode org.jacoco:jacoco-maven-plugin:prepare-agent package

That means Cloud Manager attaches the JaCoCo agent itself — you don't need JaCoCo in your POM for the pipeline to measure coverage. (Adobe's build-environment docs note it doesn't pin the plugin version; on Java 8 it must be at least 0.7.5.201505241946, and newer Java versions need newer releases.) Coverage mixes line and condition coverage:

Coverage = (CT + CF + LC) / (2 * B + EL)

CT = conditions evaluated to true at least once while running unit tests
CF = conditions evaluated to false at least once while running unit tests
LC = covered lines
B  = total number of conditions
EL = total executable lines

The threshold is 50%, and falling below it is an Important issue: the pipeline pauses and a Deployment Manager, Project Manager or Business Owner can override it (Critical issues, by contrast, stop the pipeline outright). The same formula and threshold apply in Cloud Manager for AEM 6.x (AMS). There's also an Info-level Skipped Unit Tests metric.

The formula counts conditions, not just lines: a happy-path-only suite can hit high line coverage and still miss half the branches — one more reason to write the "empty field" and "bad input" tests.

For a per-class breakdown, measure locally. The archetype doesn't configure JaCoCo, so add it to core/pom.xml:

<plugin>
  <groupId>org.jacoco</groupId>
  <artifactId>jacoco-maven-plugin</artifactId>
  <version>0.8.15</version>
  <executions>
    <execution>
      <id>prepare-agent</id>
      <goals><goal>prepare-agent</goal></goals>
    </execution>
    <execution>
      <id>report</id>
      <phase>test</phase>
      <goals><goal>report</goal></goals>
    </execution>
  </executions>
</plugin>

Run mvn clean test and open core/target/site/jacoco/index.html. If you configure a custom argLine for Surefire, include @{argLine} in it, or you'll overwrite the JaCoCo agent and get zero coverage.

Note: 50% is a floor, not a goal. Chasing 100% produces tests of getters; aim for meaningful coverage of the logic you wrote.

Running tests

GoalCommand
All unit testsmvn clean test
One test classmvn -pl core test -Dtest=ArticleHeaderTest
One test methodmvn -pl core test -Dtest=ArticleHeaderTest#fallsBackToPageTitleWhenTitleIsEmpty
Integration tests locallymvn clean verify -Plocal (in it.tests)

Unit tests also run on every mvn install, and a failure fails the build. In the IDE they run like any JUnit 5 test — just build the module with Maven once so the OSGI-INF metadata and bundle manifest exist.

Common pitfalls

  • Static mocking of platform APIs. Reaching for mockStatic(...) to fake a static helper or PageManager lookup is a design smell — resolver.adaptTo(PageManager.class) already works in AemContext. With the archetype's Mockito 4.1.0, mockStatic also needs mockito-inline (Mockito 5 includes it by default), and an unclosed MockedStatic leaks into other tests — use try-with-resources.
  • Time-dependent code. new Date() or Instant.now() inside logic makes tests flaky around midnight and time zones. Inject a java.time.Clock and use Clock.fixed(...) in tests.
  • Oak startup cost. JCR_OAK takes seconds to boot. Default to RESOURCERESOLVER_MOCK and inject JcrOakAemContext only where needed.
  • null from adaptTo. Usually the model isn't registered, it's adapted from the wrong adaptable, or a required injection failed. Switch to ModelFactory.createModel(...) to see why.
  • JCR API under RESOURCERESOLVER_MOCK. adaptTo(Node.class) and adaptTo(Session.class) return null — switch the type or, better, refactor to the Sling API.
  • Queries returning nothing. RESOURCERESOLVER_MOCK doesn't execute JCR queries, JCR_MOCK needs MockJcr.setQueryResult(...), and JCR_OAK has no Lucene, so full-text search returns nothing. Mock QueryBuilder with Mockito, or push query code behind an interface. For designing the queries themselves, see the Query Builder reference.

Cheat sheet

NeedUse
Enable AEM Mocks@ExtendWith(AemContextExtension.class) + new AemContext()
Add Mockito@ExtendWith({AemContextExtension.class, MockitoExtension.class})
Pick repositorynew AemContext(ResourceResolverType.JCR_MOCK) or a JcrOakAemContext parameter
Load JSON fixturecontext.load().json("/path/Fixture.json", "/content/...")
Create a page/resource/assetcontext.create().page(...) / .resource(...) / .asset(...)
Set current resource/pagecontext.currentResource(path) / context.currentPage(path)
Selectors, extension, suffixcontext.requestPathInfo().setSelectorString/setExtension/setSuffix(...)
Register modelscontext.addModelsForClasses(...) / addModelsForPackage(...)
Adapt a modelcontext.request().adaptTo(M.class) or resource.adaptTo(M.class)
Debug a failing modelcontext.getService(ModelFactory.class).createModel(adaptable, M.class)
Register a mock servicecontext.registerService(Type.class, mock)
Activate a real service with configcontext.registerInjectActivateService(new Impl(), "key", value)
Response assertionscontext.response().getStatus() / getOutputAsString()
Workflow argumentsnew SimpleMetaDataMap() + put("PROCESS_ARGS", ...)
Coverage gate50% (line + condition), Important, overridable
UI tests opt-inui.tests/testing.properties → ui-tests.version=1

Best practices

  • ✅ Test behavior and edge cases — empty fields, missing resources, bad input — not getters.
  • ✅ Default to RESOURCERESOLVER_MOCK; escalate to JCR_MOCK/JCR_OAK only when the code needs it.
  • ✅ Build real content with JSON fixtures and ContentBuilder instead of mocking Resource/ValueMap.
  • ✅ Use Mockito for your own service interfaces and platform services AEM Mocks doesn't provide.
  • ✅ Align transitive Sling versions with the wcm.io AEM dependencies BOM.
  • ✅ Run JaCoCo locally and read the branch coverage, not just line coverage.
  • ✅ Keep it.tests and ui.tests short and focused on critical flows.

Do's and Don'ts

Do

  • ✅ Use ModelFactory.createModel(...) when a model adapts to null.
  • ✅ Register dependencies before registerInjectActivateService — and treat a ReferenceViolationException as a real wiring bug.
  • ✅ Inject a Clock for anything time-based.
  • ✅ Add testing.properties if you want Cloud Manager to run your UI tests.

Don't

  • ❌ Don't hand-mock Resource, ValueMap, Page or ResourceResolver chains.
  • ❌ Don't use mockStatic as a substitute for adaptable, injectable design.
  • ❌ Don't add -DskipTests or maven.test.skip to your POM to get a pipeline green.
  • ❌ Don't write tests just to push coverage past 50% — the formula rewards branches, and so do real bugs.
  • ❌ Don't expect unit tests to prove servlet registration, Dispatcher rules or HTL rendering.

Wrapping up

Unit testing AEM code is no longer a fight with the platform. AemContext gives you a repository, a real-injecting OSGi container and configurable requests; JSON fixtures and ContentBuilder give you realistic content; Mockito covers your own interfaces and the services AEM Mocks doesn't implement. Test models, services, servlets, workflow processes and scheduled tasks at that level, leave wiring and rendering to it.tests and ui.tests, and let JaCoCo show you which branches you've missed before Cloud Manager's 50% gate does.

Continue with the Backend Development guide and Component Development guide for the code under test, the OSGi guide and Sling guide for what AEM Mocks simulates, the Cloud Service guide for the pipeline those tests run in, and the AEM Developer Cheat Sheet for quick reference. To start from a working skeleton, generate a component with its JUnit 5 test in the AEM Component Generator.

Share this article

Discussion

By commenting you agree to the Privacy Policy. Guest comments are reviewed before they appear.

Loading discussion…

Subscribe to the Newsletter

Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

Back to Blog