单元测试 unit-testing
本教程介绍单元测试的实施,该单元测试将验证在自定义组件教程中创建的Byline组件的Sling模型的行为。
先决条件 prerequisites
查看设置本地开发环境所需的工具和说明。
如果系统上同时安装了Java™ 8和Java™ 11,则VS代码测试运行程序在执行测试时可能会选择较低的Java™运行时,从而导致测试失败。 如果发生这种情况,请卸载Java™ 8.
入门项目
查看本教程所基于的基本行代码:
-
从GitHub中签出
tutorial/unit-testing-start
分支code language-shell $ cd aem-guides-wknd $ git checkout tutorial/unit-testing-start
-
使用您的Maven技能将代码库部署到本地AEM实例:
code language-shell $ mvn clean install -PautoInstallSinglePackage
note note NOTE 如果使用AEM 6.5或6.4,请将 classic
配置文件附加到任何Maven命令。code language-shell $ mvn clean install -PautoInstallSinglePackage -Pclassic
您始终可以在GitHub上查看完成的代码,或通过切换到分支tutorial/unit-testing-start
在本地签出代码。
目标
- 了解单元测试的基础知识。
- 了解通常用于测试AEM代码的框架和工具。
- 了解在编写单元测试时模拟或模拟AEM资源的选项。
背景 unit-testing-background
在本教程中,我们将了解如何为署名组件的Sling模型(在创建自定义AEM组件中创建)编写单元测试。 单元测试是用Java™编写的构建时测试,用于验证Java™代码的预期行为。 每个单元测试通常很小,可根据预期结果验证方法(或工作单元)的输出。
我们采用AEM最佳实践,并采用:
单元测试和AdobeCloud Manager unit-testing-and-adobe-cloud-manager
AdobeCloud Manager将单元测试执行和代码覆盖率报告集成到其CI/CD管道中,以帮助鼓励和促进单元测试AEM代码的最佳实践。
虽然单元测试代码是任何代码库的一个良好实践,但在使用Cloud Manager时,通过为Cloud Manager提供单元测试来利用其代码质量测试和报告工具非常重要。
更新测试Maven依赖项 inspect-the-test-maven-dependencies
第一步是检查Maven依赖项以支持编写和运行测试。 需要四个依赖项:
- JUnit5
- Mockito测试框架
- Apache Sling Mocks
- AEM Mocks Test Framework (由io.wcm提供)
使用AEM Maven原型在安装过程中,会自动将 JUnit5、Mockito和 AEM Mocks**测试依赖项添加到项目中。
-
要查看这些依赖项,请打开位于 aem-guides-wknd/pom.xml 的父Reactor POM,导航到
<dependencies>..</dependencies>
并查看<!-- Testing -->
下io.wcm的JUnit、Mockito、Apache Sling Mocks和AEM Mock Tests的依赖项。 -
确保
io.wcm.testing.aem-mock.junit5
设置为 4.1.0:code language-xml <dependency> <groupId>io.wcm</groupId> <artifactId>io.wcm.testing.aem-mock.junit5</artifactId> <version>4.1.0</version> <scope>test</scope> </dependency>
note caution CAUTION 原型 35 生成版本为 4.1.8 的 io.wcm.testing.aem-mock.junit5
项目。 请降级到 4.1.0 以遵循本章的其余部分。 -
打开 aem-guides-wknd/core/pom.xml 并查看相应的测试依赖项是否可用。
core 项目中的并行源文件夹将包含单元测试和任何支持的测试文件。 此 test 文件夹提供了测试类与源代码的分离,但允许测试像它们与源代码位于同一包中一样运行。
创建JUnit测试 creating-the-junit-test
单元测试通常使用Java™类将1映射到1。 在本章中,我们将为 BylineImpl.java 编写JUnit测试,该程序是支持Byline组件的Sling模型。
存储单元测试的位置。
-
通过在Java™包文件夹结构的
src/test/java
下创建一个新的Java™类来为BylineImpl.java
创建单元测试,该结构镜像了要测试的Java™类的位置。因为我们正在测试
src/main/java/com/adobe/aem/guides/wknd/core/models/impl/BylineImpl.java
在上创建相应的单元测试Java™类
src/test/java/com/adobe/aem/guides/wknd/core/models/impl/BylineImplTest.java
单元测试文件
BylineImplTest.java
上的Test
后缀是一个约定,允许我们- 轻松将其标识为
BylineImpl.java
的测试文件 - 但是,还要区分测试文件 和 所测试的类
BylineImpl.java
查看BylineImplTest.java reviewing-bylineimpltest-java
此时,JUnit测试文件是一个空的Java™类。
-
使用以下代码更新文件:
code language-java package com.adobe.aem.guides.wknd.core.models.impl; import static org.junit.jupiter.api.Assertions.*; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; public class BylineImplTest { @BeforeEach void setUp() throws Exception { } @Test void testGetName() { fail("Not yet implemented"); } @Test void testGetOccupations() { fail("Not yet implemented"); } @Test void testIsEmpty() { fail("Not yet implemented"); } }
-
第一个方法
public void setUp() { .. }
使用JUnit的@BeforeEach
进行注释,它指示JUnit测试运行程序在运行该类中的每个测试方法之前执行此方法。 这为初始化所有测试所需的通用测试状态提供了一个方便的位置。 -
后续方法是测试方法,其名称按惯例以
test
为前缀,并以@Test
注释标记。 请注意,默认情况下,我们的所有测试都设置为失败,因为尚未实施它们。首先,我们先对我们测试的类中的每个公共方法使用一个测试方法,因此:
table 0-row-3 1-row-3 2-row-3 3-row-3 BylineImpl.java BylineImplTest.java getName() 测试方 testGetName() getOccupations() 测试方 testGetOccupations() isEmpty() 测试方 testIsEmpty() 这些方法可根据需要进行扩展,我们将在本章的后面部分中看到。
运行此JUnit测试类(也称为JUnit测试用例)时,每个标记为
@Test
的方法将作为测试执行,测试可能会通过或失败。
core/src/test/java/com/adobe/aem/guides/wknd/core/models/impl/BylineImplTest.java
-
通过右键单击
BylineImplTest.java
文件并点按 运行 来运行JUnit测试用例。
如预期的那样,所有测试都会失败,因为它们尚未实施。右键单击BylineImplTests.java > Run
查看BylineImpl.java reviewing-bylineimpl-java
在编写单元测试时,有两种主要方法:
- TDD或测试驱动开发,它涉及在开发实现之前增量写入单元测试;请编写测试,然后编写实现以使测试通过。
- 以实施为先的开发,包括先开发工作代码,然后编写测试来验证该代码。
在本教程中,将使用后一种方法(因为我们在上一章中创建了有效的 BylineImpl.java)。 因此,我们必须回顾和了解其公共方法的行为,同时也要了解其实施细节。 这听起来可能恰恰相反,因为一个良好的测试应该只关注输入和输出,然而在AEM中工作时,为了构建工作测试,需要理解各种实施注意事项。
在AEM的上下文中,TDD需要一定程度的专业知识,并且最适合于精通AEM代码的AEM开发和单元测试的AEM开发人员。
设置AEM测试上下文 setting-up-aem-test-context
大多数为AEM编写的代码依赖于JCR、Sling或AEM API,这反过来又需要运行的AEM的上下文才能正确执行。
由于单元测试是在构建时执行的,因此在正在运行的AEM实例的上下文之外,没有此类上下文。 为此,wcm.io的AEM Mocks创建了模拟上下文,使这些API 大部分 可以像在AEM中运行一样。
-
在 BylineImplTest.java 中使用 wcm.io的
AemContext
创建AEM上下文,方法是将其添加为使用@ExtendWith
修饰的JUnit扩展名到 BylineImplTest.java 文件中。 该扩展会处理所有所需的初始化和清理任务。 为AemContext
创建一个可用于所有测试方法的类变量。code language-java import org.junit.jupiter.api.extension.ExtendWith; import io.wcm.testing.mock.aem.junit5.AemContext; import io.wcm.testing.mock.aem.junit5.AemContextExtension; ... @ExtendWith(AemContextExtension.class) class BylineImplTest { private final AemContext ctx = new AemContext();
此变量
ctx
公开提供一些AEM和Sling抽象的模拟AEM上下文:- BylineImpl Sling模型已注册到此上下文中
- 在此上下文中创建模拟JCR内容结构
- 可在此上下文中注册自定义OSGi服务
- 提供各种常见的必需模拟对象和帮助程序,如SlingHttpServletRequest对象;各种模拟Sling和AEM OSGi服务,如ModelFactory、PageManager、Page、Template、ComponentManager、Component、TagManager、Tag等。
- 并非这些对象的所有方法都已实现!
- 以及更多!
ctx
对象将作为大多数模拟上下文的入口点。 -
在每个
@Test
方法之前执行的setUp(..)
方法中,定义一个常见的模拟测试状态:code language-java @BeforeEach public void setUp() throws Exception { ctx.addModelsForClasses(BylineImpl.class); ctx.load().json("/com/adobe/aem/guides/wknd/core/models/impl/BylineImplTest.json", "/content"); }
addModelsForClasses
将要测试的Sling模型注册到模拟AEM Context中,以便可在@Test
方法中实例化。load().json
将资源结构加载到模拟上下文中,允许代码与这些资源交互,就像它们由真实存储库提供一样。 文件BylineImplTest.json
中的资源定义已加载到 /content 下的模拟JCR上下文中。BylineImplTest.json
尚不存在,因此让我们创建它并定义测试所需的JCR资源结构。
-
表示模拟资源结构的JSON文件存储在 core/src/test/resources 下,遵循与JUnit Java™测试文件相同的包路径。
在
core/test/resources/com/adobe/aem/guides/wknd/core/models/impl
处创建名为 BylineImplTest.json 的JSON文件,该文件包含以下内容:code language-json { "byline": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline" } }
此JSON为Byline组件单元测试定义了一个模拟资源(JCR节点)。 此时,JSON具有表示Byline组件内容资源所需的最小属性集
jcr:primaryType
和sling:resourceType
。处理单元测试时的一般规则是创建满足每个测试所需的最小模拟内容、上下文和代码集。 避免在编写测试之前构建完整的模拟上下文的诱惑,因为这通常会导致不需要的工件。
现在存在 BylineImplTest.json,执行
ctx.json("/com/adobe/aem/guides/wknd/core/models/impl/BylineImplTest.json", "/content")
时,模拟资源定义将加载到路径 /content. 处的上下文中
测试getName() testing-get-name
现在,我们有了基本的模拟上下文设置,让我们为 BylineImpl的getName() 编写第一个测试。 此测试必须确保方法 getName() 返回存储在资源的“name” 属性中的正确编写的名称。
-
按如下方式更新 BylineImplTest.java 中的 testGetName()方法:
code language-java import com.adobe.aem.guides.wknd.core.models.Byline; ... @Test public void testGetName() { final String expected = "Jane Doe"; ctx.currentResource("/content/byline"); Byline byline = ctx.request().adaptTo(Byline.class); String actual = byline.getName(); assertEquals(expected, actual); }
String expected
设置预期值。 我们将此项设置为"Jane Done"。ctx.currentResource
设置模拟资源的上下文以评估代码,因此设置为 /content/byline,因为这是加载模拟署名内容资源的位置。Byline byline
通过从模拟请求对象中调整来实例化署名Sling模型。String actual
在Byline Sling模型对象上调用我们正在测试的方法getName()
。assertEquals
声明预期值与署名Sling模型对象返回的值匹配。 如果这些值不相等,测试将失败。
-
运行测试……但测试失败,出现
NullPointerException
。此测试不会失败,因为我们从未在模拟JSON中定义
name
属性,这将导致测试失败,但测试执行尚未到达该点! 此测试失败,因为署名对象本身存在NullPointerException
。 -
在
BylineImpl.java
中,如果@PostConstruct init()
引发异常,则会阻止Sling模型实例化,并导致该Sling模型对象为空。code language-java @PostConstruct private void init() { image = modelFactory.getModelFromWrappedRequest(request, request.getResource(), Image.class); }
结果发现,虽然ModelFactory OSGi服务是通过
AemContext
(通过Apache Sling Context)提供的,但并非所有方法都已实现,包括在BylineImpl的init()
方法中调用的getModelFromWrappedRequest(...)
。 这会导致AbstractMethodError,它导致init()
失败,因此ctx.request().adaptTo(Byline.class)
的自适应结果为null对象。由于提供的模拟无法容纳我们的代码,因此我们必须自己实现模拟上下文。为此,我们可以使用Mockito创建模拟ModelFactory对象,当对其调用
getModelFromWrappedRequest(...)
时,它会返回模拟Image对象。由于甚至要实例化署名Sling模型,此模拟上下文必须就位,因此我们可以将其添加到
@Before setUp()
方法。 我们还需要将MockitoExtension.class
添加到 BylineImplTest 类上方的@ExtendWith
批注。code language-java package com.adobe.aem.guides.wknd.core.models.impl; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.Mock; import com.adobe.aem.guides.wknd.core.models.Byline; import com.adobe.cq.wcm.core.components.models.Image; import io.wcm.testing.mock.aem.junit5.AemContext; import io.wcm.testing.mock.aem.junit5.AemContextExtension; import org.apache.sling.models.factory.ModelFactory; 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 org.mockito.Mockito.*; import org.apache.sling.api.resource.Resource; @ExtendWith({ AemContextExtension.class, MockitoExtension.class }) public class BylineImplTest { private final AemContext ctx = new AemContext(); @Mock private Image image; @Mock private ModelFactory modelFactory; @BeforeEach public void setUp() throws Exception { ctx.addModelsForClasses(BylineImpl.class); ctx.load().json("/com/adobe/aem/guides/wknd/core/models/impl/BylineImplTest.json", "/content"); lenient().when(modelFactory.getModelFromWrappedRequest(eq(ctx.request()), any(Resource.class), eq(Image.class))) .thenReturn(image); ctx.registerService(ModelFactory.class, modelFactory, org.osgi.framework.Constants.SERVICE_RANKING, Integer.MAX_VALUE); } @Test void testGetName() { ... }
@ExtendWith({AemContextExtension.class, MockitoExtension.class})
将测试用例类标记为使用Mockito JUnit Jupiter扩展运行,该扩展允许使用@Mock注释在类级别定义模拟对象。@Mock private Image
创建类型为com.adobe.cq.wcm.core.components.models.Image
的模拟对象。 这是在类级别上定义的,这样@Test
方法就可以根据需要更改其行为。@Mock private ModelFactory
创建ModelFactory类型的模拟对象。 这是一个纯粹的Mockito模拟,没有实现任何方法。 这是在类级别上定义的,这样@Test
方法就可以根据需要更改其行为。when(modelFactory.getModelFromWrappedRequest(..)
在模拟ModelFactory对象上调用getModelFromWrappedRequest(..)
时为其注册模拟行为。 在thenReturn (..)
中定义的结果是返回模拟图像对象。 仅在以下情况下调用此行为:第一个参数等于ctx
的请求对象,第二个参数是任何Resource对象,第三个参数必须是核心组件Image类。 我们接受任何资源,因为在整个测试中,我们将ctx.currentResource(...)
设置为 BylineImplTest.json 中定义的各种模拟资源。 请注意,我们添加了 lenient() 严格性,因为稍后我们将要覆盖ModelFactory的此行为。ctx.registerService(..)
。 将模拟ModelFactory对象注册到AemContext中,具有最高的服务等级。 这是必需的,因为BylineImpl的init()
中使用的ModelFactory是通过@OSGiService ModelFactory model
字段注入的。 为了使AemContext注入 我们的 模拟对象(该对象处理对getModelFromWrappedRequest(..)
的调用),我们必须将其注册为该类型的最高级别服务(ModelFactory)。
-
重新运行测试,再次失败,但这次消息清楚地表明了失败的原因。
由于断言 , testGetName()失败
我们收到一个 AssertionError,表示测试中的断言条件失败,它告诉我们 预期值为“Jane Doe”,但 实际值为null。 这是有道理的,因为“name” 属性尚未添加到 BylineImplTest.json 中的模拟 /content/byline 资源定义中,因此让我们添加它:
-
更新 BylineImplTest.json 以定义
"name": "Jane Doe".
code language-json { "byline": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "name": "Jane Doe" } }
-
重新运行测试,现在
testGetName()
通过!
测试getOccupations() testing-get-occupations
太好了! 第一个测试已经通过! 让我们继续并测试getOccupations()
。 由于模拟上下文的初始化已在@Before setUp()
方法中完成,因此该测试用例中的所有@Test
方法均可使用此方法,包括getOccupations()
。
请记住,此方法必须返回按字母顺序排序的占有列表(降序)存储在occutions属性中。
-
按如下方式更新
testGetOccupations()
:code language-java import java.util.List; import com.google.common.collect.ImmutableList; ... @Test public void testGetOccupations() { List<String> expected = new ImmutableList.Builder<String>() .add("Blogger") .add("Photographer") .add("YouTuber") .build(); ctx.currentResource("/content/byline"); Byline byline = ctx.request().adaptTo(Byline.class); List<String> actual = byline.getOccupations(); assertEquals(expected, actual); }
List<String> expected
定义预期的结果。ctx.currentResource
将当前资源设置为根据/content/byline处的模拟资源定义评估上下文。 这可确保在模拟资源的上下文中执行 BylineImpl.java。ctx.request().adaptTo(Byline.class)
通过从模拟请求对象中调整来实例化署名Sling模型。byline.getOccupations()
在Byline Sling模型对象上调用我们正在测试的方法getOccupations()
。assertEquals(expected, actual)
声明预期列表与实际列表相同。
-
请记住,与上述
getName()
一样,BylineImplTest.json 未定义占用,因此如果运行该测试,测试将失败,因为byline.getOccupations()
将返回空列表。更新 BylineImplTest.json 以包含职业列表,这些职业按非字母顺序设置,以确保我们的测试验证职业是否按
getOccupations()
的字母顺序排序。code language-json { "byline": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "name": "Jane Doe", "occupations": ["Photographer", "Blogger", "YouTuber"] } }
-
运行测试,再次通过! 好像获取已排序的职业可以正常进行!
testGetOccupations()通过
测试isEmpty() testing-is-empty
上次测试 isEmpty()
的方法。
测试isEmpty()
很有趣,因为它需要测试各种条件。 正在审核 BylineImpl.java 的isEmpty()
方法,必须测试以下条件:
- 当名称为空时返回true
- 当占用为null或空时返回true
- 当图像为null或没有src URL时返回true
- 当名称、占用和图像(带有src URL)存在时,返回false
为此,我们需要创建测试方法,每个测试方法在BylineImplTest.json
中测试特定条件和新的模拟资源结构以驱动这些测试。
此检查允许我们在getName()
、getOccupations()
和getImage()
为空时跳过测试,因为该状态的预期行为是通过isEmpty()
测试的。
-
第一个测试将测试未设置属性的全新组件的状态。
向
BylineImplTest.json
添加新的资源定义,赋予其语义名称“empty”code language-json { "byline": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "name": "Jane Doe", "occupations": ["Photographer", "Blogger", "YouTuber"] }, "empty": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline" } }
"empty": {...}
定义名为“empty”的新资源定义,该定义只具有jcr:primaryType
和sling:resourceType
。请记住,我们在
@setUp
中执行每个测试方法之前将BylineImplTest.json
加载到ctx
中,因此我们在 /content/empty. 的测试中立即可以使用此新资源定义。 -
按如下方式更新
testIsEmpty()
,将当前资源设置为新的"empty"模拟资源定义。code language-java @Test public void testIsEmpty() { ctx.currentResource("/content/empty"); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); }
运行测试并确保测试通过。
-
接下来,创建一组方法,以确保如果任何所需的数据点(名称、占有情况或图像)为空,
isEmpty()
将返回true。对于使用离散模拟资源定义的每个测试,使用不带 — name 的 和 不带 — occupations 的其他资源定义更新 BylineImplTest.json。
code language-json { "byline": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "name": "Jane Doe", "occupations": ["Photographer", "Blogger", "YouTuber"] }, "empty": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline" }, "without-name": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "occupations": "[Photographer, Blogger, YouTuber]" }, "without-occupations": { "jcr:primaryType": "nt:unstructured", "sling:resourceType": "wknd/components/content/byline", "name": "Jane Doe" } }
创建以下测试方法以测试每种状态。
code language-java @Test public void testIsEmpty() { ctx.currentResource("/content/empty"); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); } @Test public void testIsEmpty_WithoutName() { ctx.currentResource("/content/without-name"); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); } @Test public void testIsEmpty_WithoutOccupations() { ctx.currentResource("/content/without-occupations"); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); } @Test public void testIsEmpty_WithoutImage() { ctx.currentResource("/content/byline"); lenient().when(modelFactory.getModelFromWrappedRequest(eq(ctx.request()), any(Resource.class), eq(Image.class))).thenReturn(null); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); } @Test public void testIsEmpty_WithoutImageSrc() { ctx.currentResource("/content/byline"); when(image.getSrc()).thenReturn(""); Byline byline = ctx.request().adaptTo(Byline.class); assertTrue(byline.isEmpty()); }
testIsEmpty()
针对空模拟资源定义进行测试,并断言isEmpty()
为true。针对具有占用但没有名称的模拟资源定义进行
testIsEmpty_WithoutName()
测试。针对名称为但无占用空间的模拟资源定义进行
testIsEmpty_WithoutOccupations()
测试。testIsEmpty_WithoutImage()
针对具有名称和占用情况的模拟资源定义进行测试,但将模拟图像设置为返回空值。 请注意,我们要覆盖setUp()
中定义的modelFactory.getModelFromWrappedRequest(..)
行为,以确保此调用返回的图像对象为null。 Mockito桩模块功能非常严格,并且不需要重复的代码。 因此,我们将模拟设置为lenient
设置,以明确说明我们正在覆盖setUp()
方法中的行为。testIsEmpty_WithoutImageSrc()
针对具有名称和占用情况的模拟资源定义进行测试,但设置模拟图像以在调用getSrc()
时返回空白字符串。 -
最后,编写测试以确保 isEmpty() 在正确配置组件时返回false。 对于这种情况,我们可以重用 /content/byline,它表示完全配置的Byline组件。
code language-java @Test public void testIsNotEmpty() { ctx.currentResource("/content/byline"); when(image.getSrc()).thenReturn("/content/bio.png"); Byline byline = ctx.request().adaptTo(Byline.class); assertFalse(byline.isEmpty()); }
-
现在,运行BylineImplTest.java文件中的所有单元测试,并查看Java™测试报告输出。
在构建过程中运行单元测试 running-unit-tests-as-part-of-the-build
执行单元测试,并需要作为maven构建的一部分通过。 这可确保在部署应用程序之前成功通过所有测试。 执行包或安装等Maven目标会自动调用,并需要通过项目中的所有单元测试。
$ mvn package
$ mvn package
同样,如果将测试方法更改为“失败”,则构建将失败并报告哪些测试失败以及失败原因。
查看代码 review-the-code
在GitHub上查看完成的代码,或在Git分支tutorial/unit-testing-solution
上本地查看和部署代码。