Unit Testing Using AEM Mocks Detecting Code Flaws Before They Cause Outages for Your Users
Dec 09, 2024
Faulty code isn’t just a pain for the development team; it can also negatively impact the user experience. Businesses using Adobe Experience Manager as a Cloud Service (AEMaaCS) must ensure that their website features function properly to deliver the best digital experience to their customers. That functionality can be ensured with unit testing and AEM Mocks.
By simulating real-world scenarios and identifying code flaws early, AEM Mocks allows your development teams to catch issues that could otherwise disrupt user experiences, ensuring that your platform remains reliable and high-performing.
In this post, we’ll explore the basics of unit testing and walk through how to perform unit tests in AEM.
Prerequisites
This post will be particularly useful for AEM developers who have the following characteristics:
-
Understands/has experience with basic AEM concepts like:
-
HTL
-
AEM Dialogs
-
Sling Models
-
OSGi services
-
-
Has an Adobe AEM instance already up and running.
-
Knows how to build and deploy an AEM project to an AEM instance.
-
Has a project based on the AEM Project Archetype that builds and deploys correctly to an AEM instance.
-
Basic knowledge about Unit Testing and Assertions.
For this example, we will work inside the WKND project, commonly available to any AEM instance.
Unit Testing Basics
What Is Unit Testing?
As the name suggests, unit testing is the process of testing units, where units are the smallest functional and testable pieces of code in a project.
Unit tests automatically detect code flaws in the early stages of development, giving more time to fix bugs and, if implemented correctly, improving code quality. (If a project is configured to error out and not compile if unit tests do not pass, it makes it harder for developers to introduce bugs).
Unit Testing in AEM
In the AEMaaCS world, Unit Testing development became a requisite as the Adobe Cloud Manager CI/CD pipeline integrates Unit Test execution and coverage reports.
The AEM Maven Archetype automatically includes 3 Testing Frameworks in its parent POM:
Where are Unit Tests Located?
Unit tests are located under:
src/test/java
The classes to be tested are located under:
src/main/java
Unit Tests Naming
Unit Test classes follow almost the same fully qualified name (package and class name) as the class to be tested, but per convention, add a “Test” suffix to the class name.
Example
So, if the fully qualified name of the class to be tested is:
src/main/java → com.pckg1.pckg2.pckg3.myClass
Then its unit test fully qualified name needs to be:
src/test/java → com.pckg1.pckg2.pckg3.myClassTest
Unit Testing in AEM (io.wcm.testing.mock.aem)
Characteristics of io.wcm.testing.mock.aem library:
-
It provides classes that enable an in-memory AEM environment, which allows developers to Unit Test an AEM project functionality (around 90% of it at least).
-
It is compatible with Mockito
-
It is compatible with AEM 6.4, 6.5 and AEMaaCS
Notes:
-
Transitive dependencies should not be excluded
-
Use transitive dependencies according to the AEM being used (look at AEM Dependencies | wcm.io)
-
Some plugins are available; check the List of AEM Mock Context Plugins
1. Maven Dependency
<dependency>
<groupId>io.wcm</groupId>
<artifactId>io.wcm.testing.aem-mock.junit5</artifactId>
<version>4.1.9</version>
<scope>test</scope>
</dependency>
Note: AEM Mocks dependencies should be defined in the POM before AEM API dependency.
2. junit5.AemContext
The AEMContext class acts like a virtual AEM sandbox environment. It gets instantiated as a regular object but requires a resource type to be specified:
AemContext aemContext = new
AemContext(ResourceResolverType.ONE_OF_THE_AVAILABLE_TYPES);
The current available
ResourceResolverType (AEM Mocks JUnit 5 4.1.9-SNAPSHOT API) are:
ENUM Constant | Description | Provides |
---|---|---|
RESOURCERESOLVER_MOCK | Uses Sling "resourceresolver-mock" implementation, no underlying JCR repository. It is the default type when no value is specified. |
Sling API |
JCR_MOCK | Uses a simple JCR "in-memory" mock as the underlying repository. It’s slower than RESOURCERESOLVER_MOCK. |
Sling API & JCR API |
JCR_OAK | Uses a real JCR Jackrabbit Oak repository. It’s the slowest. |
Sling API & JCR API & Observation & JCR Queries |
NONE | Provides resource resolver environment without any ResourceProvider. It’s the fastest option due to being lightweight. Best suited for tests that do not require a resource resolver, like tests of pure Java logic, and that require a lot of test cases. |
Nothing |
The AEMContext class allows complex operations like:
Load JSON Content Structures and Map Them to Resource Paths
aemContext.load().json("/path/to/content-to-test.json",
"/content/testingName");
Note: the JSON content file must be placed inside project/src/test/resources
.
Create Mock Resources
aemContext.create().resource("resourcePath", propertiesMap) →
check other implementation of the method
Create Mock Pages
Page rootPage =
aemContext.create().page("/content/rootSitePage");
aemContext.create().page("/content/rootSitePage/childPage");
Create Mock Assets
Asset asset = context.create().asset("/content/dam/myAsset.jpg",
60, 90, "image/jpeg");
Set a Mock Resource as the Active One Been Tested
aemContext.currentResource("/path/to/my/resource");
Register Mock Services
aemContext.registerService(MyService.class, new
MyServiceImpl());
Instantiate Sling Models and Services using adapTo()
aemContext.request().adaptTo(MyAdaptableSlingModel.class)
aemContext.resourceResolver().adaptTo(MyService.class);
Mock Sling Requests and Responses
SlingHttpServletRequest request = aemContext.request();
context.request().setAttribute("attributeName", "attributeValue");
SlingHttpServletResponse response = aemContext.response();
Get Mocked Common Used Services
ResourceResolver resolver = aemContext.resourceResolver();
PageManager pageManager = aemContext.pageManager();
Set a Mock WCM Mode
aemContext.request().setAttribute(WCMMode.class.getName(),
WCMMode.EDIT);
3.junit5.AemContextExtension
The AemContextExtension class is a JUnit 5 extension that allows injecting AemContext (or subclasses of it) parameters in test methods. It ensures that the context is set up and torn down properly for each test method, providing test utility methods like afterAll, afterEach, afterTestExecution, and beforeAll.
Basic Common Development Use Case - Dialog Field or Parameter Value Used For Business Logic
Let’s work with a requirement that involves common AEM concepts:
Requirement
“A company that sells Tour Packages wants to show a list of the tour packages available on their site. When a user clicks on a package of the list, the tours in that package are shown, and the URL of that specific search needs to be able to be bookmarked or shared with users the results they request. The component must be able to pre-filter by an initial category selected during the authoring phase.”
The Solution in AEM
“The AEM developer is requested to create a component that reads a list of available tours from a service. Those tours are classified into different categories; every tour can belong to more than one category. Every tour package is offered as a group of tours in the same category. The component should show the currently selected category by the user, and the authors should be able to edit the component to select the initial tour category/package shown on the page.”
These are a couple of examples of how Tour Packages will be shown:
Water Tour Package
Mountain Tour Package
To solve this requirement, the developer will need to create:
-
An OSGi service that provides the tour data.
-
A Sling model that reads data from the OSGi Service and provides filtered data based on dialog configuration or values selected by the user.
-
An AEM Component that shows the tour data.
-
A dialog to configure the component's initial behavior.
Step By Step
-
An OSGi service that provides the tour data. In real life, data will come from a data repository, such as an RDBMS (Relational Database), Mongo DB, JSON file, REST API, etc. In this case, we are burning some data in code for example purposes.
@Slf4j @Component(immediate = true, service = TourService.class) public class TourServiceImpl implements TourService { public TourServiceImpl() { } @Override public List<Tour> getTours(){ List<Tour> tours = new ArrayList<>(); List<String> categories = Arrays.asList(CATEGORY_WATER,CATEGORY_NATIONAL_PARK,CATEGORY_ADVENTURE); Tour currentTour = new TourImpl( categories, "Arenal Volcano lake paddle board and hot spring water", "We will paddleboard in the lake next to the volcano during the morning and finish the day in the hot spring waters", 350.0, "https://www.sinac.go.cr/ES/ac/BannersParques/Volcan-Arenal-eng.jpg" ); tours.add(currentTour); categories = Arrays.asList(CATEGORY_WATER,CATEGORY_NATIONAL_PARK,CATEGORY_ADVENTURE); currentTour = new TourImpl( categories, "Surfing Day at Witches Rock", "We take an 1.5h boat ride to spend the day surfing at the mythical Witches Rock!", 250.0, "https://www.sinac.go.cr/ES/ac/BannersParques/BannersIngles/santa-rosa-Ing.jpg" ); tours.add(currentTour); categories = Arrays.asList(CATEGORY_MOUNTAIN,CATEGORY_NATIONAL_PARK,CATEGORY_ADVENTURE); currentTour = new TourImpl( categories, "Hike Chirripo National Park", "We will hike around 20/25km per day and will sleep two nights in the mountains refugee, so we can get to the top of highest mountain of the country", 600.0, "https://www.sinac.go.cr/ES/ac/BannersParques/BannersIngles/chirripo-Ing.jpg" ); tours.add(currentTour); return tours; } }
-
A Sling model that reads data from the OSGi Service and provides filtered data based on dialog configuration or values selected by the user.
As you can see, there are two ways to provide data for filtering:
-
a dialogCategory that can be set directly in the component dialog, so a specific category can shown as initial data
-
a paramCategory that can be sent from HTL. When a user selects a Category/Package, the page can be reloaded, and the sling model will use that value for filtering.
@Model( adaptables = {SlingHttpServletRequest.class, Resource.class }, adapters = TourPackage.class, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL, resourceType = TourPackageImpl.RESOURCE_TYPE ) public class TourPackageImpl implements TourPackage { protected static final String RESOURCE_TYPE = "ROOT/COMPONENT/PATH/availabletours/v1/availabletours"; @Inject String paramCategory; @ValueMapValue @Default(values="") String dialogCategory; @OSGiService TourService serviceTours; @PostConstruct private void init() { } @Override public List<Tour> getAllTours(){ return serviceTours.getTours(); } @Override public String getParamCategory() { return paramCategory; } @Override public String getDialogCategory() { return dialogCategory; } @Override public List<Tour> getToursByCategory(){ String selectedCategory = EMPTY; if(isNotEmpty(paramCategory)) { selectedCategory = paramCategory; } else { if (isNotEmpty(dialogCategory)) { selectedCategory = dialogCategory; } } return getToursByCategory(selectedCategory); } @Override public List<Tour> getToursByCategory(final String selectedCategory) { if(isNotEmpty(selectedCategory)) { return serviceTours.getTours().stream().filter( tour -> tour.getCategories().stream().anyMatch( category -> category.equalsIgnoreCase(selectedCategory) ) ).collect(Collectors.toList()); } else { return new ArrayList<>(); } } @Override public List<String> getCategories(){ HashSet<String> categories = new HashSet<>(); for (Tour Tour: serviceTours.getTours() ) { categories.addAll(Tour.getCategories()); } List<String> categoriesList = new ArrayList<>(categories); Collections.sort( categoriesList ); return categoriesList; } }
-
-
An AEM Component that shows the tours data
<div data-sly-use.tourList="${'com.oshyn.blogs.unittesting.models.TourPackage'}"> <ul class="nav"> Tour Packages: <div data-sly-list.category="${tourList.categories}"> <li><a href="?category=${category}">${category}</a></li> </div> </ul> <div data-sly-test.toursByCategory="${tourList.toursByCategory}"> <div data-sly-list.tour="${toursByCategory}"> <ol> <hr> <h3>${tour.tourName}</h3> <img src="${tour.imageReference}" alt="${tour.tourName}" width="300" height="100" /> <li>Description: "${tour.description}"</li> <li>Price: $"${tour.price}"</li> <li>Type of Tour: <div data-sly-list.category="${tour.categories}"> <a href="?category=${category}">${category}</a> </div> </li> </ol> </div> </div> </div>
-
A dialog to configure the component's initial behavior (category values are burned in the dialog as per simplicity, but they should be dynamic, but that’s outside of the scope of this blog).
-
Note: the AEM component is created at
ROOT/COMPONENT/PATH/tourpackages/v1/tourpackages
<?xml version="1.0" encoding="UTF-8"?> <jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:granite="http://www.adobe.com/jcr/granite/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0" jcr:primaryType="nt:unstructured" jcr:title="Tour Packages" sling:resourceType="cq/gui/components/authoring/dialog"> <content jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/container"> <items jcr:primaryType="nt:unstructured"> <tabs ...> <items jcr:primaryType="nt:unstructured"> <tourCategories jcr:primaryType="nt:unstructured" jcr:title="Select the initially selected Tour Package" sling:resourceType="granite/ui/components/coral/foundation/container" margin="{Boolean}true"> <items jcr:primaryType="nt:unstructured"> <columns ...> <items jcr:primaryType="nt:unstructured"> <column ...> <items jcr:primaryType="nt:unstructured"> <categories ...> <items jcr:primaryType="nt:unstructured"> <dialogCategory jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/form/select" fieldLabel="Category" name="./dialogCategory" granite:class="foundation-toggleable"> <items jcr:primaryType="nt:unstructured"> <def jcr:primaryType="nt:unstructured" text="(default)" value=""/> <water jcr:primaryType="nt:unstructured" text="Water" value="Water"/> <nationalpark jcr:primaryType="nt:unstructured" text="National Park" value="National Park"/> <adventure jcr:primaryType="nt:unstructured" text="Adventure" value="Adventure"/> <mountain jcr:primaryType="nt:unstructured" text="Mountain" value="Mountain"/> </items> </dialogCategory> </items> </categories> </items> </column> </items> </columns> </items> </tourCategories> <cq:styles jcr:primaryType="nt:unstructured" sling:resourceType="granite/ui/components/coral/foundation/include" path="/mnt/overlay/cq/gui/components/authoring/dialog/style/tab_edit/styletab"/> </items> </tabs> </items> </content> </jcr:root>
Unit Tests for Use Case
The goal is always to automatically verify that 100% of the code works as expected, but testing every single line of code can be challenging. Lines for edge cases might require a lot of time mocking data to simulate the scenario, so at least 80% is acceptable.
Let's Start Testing a Sling Model (TourPackage)
This Sling Model should be able to grab an initial filtering value from the AEM authoring dialog, so some specific AEM capabilities to mock that behavior are required.
1. Creating Mocked Page Content
core/src/test/resources/com/oshyn/blogs/unittesting/models/ToursTest.json
{
"tours-page-dialog-value-water": {
"jcr:primaryType": "nt:unstructured",
"sling:resourceType": "ROOT/COMPONENT/PATH/tourpackages/v1/tourpackages",
"dialogCategory": "Water"
},
"tours-page-empty-dialog": {
"jcr:primaryType": "nt:unstructured",
"sling:resourceType": "ROOT/COMPONENT/PATH/tourpackages/v1/tourpackages"
}
}
2. Setting the Testing Environment for All Tests
To set up an AEM mock environment, some basic steps need to be done:
-
Instantiate the current AEM Mocking Context with one of the
ResourceResolverTypes
discussed before, according to the requirements of the test -
Use the
@BeforeEach
to set a common group of operations required for all consequent tests, like:-
Loading mocked resources
-
Register and inject in the context services required by the class to be tested
package com.oshyn.blogs.unittesting.models; import com.oshyn.blogs.unittesting.models.v1.TourImpl; import com.oshyn.blogs.unittesting.models.v1.TourPackageImpl; import com.oshyn.blogs.unittesting.services.impl.v1.TourServiceImpl; import io.wcm.testing.mock.aem.junit5.AemContext; import io.wcm.testing.mock.aem.junit5.AemContextExtension; import org.apache.sling.testing.mock.sling.ResourceResolverType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import static org.junit.jupiter.api.Assertions.*; import static com.oshyn.blogs.unittesting.models.TourCategories.*; //other imports @ExtendWith({AemContextExtension.class}) public class TourPackageTest { //instantiates the current AEM Mocking Context private final AemContext aemContext = new AemContext(ResourceResolverType.RESOURCERESOLVER_MOCK); public static final String PARAMETER_CATEGORY = "paramCategory"; TourPackage tourPackage; @BeforeEach public void setUp(){ //load the JSON file that mocks the page content and assigning it to a "testing" content path aemContext.load().json( "/com/oshyn/blogs/unittesting/models/ToursTest.json", "/content" ); //register and inject a tour service in the current context aemContext.registerInjectActivateService(new TourServiceImpl()); } //test methods will come here }
-
3. Testing Methods That Only Require the Sling Model and its Dependencies
The TourPackage
Sling Model can be adapted from/to a SlingHttpServletRequest
, and the context can provide such an object, so it’s a matter of adapting it to use it.
@Test void tes_getCategories(){
TourPackage tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
assertFalse(tourPackage.getCategories().isEmpty());
}
@Test void test_getAllTours(){
TourPackage tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
assertFalse(tourPackage.getAllTours().isEmpty());
}
4. Testing Getting a Dialog Field Value
As the category dialog value needs to be tested, it must load the specific content set from the JSON-mocked data.
@Test void test_getDialogCategory(){
//loading the specific content set from the JSON mocked data
aemContext.currentResource("/content/tours-page-dialog-value-water");
tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
assertEquals(CATEGORY_WATER, tourPackage.getDialogCategory());
}
5. Testing Getting a Request Parameter
The category parameter value needs to be tested, so the parameter and its value must be set in the current AEM context request.
@Test void test_getParamCategory(){
aemContext.request().setAttribute(PARAMETER_CATEGORY, CATEGORY_MOUNTAIN);
tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
assertEquals(CATEGORY_MOUNTAIN, tourPackage.getParamCategory());
}
6. Testing Logic Based on a Parameter Value
As the logic of filtering data based on a parameter value needs to be tested, the specific content set from the JSON mocked data must be loaded, and the parameter and its value must be set in the current AEM context request. After that, the expected result is compared with the values returned by the filtering method.
@Test void test_getToursByCategory_from_parameter(){
aemContext.currentResource("/content/tours-page-dialog-value-water");
aemContext.request().setAttribute(PARAMETER_CATEGORY, CATEGORY_MOUNTAIN);
tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
//loading expected data
List<Tour> expectedTours = new ArrayList<>();
List<String> categories = Arrays.asList(CATEGORY_MOUNTAIN,CATEGORY_NATIONAL_PARK,CATEGORY_ADVENTURE);
Tour currentTour = new TourImpl(
categories,
"Hike Chirripó National Park",
"We will hike around 20/25km per day and will sleep two nights in the mountains refugee, so we can get to the top of highest mountain of the country",
600.0,
"https://www.sinac.go.cr/ES/ac/BannersParques/BannersIngles/chirripo-Ing.jpg"
);
expectedTours.add(currentTour);
//testing against expected values
assertFalse(tourPackage.getToursByCategory().isEmpty());
assertEquals(tourPackage.getToursByCategory().size(), expectedTours.size());
assertEquals(tourPackage.getToursByCategory().get(0).getCategories(), expectedTours.get(0).getCategories());
assertEquals(tourPackage.getToursByCategory().get(0).getTourName(), expectedTours.get(0).getTourName());
}
7. Testing Getting an Invalid Parameter Value
Because an invalid category parameter value must be tested, the parameter and its value must be set in the current AEM context request.
@Test
void test_getToursByCategory_from_invalid_parameter(){
aemContext.request().setAttribute(PARAMETER_CATEGORY, EMPTY);
aemContext.currentResource("/content/tours-page-empty-dialog");
tourPackage = aemContext.request().adaptTo(TourPackageImpl.class);
assertNotNull(tourPackage);
assertTrue(tourPackage.getToursByCategory().isEmpty());
}
In this way, we have a coverage of 100% of the methods (10 of 10) and 96% of the lines (24 of 25) of the SlingModel, which should be more than enough to consider the Unit testing as complete.
Wrapping Up
Errors are part of human nature, so they cannot be eliminated but avoided using techniques like unit testing.
Developers can create unit tests in AEM using the io.wcm.testing.mock.aem
library, as it provides a way to Mock almost the full AEM Context, allowing developers to create tests inside an entirely mocked AEM ecosystem.
However, what's your solution if you don’t have a team of developers with AEM experience? Oshyn is an experienced AEM partner and can help with every aspect of AEM, from development and implementation to continuous maintenance. Contact us to find out how we can help with your AEM needs.
Further Reading
Related Insights
-
Oshyn
-
-
Esteban Bustamante
How to Add Functionality to your AEM Site
When the Standard Features aren't Adequate
-
-
Francisco Cornejo
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.