CUBA 应用程序中的 Web 集成测试

在本指南中,您将了解 CUBA 应用程序中的自动化测试如何工作。重点对 Web 层 UI 的集成测试进行介绍。

将要构建的内容

本指南对 CUBA 宠物诊所 示例进行了增强,已演示如何使用 Web 集成测试对现有功能实现自动化测试:

  • Owner 浏览→编辑 界面的交互

  • 发送疾病预警邮件

开发环境要求

您的开发环境需要满足以下条件:

下载 并解压本指南的源码,或者使用 git 克隆下来:

概述

在这个关于测试的第三个指南中,您将了解到如何测试用户界面中的逻辑。这与我们之前介绍的中间件集成测试(见 CUBA guide: middleware integration testing)非常相似,需要创建一个接近于生产的测试环境来运行测试。

区别在于,所提供的环境仅启动架构图中的 客户端层 ,它只是 CUBA 应用程序的一部分。

AppTiers

Web 客户端块 是 web 集成测试的被测试部分。

另一方面,Middleware block 不是测试环境的组成部分。这个模块中的所有功能都使用存根实现来提供。包括 CUBA 标准 API ,比如 DataManager 和包含业务逻辑的自定义服务。

WEB 集成测试环境

在这种环境下,就像中间件集成测试一样,生产应用只是部分启动。所有基于 CUBA 平台的 UI API 的工作方式与在应用程序的生产代码中一样。在 Web 客户端块 中,我们有很大一部分界面是使用 XML 声明式定义的,我们也会对这种界面定义进行测试。

覆盖范围示例

为了理解覆盖范围的问题,我们来看看 Petclinic 应用中的一个示例,并且识别出哪些部分会覆盖到、哪些是要模拟的,以及哪些部分测试环境根本不会覆盖到。

比如,我们可以到 Petclinic 中的 Owner 列表界面,它由 XML 描述及 UI 控制器组成。下面列出的内容通常会像在生产环境一样被执行,所以我们不会介绍覆盖范围内的所有所有内容,只介绍与集成测试相关的重点内容。

pet-browse.xml
<window>
    <data readOnly="true">
        <collection id="petsDc"
                    class="com.haulmont.sample.petclinic.entity.pet.Pet"
                    view="pet-with-owner-and-type-and-visits">
            <loader id="petsDl">
                <query>
                    <![CDATA[select e from petclinic_Pet e]]> (1)
                </query>
            </loader>
        </collection>
    </data>
    <layout expand="petsTable"
            spacing="true">
        <groupTable id="petsTable"
                    dataContainer="petsDc"
                    width="100%">
            <actions>
                <action id="calculateDiscount"
                    trackSelection="true"
                    caption="msg://calculateDiscount"
                    icon="MONEY"
                  />
                  <action id="createDiseaseWarningMailing"
                    caption="msg://createDiseaseWarningMailing"
                    icon="font-icon:BULLHORN"
                  />
            </actions>
            <buttonsPanel id="buttonsPanel"
                          alwaysVisible="true">
                <button id="calculateDiscountBtn"
                        action="petsTable.calculateDiscount"
                        /> (2)
                <button id="createDiseaseWarningMailingBtn"
                        action="petsTable.createDiseaseWarningMailing"/>
            </buttonsPanel>
        </groupTable>
    </layout>
</window>
1 数据加载机制可用,但是数据必须通过模拟在测试中手动返回
2 所有的按钮和操作会被覆盖到,可以在测试中执行
这里测试环境的行为几乎与生产环境一样。XML 界面描述会被加载,并且界面的完整生命周期也会被执行。

UI 描述的主要区别是数据加载器部分。 在Web 集成测试环境,不对数据库执行查询,而是配置应该返回的实体实例。

UI 界面描述伴随着 UI 控制器。 让我们看一下对应的 PetBrowse UI 控制器的覆盖范围:

PetBrowse.java
public class PetBrowse extends StandardLookup<Pet> {
    @Inject
    private Screens screens; (1)
    @Inject
    private Notifications notifications;
    @Inject
    private GroupTable<Pet> petsTable; (2)

    @Subscribe("petsTable.calculateDiscount") (3)
    public void calculateDiscount(
            Action.ActionPerformedEvent actionPerformedEvent
    ) {
        Pet pet = petsTable.getSingleSelected();

        String discountMessage = String.format(
                "Discount for %s: %s",
                pet.getName(),
                pet.calculateDiscount() (4)
        );

        notifications.create(Notifications.NotificationType.TRAY)
            .withCaption(discountMessage)
            .show();
    }

    @Subscribe("petsTable.createDiseaseWarningMailing")
    public void createDiseaseWarningMailing(
            Action.ActionPerformedEvent actionPerformedEvent
    ) {
        screens
            .create(
                CreateDiseaseWarningMailing.class,
                OpenMode.DIALOG
            )
            .show(); (5)
    }
}
1 CUBA UI API 会被覆盖到,会像在生产环境中一样工作
2 在 XML 界面描述中定义的 UI 组件注入会被覆盖到
3 与 XML 界面描述进行交互的操作订阅会被覆盖到,在测试中点击按钮操作会被执行
4 global 模块中的业务逻辑 (e.g. pet.calculateDiscount()) 也是测试环境的一部分
5 Screens API 会被正确执行,并且会打开 CreateDiseaseWarningMailing 界面

UI 控制器的行为也会与生产环境相似。所有注入项(UI 组件和 CUBA API) 均按预期工作。 所有在 WebGlobal 模块中的业务逻辑会像在生产环境中一样被执行。

WEB 集成测试的覆盖范围

根据上面的示例,您可以在此概览图中看到 Web 集成测试环境的覆盖范围:

coverage

实线箭头所指部分属于覆盖范围,而虚线箭头所指部分为未覆盖部分,必须在测试用例中进行模拟。

如示例中所述,所有界面控制器代码以及 XML 界面描述定义均已涵盖。 另外,位于 webglobal 模块中的所有业务逻辑也会被覆盖到并执行。

如图中最上部分所示,在浏览器中执行的代码不属于测试环境。这里的 JavaScript 代码是 Vaadin 的一部分,未覆盖。

Web 集成测试环境不会使用真实的 Web 浏览器与应用程序进行交互。要在测试用例中涵盖这一领域,需要通过 Masquerade 之类的工具执行功能性的端到端测试。

Web 集成测试环境分层图底部是中间件服务,对于服务接口,在测试用例中必须提供可以返回预期值的存根实现。

XML 界面描述中的声明式数据加载也是如此,在底层,声明性数据交互与 DataManager API 一样,也使用 DataService 服务提供数据。这意味着在测试用例中,必须使用存根实现替换 DataService 接口, 在存根实现中提供 UI 组件需要的数据。可从这里 CUBA API 参考:DataService 查阅 DataService API 的详细信息。

优点 & 局限性

使用 Web 集成测试,开发人员可以自动验证大多数 UI 界面定义及其业务逻辑是否正确。对于主要依赖于单元测试的 UI 部分,将需要使用许多 CUBA UI API 的模拟方法,同时放弃声明部分的验证。

在文章开始,我们了解到 Web 集成测试为测试提供了接近生产的环境。这意味着对于许多 UI 逻辑验证来说,没有必要通过Selenium 等浏览器运行端到端的黑盒测试用例。

通过 Web 浏览器进行自动测试从本质上讲要复杂得多,比如与浏览器交互的异步性、更复杂的测试数据设置及执行速度。

Web 集成测试没有这些问题,比运行基于 Selenium 的测试要快几个数量级。一般情况下运行一个 Web 集成测试需要的时间是秒级,而基于 Selenium 的执行相同的操作,一般需要半分钟。同样,很多基于 Selenium 的测试套件中经常发生误报,但在 Web 集成测试中并不存在这种问题,这是由于浏览器的异步特性导致,但是很多情况下中并不需要这个特性。

但是另一方面,Web 集成测试可以执行和验证的功能也会受到限制。由于我们必须提供数据操作的存根实现,因此模拟不出真实数据操作过程中的一些场景。下面列举几个在 WEB 集成测试中无法进行验证的情况:

  • 在订单编辑界面通过点击保存按钮引发订单 id 字段的违反唯一性验证

  • 客户名称应显示在订单表中,但是实体视图中没有包含客户名称

  • 订单数据加载器中的 JPQL 查询是有效的语句

对于所有这些示例(还有更多),是无法使用集成测试来验证其行为的。

验证 UI 中使用的自定义服务的正确性也是如此。 由于这些服务都提供了存根实现,因此有可能会遗漏一些用例,从而导致应用在生产环境和 WEB 集成测试环境的表现不一致。

这些是 Web 集成测试的局限性。要克服这种局限,您需要一个端到端的黑盒测试用例。CUBA 有一个专用的库:https://www.cuba-platform.com/marketplace/masquerade/[Masquerade],可以为创建这些测试用例提供便利。它基于 Selenium,因此可以执行基于浏览器的真实测试,这些测试可以执行从 UI 到数据库的交互。

Petclinic Web 集成测试

在研究了什么是 Web 集成测试及其功能边界的理论方法之后,让我们在这里进行一些调整,来看看 Web 集成测试是什么样子。 我们将基于 Petclinic 示例进行演示。

第一个 WEB 集成测试

要运行 Web 集成测试,您必须安装并注册 TestUiEnvironment 为 JUnit 扩展 。TestUiEnvironment 类似于中间件集成测试中的 Test 容器。它充当与应用程序进行交互的运行时环境。

PetEditTest.java
class PetEditTest {

    @RegisterExtension
    TestUiEnvironment environment =
            new TestUiEnvironment(PetclinicWebTestContainer.Common.INSTANCE)
                    .withScreenPackages(
                            "com.haulmont.cuba.web.app.main",
                            "com.haulmont.sample.petclinic.web"
                    )
                    .withUserLogin("admin");  (1)

    private TestEntityFactory<Pet> petsFactory;

    @BeforeEach
    void setUp() {
        Screens screens = environment.getScreens(); (2)

        petsFactory = environment
            .getContainer()
            .getEntityFactory(Pet.class, TestEntityState.NEW); (3)
    }

    // ...
}
1 直接创建 TestUiEnvironment 并进行配置
2 可以通过环境获取诸如 Screens 之类的 CUBA API
3 EntityFactory 允许创建专门用于测试的实体对象

使用 Entity Factory,可以为自动化测试的特定需求(例如所需状态的定义) 创建实体。此外,大多数 CUBA API 都可以直接从 environmentenvironment.getContainer() Web测试容器中获取。

设置好该部分之后,我们看一下第一个 Web 集成测试用例。测试验证可以正确打开 PetEdit 编辑器,并且将 identificationNumber 属性绑定到相应的 UI 组件。

PetEditorTest.java
class PetEditTest {
    // ...
    private Screens screens;
    private TestEntityFactory<Pet> petsFactory;

    @BeforeEach
    void openMainScreen() {
        screens = environment.getScreens();
        screens.create(MainScreen.class, OpenMode.ROOT).show(); (4)
    }

    @Test
    void identificationNumberIsCorrectlyBoundInTheInputField() {

        Pet pet = petsFactory.create(
                Collections.singletonMap("identificationNumber", "019")
        ); (1)

        PetEdit petEdit = showPetEditorFor(pet);

        TextInputField inputField = identificationNumberField(petEdit);

        assertThat(inputField.getValue()) (4)
            .isEqualTo("019");
    }

    private PetEdit showPetEditorFor(Pet pet) {
        PetEdit petEdit = screens.create(PetEdit.class); (2)
        petEdit.setEntityToEdit(pet);
        petEdit.show();
        return petEdit;
    }

    private TextInputField identificationNumberField(PetEdit petEdit) {
        return (TextInputField) petEdit
            .getWindow()
            .getComponent("identificationNumberField"); (3)
    }
}
1 通过其测试数据工厂创建一个新的 Pet 实体
2 使用 CUBA UI API 打开界面
3 getComponent 获取已打开界面上的组件的引用
4 CUBA 组件 API 可用于执行断言

第一个测试展示了如何以编程方式打开界面以及与界面组件进行交互。在此示例中,我们验证了表单组件中的数据绑定行为是否正确。

我们既不检查任何控制器逻辑也不 显式地 检查 CUBA 的内部数据绑定是否按预期工作。我们验证界面描述是否包含名为 ` identificationNumberField` 的字段,该字段应该连接到数据容器: petDc 以及实体属性: identificationNumber

下面这部分界面 XML 描述得到了正确性验证:

pet-edit.xml
<form id="fieldGroup" dataContainer="petDc">
    <column width="250px">
        <textField
            property="name"
            id="nameField"/>
        <textField
            property="identificationNumber"
            id="identificationNumberField" /> (1)
        <dateField
            property="birthDate"
            id="birthDateField" />
    </column>
</form>
1 identificationNumberField 的声明绑定了正确的属性,也定义了正确的组件类型,这是测试的关键部分

代码的一些其它部分也被 隐式 地得到了验证:

  • PetEdit Java 控制器拥有正确的注解使其作为 Pet 实体的编辑器

  • 界面描述的 XML 定义是格式正确的

  • 打开界面时不会出现错误(比如生命周期事件被正确执行)

  • 实体被正确地绑定到 petDc 数据容器

由于 Web 集成测试环境是一种灰盒测试,因此可以使用与生产环境相同的 API。 这大大简化了测试用例的实现,因为可以将所有 CUBA API 的知识和经验直接用于集成测试中。

与 UI 组件的交互也与生产代码中的相同。唯一的区别是对组件实例的获取,因为没有依赖项注入机制可用。

在此示例中,实例通过 petEdit.setEntityToEdit(pet) 传入,然后手动处理数据绑定。 在下一个示例中,我们将看一下界面尝试自动从 'DataService' 加载数据的情况。

Owner 浏览界面

第二个示例是一个 Web 集成测试,它处理 Ower 浏览界面和 Owner 编辑器之间的交互。在这种情况下,由于使用了 @LoadDataBeforeShow 注解了控制器,因此数据会自动加载:

OwnerBrowse.java
@LoadDataBeforeShow
public class OwnerBrowse extends StandardLookup<Owner> {
}

控制器不包含其他 UI/业务逻辑 OwnerBrowseTest web 集成测试包含两个测试用例:

第一个测试用例验证加载的数据将正确显示在表格中。第二个测试用例更进一步,检查 OwnerBrowseOwnerEdit 两个界面之间的交互。 在表格中选择 Owner 并执行编辑操作后,Owner 应显示出编辑器,并显示对应的的数据。

测试: 声明性数据加载

让我们看一下第一个测试用例:when_ownerListIsDisplayed_then_ownersAreShownInTheTable

为了给数据容器提供数据,必须模拟数据服务的结果。 对于 Web 集成测试环境,可以使用专门的 DataService 实现:DataServiceProxy。 此类模仿普通 DataService 实现的部分行为。可参阅 CUBA文档

DataServiceProxy - DataManager 的默认存根实现。它包含一个 commit 方法的实现,该方法模拟真实数据存储的行为: 分离新实体、增加版本号等。load 方法返回 null 或 空集合。

在这个用例中,我们希望 DataServiceProxy 在调用 loadList 时返回一组预定义的数据。我们也想利用 DataServiceProxy 实现的其他部分。这意味着,我们必须为这个类提供 部分 不同实现。Mockito 允许我们使用 Spy 实现部分模拟。Spy 类似于一个模拟(mock)对象,但是区别在于它会调用实际方法,但被模拟的方法除外。

我们先看看完整的测试用例,然后再看实现细节:

OwnerBrowseTest.java
class OwnerBrowseTest {


    //...

    @BeforeEach
    public void setUp() {
        mockDataService();
        data = new PetclinicData(environment.getContainer());
    }

    private void mockDataService() {
        dataService = Mockito.spy(
                new DataServiceProxy(environment.getContainer())
        );
        TestServiceProxy.mock(DataService.class, dataService);
    }

    @Test
    void when_ownerListIsDisplayed_thenOwnersAreShownInTheTable() {

        when(ownerListIsLoaded())
                .thenReturn(ashAndMisty);

        OwnerBrowse ownerBrowse = openScreen(OwnerBrowse.class);

        Table<Owner> ownersTable = ownersTable(ownerBrowse);

        assertThat(ownersTable.getItems().getItems())
                .hasSize(2);
    }

    private List<Owner> ownerListIsLoaded() {
        LoadContext<Owner> loadOwnerList = Mockito.argThat(loadContext ->
                loadContext.getEntityMetaClass().equals("petclinic_Owner")
        );
        return dataService.loadList(loadOwnerList);
    }

    // ...
}

setup 部分,使用 Mockito.spy(…​) 创建一个新的 DataServiceProxy Spy 实例,在这里我们可以重新配置 loadList 方法的行为。

创建 Spy 后,我们必须在测试用例中将此服务实例注册为用于测试的 DataService API 实现。这是通过 TestServiceProxy 的静态 `mock`方法实现的。

手动数据配置的其余部分是模拟调用和预期结果的定义。为此,我们使用了 Mockito API:when(…​).thenReturn(…​)。 在这种情况下,我们需要更加精确一些,因为我们不想模拟对 DataService.loadList(…​) 的所有调用,而只模拟对 Owner 实体的 load 调用。

Help 方法 ownerListIsLoaded 定义了这种情况。 Mockito.argThat 是一个 API,可让测试定义一个谓词 lambda 表达式,该表达式确定方法调用参数 (在这种情况下为 loadList) 是否与我们要模拟的调用相匹配,它充当了过滤器,以精确地过滤正在执行的调用。

Lambda 表达式会获取到原始参数: LoadContextloadContext.getEntityMetaClass().equals("petclinic_Owner") 是我们过滤条件,用于验证实体类是否是 Owner 实体 。

方法用例:

when(ownerListIsLoaded())
                .thenReturn(ashAndMisty);

定义 mock 与返回值的组合。如果是加载 Owner 列表,则应返回 Ash 和 Misty。

有了这些信息,我们再来看看实际的测试用例:

OwnerBrowseTest.java
class OwnerBrowseTest {
    // ...
    @Test
    void when_ownerListIsDisplayed_then_ownersAreShownInTheTable() {

        when(ownerListIsLoaded())
                .thenReturn(ashAndMisty); (1)

        OwnerBrowse ownerBrowse = openScreen(OwnerBrowse.class);

        Table<Owner> ownersTable = ownersTable(ownerBrowse); (2)

        assertThat(ownersTable.getItems().getItems())
                .hasSize(2); (3)
    }

    private Table<Owner> ownersTable(OwnerBrowse ownerBrowse) {
        return (Table<Owner>) ownerBrowse
            .getWindow()
            .getComponent("ownersTable");
    }

    private <T extends Screen> T openScreen(Class<T> screenClass) {
        T screen = screens.create(screenClass);
        screen.show();
        return screen;
    }
    // ...
}
1 配置手动数据加载
2 界面被打开,同时从界面中获取 table 组件
3 对 Owner 表格列表项数量做一个断言

这里也有几个相关点被隐式验证:

  • OwnerBrowse Java 控制器具有正确的注解,可以在界面打开时自动加载数据

  • 界面描述 owner-browse.xml 的 XML 定义是格式正确的

  • 界面正确打开,不会出现错误(例如,生命周期事件已正确执行)

  • 表格已正确绑定到 ownersDc 数据容器

  • 数据容器以声明的方式加载了 owner 实体列表

测试: 从 Owner 浏览界面到 Owner 编辑界面的交互

第二个测试用例基于我们刚刚探讨的第一个测试用例。 在这个测试用例中,目标是测试两个界面之间的正确交互。 该测试的步骤如下:

  1. 打开 Owner 浏览界面,并注入 AshMisty 作为 Owner 测试数据

  2. 在表格中选择 Ash

  3. 执行 edit 操作( Owner 编辑器会隐式打开)

断言基于以下问题:在 owner 编辑器中编辑的实体是否是 Ash 。 .OwnerBrowseTest.java

class OwnerBrowseTest {
    // ...
    @Test
    void given_ownerIsSelected_when_editIsPerformed_then_ownerEditorIsOpened() {

        when(ownerListIsLoaded())
                .thenReturn(ashAndMisty);

        OwnerBrowse ownerBrowse = openScreen(OwnerBrowse.class);

        Table<Owner> ownersTable = ownersTable(ownerBrowse);

        ownersTable.setSelected(ash); (1)

        ownersTable
                .getAction("edit")
                .actionPerform(editButton(ownerBrowse)); (2)

        OwnerEdit ownerEdit = findOpenScreen(OwnerEdit.class);

        assertThat(ownerEdit.getEditedEntity()) (4)
                .isEqualTo(ash);
    }

    private Component editButton(OwnerBrowse ownerBrowse) {
        return ownerBrowse.getWindow().getComponent("editBtn");
    }

    private <T extends Screen> T findOpenScreen(Class<T> screenClass) {
        return (T) screens
                .getOpenedScreens()
                .getAll() (3)
                .stream()
                .filter(openedScreen ->
                    openedScreen.getClass().isAssignableFrom(screenClass)
                )
                .findFirst()
                .orElseThrow(RuntimeException::new);
    }

    private Table<Owner> ownersTable(OwnerBrowse ownerBrowse) {
        return (Table<Owner>) ownerBrowse
            .getWindow()
            .getComponent("ownersTable");
    }

    private <T extends Screen> T openScreen(Class<T> screenClass) {
        T screen = screens.create(screenClass);
        screen.show();
        return screen;
    }
    // ...
}
1 table.setSelected(…​) 在表格中选择 Ash
2 Owner 表格的 edit 操作通过 Edit 按钮执行
3 通过Screens API,可以迭代打开的界面并过滤特定类型的界面
4 editor.getEditedEntity() 用于获取编辑器正在编辑的实体

在此测试用例中,我们使用了 CUBA UI API 的其他部分来与应用程序进行交互,例如使用了 ownersTable.setSelected(…​)。 另外,我们使用它来获取测试中需要的一些信息,例如搜索打开的界面。

创建疾病警告邮件

我们要看的第三个也是最后一个测试是在 CUBA指南:在CUBA中创建业务逻辑 中实现的逻辑:“疾病警告邮件” 功能。 对于功能的中间件逻辑,我们已经创建了一个中间件集成测试,如 CUBA指南:中间件集成测试 中所述。

在中间件集成测试中,我们涵盖了服务接口及其下面的所有内容,但忽略了 UI 部分。在本指南中,我们将创建一个与 UI 交互、模拟服务接口行为的 Web 集成测试。这样,我们可以控制依赖项 DiseaseWarningMailingService 的行为,并确保 UI 代码根据我们的期望与其交互。

可以从 Pet 浏览界面访问结果 UI 对话框: image::integration-testing-web/create-disease-warning-mailing.png[align="center", link="images/integration-testing-web/create-disease-warning-mailing.png"]

此测试包含下列先决条件:

  1. 模拟依赖 DiseaseWarningMailingService

  2. 通过 Pet 浏览界面打开 DiseaseWarningMailing 圣诞框

然后有四个测试用例覆盖该对话框的功能:

  1. 如果表单被正确填充,它不会出现验证错误

  2. 如果表单被正确填充,邮件会被发送

  3. 如果表单没有被正确填充,它会包含验证错误

  4. 如果表单没有被正确填充,邮件不会被发送

在下面的列示中,只展 1. 和 2. 这两个用例。 可以从 CreateDiseaseWarningMailingTest.java 找到完整用例。

CreateDiseaseWarningMailingTest.java
class CreateDiseaseWarningMailingTest {
    // ...
    private Screens screens;
    private DiseaseWarningMailingService diseaseWarningMailingService;
    private PetType electricType;

    private CreateDiseaseWarningMailing dialog;

    @BeforeEach
    void setUp() {
        setupTestData();
        mockDiseaseWarningMailingService();
        openWarningMailingDialogFromPetBrowse();
    }

    // ...

    private void mockDiseaseWarningMailingService() {

        diseaseWarningMailingService = Mockito.mock(
                DiseaseWarningMailingService.class
        );

        TestServiceProxy.mock(
                DiseaseWarningMailingService.class,
                diseaseWarningMailingService
        ); (1)
    }

    private void openWarningMailingDialogFromPetBrowse() {
        screens = environment.getScreens();
        screens.create(MainScreen.class, OpenMode.ROOT).show();

        PetBrowse petBrowse = openScreen(PetBrowse.class);

        petTable(petBrowse)
                .getAction("createDiseaseWarningMailing")
                .actionPerform(createDiseaseWarningMailingBtn(petBrowse));

        dialog = findOpenScreen(CreateDiseaseWarningMailing.class); (2)

    }

    @Nested
    @DisplayName("When Valid Dialog Input Data, then...")
    class When_SubmitValidDialogInput {

        @BeforeEach
        void fillFields() {
            city(dialog).setValue("Alabastia");
            disease(dialog).setValue("Fever");
            petType(dialog).setValue(electricType);
        }

        @Test
        @DisplayName("Form is valid")
        void then_formIsValid() {

            submitDialog(dialog);

            assertThat(dialog.validationResult().getAll()) (3)
                    .isEmpty();
        }

        @Test
        @DisplayName("Mailing is send")
        void then_mailingIsSend() {

            submitDialog(dialog);

            verify(diseaseWarningMailingService, times(1))
                    .warnAboutDisease(
                            electricType,
                            "Fever",
                            "Alabastia"
                    ); (4)
        }

    }

    private void submitDialog(CreateDiseaseWarningMailing dialog) {
        dialog
                .getWindow()
                .getAction("createDiseaseWarningMailing")
                .actionPerform(createMailingBtn(dialog));
    }


    private Component createMailingBtn(CreateDiseaseWarningMailing dialog) {
        return component(dialog, "createDiseaseWarningMailingBtn");
    }

    private TextInputField<String> city(CreateDiseaseWarningMailing dialog) {
        return (TextInputField<String>) component(dialog, "city");
    }

    private Component component(Screen screen, String componentId) {
        return screen
                .getWindow()
                .getComponent(componentId);
    }

    //...
}
1 模拟 DiseaseWarningMailingService 并在环境中进行注册
2 从 Pet 浏览界面打开对话框
3 断言表单的验证结果中没有错误
4 Mockito 的 verify 方法可以验证与依赖的交互

在这些测试的断言部分,到目前为止有个要点需要注意。

第一个测试用例 When_SubmitValidDialogInput.then_formIsValid() 检查对话框是否包含任何验证错误。 这个验证是在 CreateDiseaseWarningMailing 界面中手动执行的,因为这个界面不是一个 StandardEditor,而是自定义界面。因此,无法从界面外部直接获取 Validation 错误。

所以给产品界面专门增加了一个方法:CreateDiseaseWarningMailing.validationResult()

CreateDiseaseWarningMailing.java
public class CreateDiseaseWarningMailing extends Screen {
    // ...
    ValidationErrors validationResult() {
        return screenValidation.validateUiComponents(
                asList(city, disease, petType)
        );
    }
}

这个方法用于在 Controller 中执行验证。但是测试用例也可以调用它来 ValidationErrors 对象,以实施断言。

第二个测试用例 When_SubmitValidDialogInput.then_mailingIsSend() 使用了 DiseaseWarningMailingService 模拟(mock) 来验证受测系统(CreateDiseaseWarningMailing) 可以与依赖正确交互。

在测试用例中需要以某种方式表达出与依赖的交互。 Mockito 提供的 verify 方法可以做到这点:

verify(diseaseWarningMailingService, times(1))
        .warnAboutDisease(
                electricType,
                "Fever",
                "Alabastia"
        );

它会转换成下面的交互表述:

验证 `diseaseWarningMailingService` 对象的 `warnAboutDisease` 方法使用三个参数 `electricType, "Fever", "Alabastia"` 调用了一次

Mockito 接受这种对对交互的期望,并确保这期望可以被正确满足。如果在测试用例中未发生此交互,则测试失败。

使用这些用例,我们成功地测试了 UI 代码,该 UI 代码是整个 "Create Disease Warning Mailing” 功能的一部分。 中间件集成测试验证了 warnAboutDisease 方法本身是否按预期工作。

Sneferu

看了 Web 集成测试的各种实现之后,您应该对如何通过 Web 集成测试与 UI 交互有一个基本的了解。CUBA UI API 在大多数情况下都能够以编程方式与各种 UI 组件和界面进行交互。

但是,由于这些 API 针对生产环境应用程序代码进行了优化,因此将它们用于远程控制 CUBA UI (测试通常这样做)可能有点不方便。这导致会在 Web 集成测试中产生一些 “架子(boilerplate)” 代码,如下所示:

OwnerBrowseTest.java
class OwnerBrowseTest {
    // ...
    private <T extends Screen> T findOpenScreen(Class<T> screenClass) {
        return (T) screens
                .getOpenedScreens()
                .getAll()
                .stream()
                .filter(openedScreen ->
                    openedScreen.getClass().isAssignableFrom(screenClass)
                )
                .findFirst()
                .orElseThrow(RuntimeException::new);
    }
}

编写这些代码会耽搁开发人员手头的主要任务:编写测试用例以验证应用程序的正确行为。

Sneferu 是一个测试库,旨在简化针对 CUBA 的 Web 集成测试的编写,从而让开发人员专注于编写用于验证的测试用例。 凭借其特有的 DSL 和能够自行创建更高级别的抽象的能力,它还使我们能够使用更高级别的语言来表达测试用例。这有助于创建更易于维护的测试包。

为了理解常规 CUBA Web 集成测试和 Sneferu Web 集成测试之间的区别,让我们重新回顾一下 OwnerBrowseTestCreateDiseaseWarningMailingTest 两个测试用例。

Owner 浏览界面 - 回顾

Owner 浏览界面测试包含一个测试用例,用于验证 OwnerBrowseOwnerEdit 界面之间的正确交互。 在 Sneferu 的帮助下编写的测试用例如下所示:

OwnerBrowseSneferuTest.java
import static de.diedavids.sneferu.ComponentDescriptors.*;
import static de.diedavids.sneferu.Interactions.*;

class OwnerBrowseSneferuTest {

    @RegisterExtension
    SneferuTestUiEnvironment environment =
            new SneferuTestUiEnvironment(PetclinicWebTestContainer.Common.INSTANCE)
                    .withScreenPackages(
                            "com.haulmont.cuba.web.app.main",
                            "com.haulmont.sample.petclinic.web"
                    )
                    .withUserLogin("admin")
                    .withMainScreen(MainScreen.class);

    // ...

    @Test
    void given_ownerIsSelected_when_editIsPerformed_then_ownerEditorIsOpened(
        @StartScreen StandardLookupTestAPI<Owner, OwnerBrowse> ownerBrowse,
        @SubsequentScreen StandardEditorTestAPI<Owner, OwnerEdit> ownerEdit
    ) {

        ownerBrowse
                .interact(selectInList(table("ownersTable"), ash))
                .andThen(click(button("editBtn")));

        assertThat(ownerEdit.screen().getEditedEntity())
                .isEqualTo(ash);
    }

    //...

}

在这个用例中,测试 UI 环境是一个专门的 Sneferu 类:SneferuTestUiEnvironment,它为环境添加了一些功能。

测试用例本身声明了两个参数。这是 JUnit 5 的重要功能,它允许表达测试所需的测试依赖。在这种情况下,第一个参数用 @StartScreen 注解,这表示在测试开始时 Sneferu 将自动打开界面,这样就可以直接与界面进行交互。类型:StandardLookupTestAPI<Owner,OwnerBrowse> ownerBrowse 定义了测试期望的界面类型。

在底层,将显示一个 OwnerBrowse 实例,它被包装在一个名为 'StandardLookupTestAPI' 的类中,这个类提供了测试用例与界面交互方面的更多抽象。

第二个参数是 OwnerEdit 界面,在此测试用例中我们需要它。与 Owner 浏览界面不同,此界面不是我们测试界面交互(test-screen-interaction) 的起始界面。而是由应用程序自动打开的界面。但是测试用例也需要获得对它的引用,以便以后可以与此界面交互。在这种情况下,使用 @SubsequentScreen 注解。

为了与界面进行交互,ScreenTestAPI 包装器类提供了各种方法来进行交互。ownerBrowse.interact(…​) 用于这种交互。它期望将交互对象作为参数提供。Sneferu 提供了许多开箱即用的交互功能。静态导入 de.diedavids.sneferu.Interactions.*; 允许直接访问诸如 selectInList 之类的方法,从而使测试用例的表达更简单。

在这种情况下,交互(Interaction) Interactions.selectInList(table("ownersTable"),ash) 选择列表组件中的一个特定实体。它有两个参数:第一个是要执行交互的组件的引用。而第二个参数定义应选择的实体。

可以通过 interact(…​).andThen(…​) 进行链式调用,这样可以连续地执行多个交互。

执行交互后,应用程序应已打开 “Owner Editor” 界面。 现在,ownerEdit 保持了对此最近打开的界面的引用。因此现在可以与此界面进行交互。在这种情况下,将通过 ownerEdit.screen().getEditedEntity() 获取内部 Screen 对象,然后比较被编辑的实体。

Disease Warning Mailing - 回顾

用 Sneferu 实现的疾病警告邮件测试示例如下:

DiseaseWarningMailingSneferuTest.java
class DiseaseWarningMailingSneferuTest {
    //...
    @Nested
    @DisplayName("When Valid Dialog Input Data, then...")
    class When_SubmitValidDialogInput {
        // ...
        @Test
        @DisplayName("Mailing is send")
        void then_mailingIsSend(
            @StartScreen StandardScreenTestAPI<CreateDiseaseWarningMailing> dialog
        ) {

            dialog
                    .interact(enter(textInputField("city"), "Alabastia"))
                    .andThen(enter(textInputField("disease"), "Fever"))
                    .andThen(select(lookupField("petType"), electricType))
                    .andThen(click(button("createDiseaseWarningMailingBtn")));

            verify(diseaseWarningMailingService, times(1))
                    .warnAboutDisease(
                            electricType,
                            "Fever",
                            "Alabastia"
                    );
        }
    }
}

与第一个测试用例相似,再次注入了起始界面 (这里是 StandardScreenTestAPI 类型)。然后在界面上执行交互。最后像前面一样,写了一个断言: diseaseWarningMailingService 应该调用一次。

如我们所见,Sneferu 提供了专门的交互 API,可以让我们更方便地以编程的方式进行界面交互。另外,它减少了使用 CUBA UI API 时需要的许多 ”架子(boilerplate)“ 代码。

可以在这里找到这个库的详细信息: CUBA Marketplace: SneferuGithub: mariodavid/sneferu

总结

在本指南中,我们了解了如何编写和执行 CUBA 的 Web集成测试 。一开始,我们讨论了 Web 集成测试的覆盖范围及其局限性。

Web 集成测试包含 Java 控制器及相应的 XML 界面描述的 UI 逻辑。它不涉及诸如服务实现或数据加载之类的中间件部分的执行。

之后,我们研究了三个 Web 集成测试示例:PetEditTestOwnerBrowseTestCreateDiseaseWarningMailingTest

这些测试展示了如何通过 TestUiEnvironment 以及其它技术以编程方式打开界面、与界面上的组件进行交互。此外,我们模拟了 Web 集成测试的边界,并验证了与依赖项的交互是否正确执行。

最后,我们研究了在 CUBA 中编写 Web 集成测试的另一种方法: Sneferu。这是一个专为编写 Web 集成测试而设计的库,它可以让 WEB 集成测试的编写更快速、轻松且更具表达力。

Web 集成测试和中间件集成测试的组合(见:https://www.cuba-platform.com/guides/integration-testing-middleware[CUBA 应用程序中的中间件集成测试]) 形成了可靠的 “安全网“ 。 在服务边界之间进行验证,整个测试会有更好的覆盖范围。这样,您可以快速迭代应用程序,同时可以确保功能仍按预期运行。