端到端测试

学习目标

  • 编写有价值的测试覆盖应用程序的所有部分

  • 了解不同的端到端测试方法

  • 设置Cypress来测试您的Angular项目

  • 编排Web浏览器来加载和检查您的应用程序

  • 拦截API调用以返回固定数据

我们已经成功地使用Karma、Jasmine和Angular的自有测试工具编写了单元测试和集成测试。这些精确的测试能够确保单个应用程序部分(如组件或服务)或一组连接的部分按照预期工作。

用户角度

Karma和Jasmine测试从技术角度进行。它们仅关注前端JavaScript代码,并在受控且隔离的测试环境中运行。然而,真正重要的是整个应用程序对用户是否有效。

确保应用程序正常工作的最有效和可靠的方式是手动测试:专门的软件测试人员根据测试计划逐个功能、逐个案例地测试应用程序。

手动测试速度慢、劳动密集且不能经常重复。从开发人员的角度来看,它们不具备特定性:如果测试失败,我们不能轻易确定应用程序的哪个部分负责或哪个代码更改导致了回归。

我们需要采用用户的角度进行自动化测试。这就是端到端(E2E)测试的作用。

端到端测试的优势

如在 测试工作的分配 中所讨论的,所有类型的自动化测试都有其优缺点。单元测试和集成测试速度快、可靠,但不能保证应用程序正常工作。端到端测试速度较慢,经常出现错误,但可以评估整个应用程序的适用性。

真实条件

当应用程序的所有部分集合在一起时,会出现一种新类型的错误。这些错误通常与事件的时间和顺序有关,例如网络延迟和竞态条件。

我们编写的单元测试和集成测试使用了一个假的后端。我们发送假的HTTP请求并用假的数据进行响应。我们努力使原始数据和假数据保持一致。

前端和后端

让前端代码与实际的API端点和后端的响应保持同步要困难得多。即使前端和后端共享有关传输数据的类型信息,仍然会出现不匹配。

端到端测试的目标是捕获这些无法通过其他自动化测试捕获的错误。

端到端测试的部署

端到端测试需要一个与生产环境密切相似的测试环境。您需要部署完整的应用程序,包括前端和相关的后端部分。为此,后端框架通常支持不同环境的配置,如开发、测试和生产环境。

确定性环境

数据库需要填充预制的假数据。在每次运行端到端测试时,您需要将数据库重置为定义的初始状态。

后端服务需要使用确定性响应来回应请求。第三方依赖项需要进行设置,以返回真实的数据,但不会危及生产数据。

由于本指南不涉及DevOps,我们不会在此处详细介绍,而是专注于编写端到端测试。

端到端测试的工作原理

端到端测试模拟用户与应用程序的交互方式。通常,测试引擎启动一个普通的浏览器,并远程控制它。

模拟用户操作

一旦浏览器启动,端到端测试会导航到应用程序的URL,读取页面内容,并进行键盘和鼠标输入。例如,测试会填写表单并点击提交按钮。

与单元测试和集成测试类似,端到端测试会进行断言:页面是否包含正确的内容?URL是否发生了变化?通过这种方式,整个功能和用户界面都会被检查。

端到端测试框架

用于端到端测试的框架允许导航到URL、模拟用户输入和检查页面内容。除此之外,它们没有太多共同之处。测试语法和测试运行方式差异很大。

端到端测试框架可以分为两类:使用WebDriver的和不使用WebDriver的。

浏览器自动化

WebDriver 协议(protocol) 允许使用一组命令远程控制浏览器。它源自Selenium浏览器自动化项目,现在由万维网联盟(W3C)进行开发。

所有常见的浏览器都支持WebDriver协议,并可以进行远程控制。最重要的WebDriver命令包括:

  • 导航到给定的URL

  • 在DOM中查找一个或多个元素

  • 获取找到的元素的信息:

  • 获取元素的属性或属性值

  • 获取元素的文本内容

  • 点击一个元素

  • 向表单字段发送键盘输入

  • 执行任意的JavaScript代码

WebDriver是一个高级的通用的基于HTTP的协议。它将在一台机器上运行的测试与可能在另一台机器上运行的浏览器连接起来。对浏览器的控制程度有限。

灵活性与可靠性

WebDriver的主要优点是测试可以在不同的浏览器中运行,甚至可以同时运行。然而,只有一些端到端测试框架建立在WebDriver上。那些不使用WebDriver的框架更直接地集成到浏览器中,可以通过插件或通过修改浏览器源代码来实现。这使得它们更可靠,但也更不灵活,因为它们只支持某些浏览器或定制的浏览器构建。

在Angular 12之前,Angular使用 Protractor 作为其默认的端到端测试框架。Protractor基于WebDriver。从Angular 12开始,Protractor已被弃用。在新的CLI项目中,默认未配置端到端测试解决方案。

在本指南中,我们将了解Cypress,一个成熟的不使用WebDriver的端到端测试框架。

介绍Cypress

Cypress是一个旨在改善开发者体验以及端到端测试的性能和可靠性的测试框架。

Cypress是一家公司的产品。我们将使用的测试运行器是开源且免费的。该公司通过额外的付费服务获得收入。Cypress云仪表板管理在持续集成环境中记录的测试运行。您无需订阅此服务即可编写和运行Cypress测试。

架构

由于Cypress不使用WebDriver,它具有独特的架构。启动Cypress时,一个Node.js应用程序启动浏览器。浏览器不是远程控制的,而是测试直接在浏览器内部运行,由浏览器插件支持。测试运行器为在浏览器中检查和调试测试提供了强大的用户界面。

权衡

从我们的角度来看,Cypress有一些缺点。

  • Cypress使用Mocha和Chai库的组合来编写测试,而不是使用Jasmine。虽然两个堆栈的目的相同,但您需要学习它们之间微小的区别。如果您在单元测试和集成测试中使用Jasmine,那么您的Cypress测试看起来可能相似,但在细节上会有所不同。

  • Cypress仅支持Firefox以及基于Chromium的浏览器,如Google Chrome和Microsoft Edge。Cypress对Safari使用的WebKit浏览器引擎具有实验性支持。Cypress不支持旧版Edge或Internet Explorer。

Cypress并不简单地比基于WebDriver的框架更好。它通过缩小范围并做出权衡来解决它们的问题。

推荐

话虽如此,本指南建议在测试Angular应用程序时使用Cypress。Cypress得到了很好的维护和文档支持。使用Cypress,您可以轻松编写有价值的端到端测试。

如果您确实需要一个最新的基于WebDriver的框架,请考虑使用Webdriver.io。

安装Cypress

向现有的Angular CLI项目添加Cypress的简单方法是使用 Cypress Angular Schematic

在您的Angular项目目录中,运行以下命令:

ng add @cypress/schematic

这个命令执行了四个重要的操作:

  1. 将Cypress和辅助的npm包添加到package.json

  2. 添加了Cypress配置文件cypress.config.ts

  3. 修改angular.json配置文件以添加ng run命令。

  4. 创建一个名为cypress的子目录,其中包含测试的框架。

输出如下所示:

ℹ Using package manager: npm
✔ Found compatible package version: @cypress/schematic@2.5.0.
✔ Package information loaded.

The package @cypress/schematic@2.5.0 will be installed and executed.
Would you like to proceed? Yes
✔ Packages successfully installed.
? Would you like the default `ng e2e` command to use Cypress? [ Protractor to Cypress Migration Guide: https://on.cypress.io/protractor-to-cypress?cli=true ] Yes
? Would you like to add Cypress component testing?  This will add all files needed for Cypress component testing. No
CREATE cypress.config.ts (134 bytes)
CREATE cypress/tsconfig.json (139 bytes)
CREATE cypress/e2e/spec.cy.ts (143 bytes)
CREATE cypress/fixtures/example.json (85 bytes)
CREATE cypress/support/commands.ts (1377 bytes)
CREATE cypress/support/e2e.ts (649 bytes)
UPDATE package.json (1187 bytes)
UPDATE angular.json (3643 bytes)
✔ Packages installed successfully.

安装程序询问是否要使用ng e2e命令启动Cypress。如果您正在设置一个尚未具备端到端测试的新项目,可以安全地回答“是”。

在Angular CLI 12版本之前,ng e2e用于启动Protractor。如果您的项目中有任何遗留的Protractor测试,并且希望继续使用ng e2e运行它们,请回答“否”。

使用Cypress编写端到端测试

在项目目录中,您会找到一个名为cypress的子目录。它包含:

  • 一个用于该目录中所有TypeScript文件的tsconfig.json配置,

  • 一个用于端到端测试的e2e目录,

  • 一个用于自定义命令和其他测试辅助工具的support目录,

  • 一个用于测试数据的fixtures目录。

测试文件位于e2e目录中。每个测试是一个以.cy.ts为扩展名的TypeScript文件。

测试本身是使用Mocha测试框架结构化的。断言(也称为期望)使用Chai编写。

Mocha和Chai是一种常见的组合。它们的功能与Jasmine大致相同,但更加灵活且功能丰富。

测试套件

如果您以前 使用Jasmine编写过单元测试,Mocha的结构将会很熟悉。一个测试文件包含一个或多个使用 describe('…', () ⇒ { /* … */}) 声明的测试套件。通常,一个文件包含一个describe块,可能带有嵌套的describe块。

describe内部,可以类似于Jasmine测试使用beforeEachafterEachbeforeAllafterAllit块。

这将导致以下端到端测试结构:

describe('… Feature description …', () => {
  beforeEach(() => {
    // Navigate to the page
  });

  it('… User interaction description …', () => {
    // Interact with the page
    // Assert something about the page content
  });
});

测试计数器组件

逐步进行,我们将为计数器示例应用程序编写端到端测试。

首先,让我们编写一个最小化的测试,检查文档标题。在项目目录中,我们创建一个名为 cypress/e2e/counter.cy.ts 的文件。其内容如下:

describe('Counter', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('has the correct title', () => {
    cy.title().should('equal', 'Angular Workshop: Counters');
  });
});
命令

Cypress命令是cy命名空间对象的方法。在这里,我们使用了两个命令,visittitle

cy.visit命令指示浏览器访问给定的URL。在上面的例子中,我们使用路径/。Cypress会将路径追加到baseUrl上。默认情况下,baseUrl在Cypress的配置文件cypress.config.ts中设置为http://localhost:4200

链式调用

cy.title返回页面标题。具体来说,它返回一个Cypress链式调用器(Chainer)。这是一个围绕任意值的异步包装器。大多数情况下,链式调用器包装DOM元素。在这种情况下,cy.title包装了一个字符串。

断言

链式调用器有一个should方法用于创建断言。Cypress会将该调用传递给Chai库以验证断言。

cy.title().should('equal', 'Angular Workshop: Counters');

我们传递了两个参数,'equal' 和预期的标题字符串。equal创建了一个断言,主题值(页面标题)等于给定的值('Angular Workshop: Counters')。equal使用熟悉的===比较。

这种should风格的断言与Jasmine的expectations不同,后者使用 expect(…).toBe(…) 的风格。实际上,Chai支持三种不同的断言风格:shouldassertexpect。在Cypress中,通常会在链式调用器上使用should,在未包装的值上使用expect

运行Cypress测试

将上一章节中的最小测试保存为 cypress/e2e/counter.cy.ts

Cypress有两个命令用于运行端到端测试:

测试运行器
  • npx cypress run - 非交互式测试运行器。在“headless”浏览器中运行测试。这意味着浏览器窗口不可见。

    测试运行一次,然后关闭浏览器并完成shell命令。您可以在shell输出中看到测试结果。

    该命令通常在持续集成环境中使用。

  • npx cypress open - 交互式测试运行器。打开一个窗口,您可以选择使用哪个浏览器和运行哪些测试。浏览器窗口是可见的,完成后仍然可见。

    您可以在浏览器窗口中看到测试结果。如果对测试文件进行更改,Cypress会自动重新运行测试。

    该命令通常在开发环境中使用。

启动并运行测试

我们安装的Cypress原理图会将这些命令包装起来,使其与Angular集成。

  • ng run $project-name$:cypress-run - 启动非交互式测试运行器。

  • ng run $project-name$:cypress-open - 启动交互式测试运行器。

`$project-name$`是一个占位符。请插入相应的Angular项目的名称。这通常与目录名相同。如果不是这样,可以在angular.jsonprojects对象中找到它。

例如,Counter示例的项目名称为angular-workshop。因此,命令如下:

  • ng run angular-workshop:cypress-run

  • ng run angular-workshop:cypress-open

开发服务器

命令 npx cypress runnpx cypress openng run $project-name$:cypress-open 要求您首先在单独的shell中使用ng serve启动Angular的开发服务器。Cypress连接到baseUrlhttp://localhost:4200),如果服务器无法访问,它会通知您。

命令 ng run $project-name$:cypress-run 启动开发服务器,运行测试,并在测试完成后停止服务器。

启动窗口

命令 npx cypress open 将打开测试运行器。首先,您需要选择测试类型,在我们的例子中是“E2E测试”。

cypress choose testing type

在下一个屏幕上,您需要选择运行测试的浏览器。

cypress choose browser

Cypress会自动列出系统中找到的所有浏览器。此外,您还可以在Electron中运行测试。Cypress的用户界面是一个Electron应用程序。Electron基于Chromium,即Chrome浏览器的开源基础。

选择一个浏览器,然后点击“开始端到端测试”按钮。这将启动浏览器并打开测试运行器,即Cypress的主要用户界面。(截图显示的是Chrome浏览器。)

cypress tests

在主窗格中,列出了所有的测试。要运行单个测试,请点击它。

测试运行器

假设您在Chrome中运行测试,并运行了名为counter.cy.ts的测试,那么浏览器内的测试运行器将如下所示:

cypress runner

在“规范”列中,列出了此测试运行的测试。对于每个测试,您可以查看规范。

在右侧,可以看到被测试的网页。网页会根据窗口进行缩放,但默认视口宽度为1000像素。

规范日志

通过点击规范名称,您可以查看规范中的所有命令和断言。

cypress spec

您可以逐个命令地观察Cypress运行规范。当规范失败时,这特别有用。让我们故意修改规范,以查看Cypress的输出。

cy.title().should('equal', 'Fluffy Golden Retrievers');

这个改变导致规范失败:

cypress spec failed

Cypress提供了有用的错误信息,指出了失败的断言。您可以点击带有文件名、行号和列号的链接,例如在示例中是 cypress/e2e/counter.cy.ts:7:16,以直接跳转到代码编辑器中的断言位置。

时间旅行

浏览器内的测试运行器的一个独特功能是能够查看页面在某个时间点的状态。每当运行命令或验证断言时,Cypress会创建DOM快照。

通过将鼠标悬停在命令或断言上,您可以进行时间回溯。右侧的页面将反映出命令或断言被处理时的页面状态。

时间旅行功能在编写和调试端到端测试时非常有价值。使用它来了解您的测试如何与应用程序交互以及应用程序的反应。当测试失败时,使用它来重现导致失败的情况。

异步测试

每个Cypress命令都需要一些时间来执行。但从规范的角度来看,执行是瞬间完成的。

命令队列

实际上,Cypress命令只是声明式的。执行是异步进行的。通过调用cy.visitcy.title,我们将命令添加到队列中。队列稍后逐个处理命令。

因此,我们无需等待cy.visit的结果。Cypress会自动等待页面加载完成后再继续执行下一个命令。

出于同样的原因,cy.title不会立即返回一个字符串,而是返回一个Chainer,允许更多的声明。

在之前编写的Jasmine单元测试和集成测试中,我们需要自己管理时间。在处理异步命令和值时,我们必须显式使用async / awaitfakeAsync和其他手段。

编写Cypress测试时,这是不必要的。Cypress API的设计注重表达力和可读性。Cypress隐藏了所有命令需要时间的事实。

同步断言

有时需要同步访问和检查一个值。Cypress通过回调函数的形式允许这样做,这些回调函数在特定命令处理完毕后执行。您可以将回调函数传递给should命令或更通用的then命令。

在这些回调函数中,可以使用Chai的expect函数对普通的未封装值进行断言。稍后我们将了解到这种实践。

自动重试和等待

Cypress的一个关键特性是它会重试某些命令和断言。

例如,Cypress查询文档标题并将其与预期标题进行比较。如果标题不立即匹配,Cypress将在四秒内重试cy.title命令和should断言。当达到超时时间时,规范失败。

自动等待

其他命令不会被重试,但具有内置的等待逻辑。例如,我们将使用Cypress的click方法单击一个元素。

Cypress会自动等待四秒钟,以确保该元素可点击。Cypress将元素滚动到视图中并检查其是否可见且未禁用。经过几次其他检查后,Cypress执行点击操作。

重试和等待的超时时间可以为所有测试或单独的命令进行配置。

重试规范

如果一个规范尽管重试和等待仍然失败,可以配置Cypress重试整个规范。这是一种最后的手段,用于处理产生不一致结果的特定规范。

这些功能使端到端测试更可靠,同时也更容易编写。在其他框架中,您需要手动等待,并且没有命令、断言或规范的自动重试。

测试计数器递增

在我们的第一个Cypress测试中,我们成功检查了页面标题。现在让我们测试计数器的递增功能。

测试需要执行以下步骤:

  1. 导航到“/”。

  2. 找到当前计数的元素并读取其文本内容。

  3. 期望文本为“5”,因为这是第一个计数器的起始计数。

  4. 找到递增按钮并点击它。

  5. 找到当前计数的元素并(再次)读取其文本内容。

  6. 期望文本现在显示为“6”。

我们使用 cy.visit('/') 导航到一个地址。路径“/”转换为http://localhost:4200/,因为这是配置的baseUrl

查找元素

下一步是在当前页面中查找元素。Cypress提供了几种查找元素的方法。我们将使用cy.get方法通过CSS选择器来查找元素。

cy.get('.example')

cy.get返回一个Chainer,它是对找到的元素的异步封装,附加了一些有用的方法。

就像在单元测试和集成测试中一样,立即出现的问题是:我们应该如何找到一个元素 - 通过id、名称、类名还是其他方式?

通过测试id查找

正如在 使用测试id查询DOM 中所讨论的,本指南建议使用 test ids 标记元素。

这些是类似于 data-testid="example" 的数据属性。在测试中,我们使用相应的属性选择器来找到这些元素,例如:

cy.get('[data-testid="example"]')
通过类型查找

虽然推荐使用测试id来查找元素,但在某些情况下,其他查找元素的方式仍然很有用。例如,您可能希望检查h1元素的存在和内容。这个元素具有特殊的含义,您不应该使用任意的测试id来找到它。

测试id的好处是它可以用于任何元素。使用测试id意味着忽略元素类型(如h1)和其他属性。如果这些发生了变化,测试不会失败。

但是,如果对于特定的元素类型或属性有特殊的原因,您的测试应该验证其使用情况。

与元素交互

为了测试计数器组件,我们想要验证第一个计数器的起始计数是“5”。当前计数存储在具有测试id count的元素中。因此,元素查找器为:

cy.get('[data-testid="count"]')
存在性和内容

cy.get命令已经内置了一个断言:它期望至少找到一个与选择器匹配的元素。否则,规范将失败。

接下来,我们检查元素的文本内容以验证起始计数。同样,我们使用should方法来创建一个断言。

cy.get('[data-testid="count"]').should('have.text', '5');

have.text断言将文本内容与给定的字符串进行比较。

我们成功了!我们找到了一个元素并检查了它的内容。

点击

现在让我们递增计数。我们找到并点击递增按钮(测试id为increment-button)。Cypress为此提供了cy.click方法。

cy.get('[data-testid="increment-button"]').click();

被测试的Angular代码处理了点击事件。最后,我们验证可见的计数增加了一个。我们重复使用 should('have.text', …) 命令,但期望一个更高的数字。

现在,测试套件看起来像这样:

describe('Counter', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it.only('has the correct title', () => {
    cy.title().should('equal', 'Angular Workshop: Counters');
  });

  it('increments the count', () => {
    cy.get('[data-testid="count"]').should('have.text', '5');
    cy.get('[data-testid="increment-button"]').click();
    cy.get('[data-testid="count"]').should('have.text', '6');
  });
});

我们需要测试的下一个功能是减少按钮。这个规范的工作方式类似于增加规范。它点击减少按钮(测试id为decrement-button)并检查计数是否减少了。

it('decrements the count', () => {
  cy.get('[data-testid="decrement-button"]').click();
  cy.get('[data-testid="count"]').should('have.text', '4');
});

最后但同样重要的是,我们测试重置功能。用户可以在表单字段(测试id为reset-input)中输入一个新的计数,并点击重置按钮(测试id为reset-button)来设置新的计数。

填写表单

Cypress的Chainer有一个通用的方法可以向可以与键盘进行交互的元素发送按键:type

为了向表单字段输入文本,我们将一个字符串传递给type方法。

cy.get('[data-testid="reset-input"]').type('123');

接下来,我们点击重置按钮,最后期望进行更改。

it('resets the count', () => {
  cy.get('[data-testid="reset-input"]').type('123');
  cy.get('[data-testid="reset-button"]').click();
  cy.get('[data-testid="count"]').should('have.text', '123');
});

这是完整的测试套件:

describe('Counter', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('has the correct title', () => {
    cy.title().should('equal', 'Angular Workshop: Counters');
  });

  it('increments the count', () => {
    cy.get('[data-testid="count"]').should('have.text', '5');
    cy.get('[data-testid="increment-button"]').click();
    cy.get('[data-testid="count"]').should('have.text', '6');
  });

  it('decrements the count', () => {
    cy.get('[data-testid="decrement-button"]').click();
    cy.get('[data-testid="count"]').should('have.text', '4');
  });

  it('resets the count', () => {
    cy.get('[data-testid="reset-input"]').type('123');
    cy.get('[data-testid="reset-button"]').click();
    cy.get('[data-testid="count"]').should('have.text', '123');
  });
});

在计数器项目的起始页面上,实际上有九个计数器实例。因此,cy.get命令返回的是九个元素,而不是一个。

首次匹配

typeclick这样的命令只能操作一个元素,因此我们需要将元素列表减少到第一个结果。这可以通过Cypress的first命令插入到链中来实现。

it('increments the count', () => {
  cy.get('[data-testid="count"]').first().should('have.text', '5');
  cy.get('[data-testid="increment-button"]').first().click();
  cy.get('[data-testid="count"]').first().should('have.text', '6');
});

这也适用于其他规范。如果测试的元素只出现一次,那么第一个命令当然是不必要的。

现在已经测试了所有计数器的功能。在接下来的章节中,我们将重构代码以提高其可读性和可维护性。

自定义Cypress命令

我们编写的测试代码相当重复。模式 cy.get('[data-testid="…"]') 一次又一次地重复出现。

第一个改进是编写一个隐藏此细节的辅助函数。我们已经编写了两个类似的函数作为 单元测试的辅助函数findElfindEls

通过测试id查找

创建一个用于查找元素的Cypress辅助函数的最简单方法是编写一个函数。

function findEl(testId: string): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.get(`[data-testid="${testId}"]`);
}

这样我们就可以写 findEl('count'),而不是 cy.get('[data-testid="count"]')

自定义命令

这种方式可以正常工作,但我们选择另一种方式。Cypress支持将自定义命令添加到cy命名空间中。我们将添加一个名为byTestId的命令,以便我们可以编写 cy.byTestId('count')

自定义命令放置在 cypress/support/commands.ts 文件中。这个文件是由Angular模板自动创建的。使用Cypress.Commands.add,我们将自定义命令作为cy的方法添加进去。第一个参数是命令的名称,第二个参数是作为函数的实现。

cy.byTestId

最简单的版本如下所示:

Cypress.Commands.add(
  'byTestId',
  (id: string) =>
    cy.get(`[data-testid="${id}"]`)
);

现在我们可以编写 cy.byTestId('count')。如果我们想通过其他方式查找元素,仍然可以回退到cy.get

cy.byTestId应该具有与通用cy.get相同的灵活性。因此,我们也添加了第二个options参数。我们从官方cy.get的类型定义中借用了函数签名。

Cypress.Commands.add(
  'byTestId',
  // Borrow the signature from cy.get
  <E extends Node = HTMLElement>(
    id: string,
    options?: Partial<
      Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow
    >,
  ): Cypress.Chainable<JQuery<E>> =
    cy.get(`[data-testid="${id}"]`, options),
);

为了进行正确的类型检查,我们需要告诉TypeScript编译器我们已经扩展了cy命名空间。在commands.ts中,我们通过为byTestId声明一个方法来扩展Chainable接口。

declare namespace Cypress {
  interface Chainable {
    /**
     * Get one or more DOM elements by test id.
     *
     * @param id The test id
     * @param options The same options as cy.get
     */
    byTestId<E extends Node = HTMLElement>(
      id: string,
      options?: Partial<
        Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow
      >,
    ): Cypress.Chainable<JQuery<E>>;
  }
}

你不必详细了解这些类型定义。它们只是确保你可以像传递给cy.get一样传递相同的选项cy.byTestId

保存commands.ts,然后打开cypress/support/e2e.ts并激活导入commands.ts的行。

import './commands';

就是这样!现在我们有了一个严格类型的命令cy.byTestId。使用这个命令,我们可以简化测试代码。

describe('Counter (with helpers)', () => {
  beforeEach(() => {
    cy.visit('/');
  });

  it('has the correct title', () => {
    cy.title().should('equal', 'Angular Workshop: Counters');
  });

  it('increments the count', () => {
    cy.byTestId('count').first().should('have.text', '5');
    cy.byTestId('increment-button').first().click();
    cy.byTestId('count').first().should('have.text', '6');
  });

  it('decrements the count', () => {
    cy.byTestId('decrement-button').first().click();
    cy.byTestId('count').first().should('have.text', '4');
  });

  it('resets the count', () => {
    cy.byTestId('reset-input').first().type('123');
    cy.byTestId('reset-button').first().click();
    cy.byTestId('count').first().should('have.text', '123');
  });
});

请记住,所有这些 first 调用只在被测试页面上有多个计数器的情况下才是必需的。如果页面上只有一个具有给定测试ID的元素,那么您不需要它们。

测试Flickr搜索

通过测试计数器应用程序,我们已经学习了使用Cypress进行基本测试的基础知识。让我们通过测试一个更复杂的应用程序——Flickr搜索来深入了解使用Cypress进行端到端测试。

在编写任何代码之前,让我们计划一下端到端测试需要做什么:

  1. 导航到“/”。

  2. 找到搜索输入字段并输入搜索词,例如“flower”。

  3. 找到提交按钮并点击它。

  4. 期望Flickr.com上的照片项目链接出现。

  5. 点击一个照片项目。

  6. 期望完整的照片详细信息出现。

不确定性的API

被测试的应用程序使用实际数据查询第三方API。该测试搜索“flower”,并且每次运行测试时,Flickr返回的结果可能不同。

在测试过程中处理这种依赖关系有两种方法:

  1. 针对真实的Flickr API进行测试。

  2. 模拟Flickr API并返回固定的响应。

如果我们针对真实的Flickr API进行测试,由于搜索结果会发生变化,我们无法对期望结果进行具体的说明。我们可以对搜索结果和完整照片进行表面测试。我们只知道点击的照片的标题或标签中包含“flower”。

真实API vs. 模拟API

这有优点和缺点。针对真实的Flickr API进行测试使得测试更加真实,但可靠性较低。如果Flickr API出现短暂故障,测试将失败,尽管我们的代码没有错误。

运行测试时针对模拟API可以使我们深入检查应用程序。应用程序是否渲染了API返回的照片?照片详细信息是否正确显示?

请记住,单元测试、集成测试和端到端测试相互补充。Flickr搜索还通过单元测试和集成测试进行了全面测试。

每种类型的测试应该做到最好。单元测试已经对不同的照片组件进行了详细测试。端到端测试不需要达到那种细节水平。

使用Cypress,这两种类型的测试都是可行的。首先,我们将针对真实的Flickr API进行测试。然后,我们将模拟API。

测试搜索表单

我们创建一个名为 cypress/e2e/flickr-search.cy.ts 的文件。我们从一个测试套件开始。

describe('Flickr search', () => {
  const searchTerm = 'flower';

  beforeEach(() => {
    cy.visit('/');
  });

  it('searches for a term', () => {
    /* … */
  });
});

我们指示浏览器在搜索字段(测试ID为 search-term-input)中输入“flower”。然后我们点击提交按钮(测试ID为 submit-search)。

it('searches for a term', () => {
  cy.byTestId('search-term-input')
    .first()
    .clear()
    .type(searchTerm);
  cy.byTestId('submit-search').first().click();
  /* … */
});
清除后再输入

type 命令不会用新值覆盖表单字段的值,而是逐个按键发送键盘输入。

在输入“flower”之前,我们需要清除字段,因为它已经有预填充的值。否则,我们会将“flower”附加到现有值上。为此,我们使用 Cypress 的 clear 方法。

单击提交按钮开始搜索。当 Flickr API 做出响应时,我们期望搜索结果出现。

期望搜索结果

搜索结果包含一个链接(a 元素,测试ID为 photo-item-link)和一张图片(img 元素,测试ID为 photo-item-image)。

由于从 Flickr 请求的结果数是15个,我们期望出现15个链接。

cy.byTestId('photo-item-link')
  .should('have.length', 15)

通过编写 should('have.length', 15),我们断言存在15个元素。

每个链接的 href 属性都应包含 https://www.flickr.com/photos/。我们无法检查确切的 URL,因为结果是动态的。但我们知道所有的 Flickr 照片 URL 具有相同的结构。

Chai 断言中没有直接检查列表中的每个链接是否具有包含 https://www.flickr.com/photos/href 属性的方法。我们需要逐个检查列表中的每个链接。

Chainer 提供了 each 方法,用于为每个元素调用一个函数。这类似于 JavaScript 的 forEach 数组方法。

cy.byTestId('photo-item-link')
  .should('have.length', 15)
  .each((link) => {
    /* Check the link */
  });

Cypress对我们来说有三个惊喜。

同步的jQuery对象
  1. link是一个同步值。在each回调内部,我们处于同步的JavaScript环境中。(我们可以在这里执行异步操作,但没有必要。)

  2. link的类型是JQuery<HTMLElement>。这是一个使用流行的jQuery库封装的元素。Cypress选择了jQuery,因为许多JavaScript开发人员已经熟悉它。为了读取href属性,我们使用 link.attr('href')

  3. 我们不能使用Cypress的should方法,因为它仅存在于Cypress Chainers上。但是在这里我们正在处理一个jQuery对象。我们必须使用标准的Chai断言。我们使用expectto.contain

这将引出:

cy.byTestId('photo-item-link')
  .should('have.length', 15)
  .each((link) => {
    expect(link.attr('href')).to.contain(
      'https://www.flickr.com/photos/'
    );
  });

测试现在如下所示:

describe('Flickr search', () => {
  const searchTerm = 'flower';

  beforeEach(() => {
    cy.visit('/');
  });

  it('searches for a term', () => {
    cy.byTestId('search-term-input')
      .first()
      .clear()
      .type(searchTerm);
    cy.byTestId('submit-search').first().click();

    cy.byTestId('photo-item-link')
      .should('have.length', 15)
      .each((link) => {
        expect(link.attr('href')).to.contain(
          'https://www.flickr.com/photos/'
        );
      });
    cy.byTestId('photo-item-image').should('have.length', 15);
  });
});

为了开始测试,我们首先使用 ng serve 命令启动开发服务器,然后再启动Cypress:

ng run flickr-search:cypress-open

这将打开测试运行器,在其中我们点击 flickr-search.cy.ts

测试完整的照片

当用户点击结果列表中的链接时,将捕获点击事件并在列表旁边显示完整的照片详细信息。(如果用户按住控制/命令键点击或右键点击,他们可以跟随链接访问 flickr.com。)

在端到端测试中,我们添加一个规范来验证这种行为。

it('shows the full photo', () => {
  /* … */
});

首先,它搜索“flower”,就像之前的规范一样。

cy.byTestId('search-term-input').first().clear().type(searchTerm);
cy.byTestId('submit-search').first().click();

然后我们找到所有的照片项目链接,但不是为了检查它们,而是为了点击第一个链接:

cy.byTestId('photo-item-link').first().click();

点击后,照片详细信息将出现。正如上面提到的,我们无法检查特定的标题、特定的照片URL或特定的标签。每次测试运行时,点击的照片可能是不同的。

由于我们搜索了“flower”,该词要么在照片标题中,要么在标签中。我们检查具有测试ID full-photo 的包装元素的文本内容。

cy.byTestId('full-photo').should('contain', searchTerm);
Contain vs. have text

包含(contain)断言检查给定的字符串是否出现在元素的文本内容中。(相比之下,have.text断言检查内容是否与给定的字符串相等。它不允许有额外的内容。)

接下来,我们检查是否存在标题和一些标签,并且它们不为空。

cy.byTestId('full-photo-title').should('not.have.text', '');
cy.byTestId('full-photo-tags').should('not.have.text', '');

图片本身需要存在。我们无法详细检查src属性。

cy.byTestId('full-photo-image').should('exist');

现在规范的样子如下:

it('shows the full photo', () => {
  cy.byTestId('search-term-input').first().clear().type(searchTerm);
  cy.byTestId('submit-search').first().click();

  cy.byTestId('photo-item-link').first().click();
  cy.byTestId('full-photo').should('contain', searchTerm);
  cy.byTestId('full-photo-title').should('not.have.text', '');
  cy.byTestId('full-photo-tags').should('not.have.text', '');
  cy.byTestId('full-photo-image').should('exist');
});

这里给出的断言(containtextexist)是由Chai-jQuery定义的,Chai-jQuery是一个用于检查jQuery元素列表的断言库。

恭喜,我们已经成功测试了Flickr搜索!这个示例演示了几个Cypress命令和断言。我们也对Cypress的内部机制有了一瞥。

页面对象

我们编写的Flickr搜索端到端测试是完全可用的。我们可以进一步改进代码,以增加代码的清晰性和可维护性。

我们引入了一种称为页面对象的设计模式。设计模式是一种经过验证的代码结构,是解决常见问题的最佳实践。

高级交互

页面对象表示受端到端测试审查的网页。页面对象提供了一个高级接口,用于与页面进行交互。

到目前为止,我们编写的是低级别的端到端测试。它们通过硬编码的测试ID查找单个元素,检查其内容并点击它们。这对于小型测试是可以的。

但是,如果页面逻辑复杂,有多种测试情况,那么测试将变成一堆难以管理的低级指令。很难找到这些测试的要点,并且很难进行更改。

页面对象将众多低级指令组织成少数高级交互。Flickr搜索应用程序中的高级交互是什么?

  1. 使用搜索词搜索照片

  2. 读取照片列表并与列表项交互

  3. 读取照片详细信息

在可能的情况下,我们将这些交互分组为页面对象的方法。

普通类

页面对象只是一个抽象模式,其具体实现取决于您。通常,页面对象被声明为在测试开始时实例化的类。

我们将这个类称为FlickrSearch,并将其保存在一个单独的文件cypress/pages/flickr-search.page.ts中。pages目录专门用于页面对象,并且.page.ts后缀标记了页面对象。

export class FlickrSearch {
  public visit(): void {
    cy.visit('/');
  }
}

这个类有一个visit方法,用于打开页面,即页面对象所表示的页面。

在测试中,我们导入这个类,并在beforeEach块中创建一个实例。

import { FlickrSearch } from '../pages/flickr-search.page';

describe('Flickr search (with page object)', () => {
  const searchTerm = 'flower';

  let page: FlickrSearch;

  beforeEach(() => {
    page = new FlickrSearch();
    page.visit();
  });

  /* … */
});

FlickrSearch实例存储在describe作用域中声明的变量中。这样,所有的规范都可以访问页面对象。

搜索

让我们在页面对象上实现第一个高级交互:搜索照片。我们将相关的代码从测试中移到页面对象的一个方法中。

public searchFor(term: string): void {
  cy.byTestId('search-term-input').first().clear().type(term);
  cy.byTestId('submit-search').first().click();
}

searchFor方法接受一个搜索词,并执行所有必要的步骤。

元素查询

其他高级交互,如读取照片列表和照片详情,无法转换为页面对象方法。但是,我们可以将测试ID和元素查询移动到页面对象中。

public photoItemLinks(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('photo-item-link');
}

public photoItemImages(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('photo-item-image');
}

public fullPhoto(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('full-photo');
}

public fullPhotoTitle(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('full-photo-title');
}

public fullPhotoTags(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('full-photo-tags');
}

public fullPhotoImage(): Cypress.Chainable<JQuery<HTMLElement>> {
  return cy.byTestId('full-photo-image');
}

这些方法返回元素链式调用器(element Chainers)。

接下来,我们将重写端到端测试,以使用页面对象的方法。

import { FlickrSearch } from '../pages/flickr-search.page';

describe('Flickr search (with page object)', () => {
  const searchTerm = 'flower';

  let page: FlickrSearch;

  beforeEach(() => {
    page = new FlickrSearch();
    page.visit();
  });

  it('searches for a term', () => {
    page.searchFor(searchTerm);
    page
      .photoItemLinks()
      .should('have.length', 15)
      .each((link) => {
        expect(link.attr('href')).to.contain(
          'https://www.flickr.com/photos/'
        );
      });
    page.photoItemImages().should('have.length', 15);
  });

  it('shows the full photo', () => {
    page.searchFor(searchTerm);
    page.photoItemLinks().first().click();
    page.fullPhoto().should('contain', searchTerm);
    page.fullPhotoTitle().should('not.have.text', '');
    page.fullPhotoTags().should('not.have.text', '');
    page.fullPhotoImage().should('exist');
  });
});

对于上面的Flickr搜索,使用页面对象可能有些过于复杂。然而,该示例展示了页面对象的关键思想:

  • 识别重复的高级交互并将其映射到页面对象的方法中。

  • 将元素的查找操作移动到页面对象中。用于查找的测试ID、标签名称等应该统一存放在一个集中的位置。

    当被测试页面的标记发生变化时,需要更新页面对象,但测试本身不应该改变。

  • 将所有断言(shouldexpect)保留在规范中,不要将它们移动到页面对象中。

高级测试

在编写端到端测试时,很容易陷入技术细节的困扰:查找元素、点击元素、填写表单字段、检查字段的值和文本内容。但是端到端测试不应该围绕这些低级细节展开。它们应该描述用户在高层次上的行为流程。

重构的目标不是为了简洁。使用页面对象并不一定会导致代码量减少。页面对象的目的是将低级细节(如通过测试ID查找元素)与高级用户在应用程序中的行为流程分离。这使得规范更易阅读和维护。

当您感觉需要整理复杂、重复的测试时,可以使用页面对象模式。一旦您熟悉了这种模式,它也有助于您在首次编写测试时避免编写这样的测试。

伪造Flickr API

我们为Flickr搜索编写的端到端测试使用了真实的Flickr API。如前所述,这使得测试更加真实可信。

该测试确保应用程序与第三方API紧密配合。但它使得测试变慢,并且只允许进行不太具体的断言。

拦截HTTP请求

使用Cypress,我们可以解除对API的依赖。Cypress允许我们拦截HTTP请求并使用虚假数据进行响应。

首先,我们需要设置虚假数据。我们已经为 FlickrService单元测试 创建了虚假的照片对象。为简单起见,我们只需导入它们:

import {
  photo1,
  photo1Link,
  photos,
  searchTerm,
} from '../../src/app/spec-helpers/photo.spec-helper';

使用虚假照片,我们创建一个模仿Flickr响应中相关部分的虚假响应对象。

const flickrResponse = {
  photos: {
    photo: photos,
  },
};
使用路由创建虚假服务器

现在,我们指示Cypress拦截Flickr API请求,并用虚假数据进行响应。这个设置在测试的beforeEach块中完成。相应的Cypress命令是cy.intercept

beforeEach(() => {
  cy.intercept(
    {
      method: 'GET',
      url: 'https://www.flickr.com/services/rest/*',
      query: {
        tags: searchTerm,
        method: 'flickr.photos.search',
        format: 'json',
        nojsoncallback: '1',
        tag_mode: 'all',
        media: 'photos',
        per_page: '15',
        extras: 'tags,date_taken,owner_name,url_q,url_m',
        api_key: '*',
      },
    },
    {
      body: flickrResponse,
      headers: {
        'Access-Control-Allow-Origin': '*',
      },
    },
  ).as('flickrSearchRequest');

  cy.visit('/');
});

cy.intercept可以以不同的方式调用。在这里,我们传递了两个对象:

  1. 一个路由匹配器,描述要拦截的请求。它包含HTTP GET方法、基本URL和一堆查询字符串参数。在URL和api_key查询参数中,*字符是一个通配符,可以匹配任意字符串。

  2. 一个路由处理器,描述Cypress应该发送的响应。我们将flickrResponse虚假对象作为JSON响应主体传递。

    由于对Flickr的请求是跨域的,我们需要设置 Access-Control-Allow-Origin: * 头部。这允许我们的源自http://localhost:4200的Angular应用程序读取来自https://www.flickr.com的响应。

别名

最后,我们通过调用 .as('flickrSearchRequest') 为请求设置一个别名(alias)。这使得以后可以使用@flickrSearchRequest别名引用该请求。

设置完成后,Cypress会拦截对Flickr的请求并自行处理。原始的Flickr API不会被访问。

现有的相对通用的规范仍然通过。在我们使它们更具体之前,我们需要验证Cypress是否找到了匹配并拦截了HTTP请求。因为如果没有拦截,测试仍然会通过。

等待请求

我们可以通过在启动搜索后显式等待请求来实现这一点。

it('searches for a term', () => {
  cy.byTestId('search-term-input').first().clear().type(searchTerm);
  cy.byTestId('submit-search').first().click();

  cy.wait('@flickrSearchRequest');

  /* … */
});

cy.wait('@flickrSearchRequest') 告诉Cypress等待满足指定条件的请求。@flickrSearchRequest是我们之前定义的别名。

如果Cypress在超时时间内找不到匹配的请求,测试将失败。如果Cypress捕获了该请求, 我们就知道Angular应用程序接收了photos数组中指定的照片。

具体断言

通过伪造Flickr API,我们对响应拥有完全控制权。我们选择返回固定数据。被测试的应用程序以确定性的方式处理数据。如前所述,这使我们能够验证应用程序是否正确地呈现了API返回的照片。

让我们编写具体的断言,将结果列表中的照片与photos数组中的照片进行比较。

it('searches for a term', () => {
  cy.byTestId('search-term-input').first().clear().type(searchTerm);
  cy.byTestId('submit-search').first().click();

  cy.wait('@flickrSearchRequest');

  cy.byTestId('photo-item-link')
    .should('have.length', 2)
    .each((link, index) => {
      expect(link.attr('href')).to.equal(
        `https://www.flickr.com/photos/${photos[index].owner}/${photos[index].id}`,
      );
    });
  cy.byTestId('photo-item-image')
    .should('have.length', 2)
    .each((image, index) => {
      expect(image.attr('src')).to.equal(photos[index].url_q);
    });
});

在这里,我们遍历链接和图片,以确保URL来自虚假数据。之前,在针对真实API进行测试时,我们只对链接进行了表面测试。我们根本无法测试图片URL。

同样地,对于完整照片规范,我们将断言更具体。

it('shows the full photo', () => {
  cy.byTestId('search-term-input').first().clear().type(searchTerm);
  cy.byTestId('submit-search').first().click();

  cy.wait('@flickrSearchRequest');

  cy.byTestId('photo-item-link').first().click();
  cy.byTestId('full-photo').should('contain', searchTerm);
  cy.byTestId('full-photo-title').should('have.text', photo1.title);
  cy.byTestId('full-photo-tags').should('have.text', photo1.tags);
  cy.byTestId('full-photo-image').should('have.attr', 'src', photo1.url_m);
  cy.byTestId('full-photo-link').should('have.attr', 'href', photo1Link);
});

现在,规范确保被测试的应用程序输出来自Flickr API的数据。have.text检查元素的文本内容,而have.attr检查srchref属性。

我们完成了!我们的端到端测试拦截了API请求,并以虚假数据作出响应,以深入检查应用程序。

拦截所有请求

对于Flickr搜索而言,我们已经拦截了对第三方API的HTTP请求。Cypress允许伪造任何请求,包括对自己的HTTP API的请求。

这对于返回对于被测试功能至关重要的确定性响应非常有用。但它还可以用于抑制对于测试无关的请求,比如边缘图像和网络分析。

端到端测试:总结

过去,端到端测试成本高昂,而结果却不理想。很难编写测试,即使应用程序正常工作,也能可靠地通过测试。这些时间无法投入到编写发现错误和回归的有用测试中。

多年来,Protractor一直是许多Angular开发人员依赖的端到端测试框架。随着Cypress的出现,它树立了新的标准。

本指南建议从Cypress开始,因为它在开发者体验和成本效益方面表现出色。然而,如果您需要测试广泛的浏览器范围,基于WebDriver的框架如Webdriver.io也是有用的。

即使使用Cypress,端到端测试比Jasmine和Karma的单元和集成测试要复杂得多,并且容易出错。然而,端到端测试在真实环境下测试功能非常有效。