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-testing | Not worth unit-testing |
|---|---|
Sling Model getters, fallbacks, @PostConstruct logic | Plain getters that return an injected field unchanged |
| OSGi service logic and config handling | That OSGi itself can activate a component |
| Servlet behavior per selector/extension/parameter | Dispatcher filters and caching (test those separately) |
| Workflow process decisions and repository writes | The 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
NullPointerExceptionbreaks 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:
| Dependency | Coordinates | Version in archetype 58 | Role |
|---|---|---|---|
| JUnit 5 | org.junit:junit-bom (import) → org.junit.jupiter:junit-jupiter | 5.8.2 | Test engine, @Test, assertions |
| Mockito | org.mockito:mockito-core | 4.1.0 | Mocks, stubbing, verification |
| Mockito + JUnit 5 | org.mockito:mockito-junit-jupiter | 4.1.0 | MockitoExtension, @Mock |
| AEM Mocks | io.wcm:io.wcm.testing.aem-mock.junit5 | 5.5.4 | AemContext, mock AEM/Sling/OSGi/JCR |
| Core Components mock plugin | com.adobe.cq:core.wcm.components.testing.aem-mock-plugin | Core Components version (2.28.0) | Registers what Core Components need in tests |
| Context-Aware Config mock plugin | org.apache.sling:org.apache.sling.testing.caconfig-mock-plugin | 1.4.0 | CA Config support in AemContext |
| Sling Models impl | org.apache.sling:org.apache.sling.models.impl | 1.4.14 (test scope, in core) | Newer injector support for @Self and @Via |
| SLF4J Test | uk.org.lidalia:slf4j-test | 1.0.1 | Assert on log output |
| JUnit Addons | junit-addons:junit-addons | 1.4 | Legacy 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,providedscope, versioned to match the SDK release you target. - AEM 6.5 —
com.adobe.aem:uber-jarat your 6.5 service-pack version. For 6.5.0–6.5.5 the archetype adds theapisclassifier; 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@BeforeAllare 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
AemContextinside@BeforeEach— you'll get duplicate contexts. - Put shared setup in a project-wide factory (the archetype's
AppAemContext) usingAemContextBuilder, plugins, and anafterSetUpcallback.
// 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 itsnewAemContextBuilder(ResourceResolverType)overload accepts a type but builds with a plainnew AemContextBuilder()— so the type you pass is ignored. Usenew 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:
| Type | What it is | JCR API? | Speed | Use when |
|---|---|---|---|---|
RESOURCERESOLVER_MOCK (default) | In-memory resource tree, no JCR mapping | ❌ adaptTo(Node.class) returns null | Fastest | Code that only uses the Sling Resource API — most models and services |
RESOURCEPROVIDER_MOCK | Real Sling resource resolver over a mocked provider | ❌ | Fast, a bit more overhead | Multiple resource providers, loading folders of JSON/FileVault XML |
JCR_MOCK | JCR Mocks in-memory repository + real Sling JCR resource provider | ✅ (limited) | Quite fast | Code that uses Node/Session; queries with pre-set results |
JCR_OAK | Real Jackrabbit Oak on a MemoryNodeStore | ✅ full | Slow start (seconds on first access) | Observation, versioning, real node-type constraints, real JCR-SQL2 queries |
NONE | Real resolver with no provider | — | Depends | Testing your own ResourceProvider |
The tradeoffs matter in practice:
RESOURCERESOLVER_MOCKis the right default and doubles as a design check: if your model needsNodeorSession, ask whether it really should. It handles binaries, dates andnullvalues slightly differently from JCR mapping.JCR_MOCKgives 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 viaMockJcr.setQueryResult(...).JCR_OAKis 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(...)oraddModelsForPackage(...)registers models explicitly. Sling Mocks also auto-registers models declared inSling-Model-Packages/Sling-Model-Classesmanifest 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 settingcurrentResource;Resource-adaptable ones from the resource itself. The wrong adaptable returnsnull. @OSGiServicefinds whatever you registered withcontext.registerService(...)— a Mockito mock or a real implementation.@ScriptVariablevalues likecurrentPage,currentStyleandpageManagercome from AEM Mocks' simulated Sling bindings.
Tip:
adaptTo()returns a silentnullwhen a model can't be created. When you're debugging, usecontext.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:
| Method | What 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:
registerInjectActivateServiceneeds the generatedOSGI-INFmetadata on the classpath. Maven (via the bnd plugin) produces it during compilation; a bare IDE build may not. If you seeNoScrMetadataException, 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 stubbinggetValueMap().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 ("wasreplicate()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:
MockitoExtensionuses strict stubs by default: awhen(...)stub your test never uses fails the test withUnnecessaryStubbingException. That's a feature — it catches stale setup. When you deliberately stub in a shared@BeforeEachand override per test, mark it withlenient().when(...), as the WKND tutorial does.- Don't mock
Resource,ValueMaporPageby 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, usecontext.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-pluginjar-with-dependenciesdescriptor, with aCloud-Manager-TestType: integration-testmanifest header (the archetype's POM already does this). - Only classes whose names end in
ITare executed. - The tests live in
src/main/java, notsrc/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.propertiesfile containingui-tests.version=1next to theui.testsPOM 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 packageThat 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 linesThe 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
| Goal | Command |
|---|---|
| All unit tests | mvn clean test |
| One test class | mvn -pl core test -Dtest=ArticleHeaderTest |
| One test method | mvn -pl core test -Dtest=ArticleHeaderTest#fallsBackToPageTitleWhenTitleIsEmpty |
| Integration tests locally | mvn 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 orPageManagerlookup is a design smell —resolver.adaptTo(PageManager.class)already works in AemContext. With the archetype's Mockito 4.1.0,mockStaticalso needsmockito-inline(Mockito 5 includes it by default), and an unclosedMockedStaticleaks into other tests — use try-with-resources. - Time-dependent code.
new Date()orInstant.now()inside logic makes tests flaky around midnight and time zones. Inject ajava.time.Clockand useClock.fixed(...)in tests. - Oak startup cost.
JCR_OAKtakes seconds to boot. Default toRESOURCERESOLVER_MOCKand injectJcrOakAemContextonly where needed. nullfromadaptTo. Usually the model isn't registered, it's adapted from the wrong adaptable, or a required injection failed. Switch toModelFactory.createModel(...)to see why.- JCR API under
RESOURCERESOLVER_MOCK.adaptTo(Node.class)andadaptTo(Session.class)returnnull— switch the type or, better, refactor to the Sling API. - Queries returning nothing.
RESOURCERESOLVER_MOCKdoesn't execute JCR queries,JCR_MOCKneedsMockJcr.setQueryResult(...), andJCR_OAKhas no Lucene, so full-text search returns nothing. MockQueryBuilderwith Mockito, or push query code behind an interface. For designing the queries themselves, see the Query Builder reference.
Cheat sheet
| Need | Use |
|---|---|
| Enable AEM Mocks | @ExtendWith(AemContextExtension.class) + new AemContext() |
| Add Mockito | @ExtendWith({AemContextExtension.class, MockitoExtension.class}) |
| Pick repository | new AemContext(ResourceResolverType.JCR_MOCK) or a JcrOakAemContext parameter |
| Load JSON fixture | context.load().json("/path/Fixture.json", "/content/...") |
| Create a page/resource/asset | context.create().page(...) / .resource(...) / .asset(...) |
| Set current resource/page | context.currentResource(path) / context.currentPage(path) |
| Selectors, extension, suffix | context.requestPathInfo().setSelectorString/setExtension/setSuffix(...) |
| Register models | context.addModelsForClasses(...) / addModelsForPackage(...) |
| Adapt a model | context.request().adaptTo(M.class) or resource.adaptTo(M.class) |
| Debug a failing model | context.getService(ModelFactory.class).createModel(adaptable, M.class) |
| Register a mock service | context.registerService(Type.class, mock) |
| Activate a real service with config | context.registerInjectActivateService(new Impl(), "key", value) |
| Response assertions | context.response().getStatus() / getOutputAsString() |
| Workflow arguments | new SimpleMetaDataMap() + put("PROCESS_ARGS", ...) |
| Coverage gate | 50% (line + condition), Important, overridable |
| UI tests opt-in | ui.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 toJCR_MOCK/JCR_OAKonly 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.testsandui.testsshort and focused on critical flows.
Do's and Don'ts
Do
- ✅ Use
ModelFactory.createModel(...)when a model adapts tonull. - ✅ Register dependencies before
registerInjectActivateService— and treat aReferenceViolationExceptionas a real wiring bug. - ✅ Inject a
Clockfor anything time-based. - ✅ Add
testing.propertiesif you want Cloud Manager to run your UI tests.
Don't
- ❌ Don't hand-mock
Resource,ValueMap,PageorResourceResolverchains. - ❌ Don't use
mockStaticas a substitute for adaptable, injectable design. - ❌ Don't add
-DskipTestsormaven.test.skipto 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.
Discussion
Loading discussion…
Try a related tool
Subscribe to the Newsletter
Get the latest articles, tutorials, and tech insights delivered straight to your inbox. No spam, unsubscribe anytime.

