01 — Test the model's contract, not its getters
Start by writing down what makes the model valid. Which authored fields are required? Which dependencies must be available? What should an empty optional field render? A suite that only compares a getter with the same fixture value can miss the failures that actually break a page.
Separate content absence from operational failure. A missing optional image might produce no image markup. A required pricing service that cannot be injected is a different condition. Making every injection optional merely to get a green test hides that distinction.
02 — Make failed adaptation observable
Sling's adaptTo() contract returns null when adaptation fails; ModelFactory.createModel() exposes exceptions. Use ModelFactory when a test needs to distinguish missing injections from an invalid adaptable or initialization failure. Match the adaptable declared by the model: a request-backed model is not interchangeable with a resource-backed model.
Use the dependency versions compatible with your AEM target. The example is a minimal JUnit 5 / AEM Mocks fixture for a resource-adaptable model with required title injection, not a test of a deployed repository. Keep each test's fixture independent.
@ExtendWith(AemContextExtension.class)
class TitleModelTest {
private final AemContext context = new AemContext();
@Model(adaptables = Resource.class)
public static class TitleModel {
@ValueMapValue private String title;
public String getTitle() { return title; }
}
@BeforeEach
void registerModel() {
context.addModelsForClasses(TitleModel.class);
}
@Test
void missingRequiredTitleIsAnAdaptationFailure() {
Resource resource = context.create().resource("/content/missing");
ModelFactory factory = context.getService(ModelFactory.class);
assertNotNull(factory);
assertThrows(MissingElementsException.class,
() -> factory.createModel(resource, TitleModel.class));
}
@Test
void authoredTitleIsExposed() {
Resource resource = context.create().resource(
"/content/complete", "title", "Engineering notes");
TitleModel model = context.getService(ModelFactory.class)
.createModel(resource, TitleModel.class);
assertEquals("Engineering notes", model.getTitle());
}
}03 — Keep fixtures smaller than the failure
AEM Mocks provides an AemContext and JUnit 5 extension for registering models, creating resources and supplying services. Prefer a minimal resource tree for behavior tests, then add a small number of representative JSON fixtures when hierarchy matters. A large copied content export makes accidental dependencies difficult to notice.
For a request model, set the current resource and request inputs explicitly before adaptation. Register a deterministic service double when the model delegates to a service; do not reach a real HTTP endpoint from a model unit test. Test the service's timeout behavior separately from the model's handling of its result.
- Required property missing: fail adaptation or surface a deliberately specified error.
- Optional child missing: return the documented empty state.
- Malformed authored value: reject, normalize or fall back according to an explicit contract.
- Required OSGi service absent: verify adaptation fails instead of silently inventing data.
- Service returns empty or throws: test the UI-facing behavior without suppressing diagnostics.
04 — Know what mocks cannot prove
A passing unit test does not establish bundle resolution, service-user mappings, actual ACLs, Dispatcher behavior or an Oak query plan. Choose the mock resolver type intentionally and retain an integration test for assumptions that depend on the repository or deployed OSGi environment.
For an upgrade, use the same content contract tests before and after the dependency change, then validate runtime wiring in the target AEM installation. The AEM 6.5 LTS case study explains why application tests and platform compatibility are complementary evidence.
05 — Review failures as part of the release
Make assertion messages explain the intended behavior. Avoid assertions on private field names or the number of internal helper calls unless those calls define a resource or performance contract. Refactoring should not require rewriting tests that describe unchanged behavior.
Repository ownership deserves a separate check: the ResourceResolver lifecycle note covers cleanup and request scope. When a dependency exists in the unit fixture but not in AEM, follow the OSGi diagnostic sequence rather than adding more mocks.
- Run isolated model tests in the build before packaging.
- Validate JSON exporter shape if a frontend consumes the model as an API.
- Exercise missing and malformed content in an author/publish integration environment.
- Document which runtime guarantees remain outside the unit suite.