之前我们讲到了页面对象模型(Page Object Model, POM)
重构交互代码,本文继续分享如何将不同的独特行为分离为独立的、原子的测试,从而让代码结构上更加清晰。
1. 识别独特行为
当前,我们有一个测试执行了五个步骤:
-
加载应用
-
创建一个新看板
-
创建一个新列表
-
向列表添加卡片
-
导航到主页 每个步骤都涵盖了一个独特的行为。如果其中任何一个行为失败,它将导致整个测试失败。此外,失败可能会阻止某些功能被覆盖。例如,如果创建新卡片失败,那么主页导航将不会被尝试。这并不理想:每个行为都应该有自己的测试来报告其结果。
每个行为都可以成为一个单独的测试。每个行为也可能包含相关但不同的行为。例如,卡片行为包括:
-
• 添加一张卡片
-
• 添加多张卡片
-
• 删除一张卡片
-
• 编辑卡片名称
-
• 移动卡片以改变列表的顺序
-
• 将卡片移动到不同的列表 所有这些行为都有一个共同的起点:必须有一个带有列表(或多个列表)的看板。这些测试可以共享常见的设置步骤。其他行为可能需要相同的设置步骤,但它们可能也有其他需求。
2. 将行为拆分为单独的测试
随着我们添加越来越多的测试,保持它们的原子性和独立性将变得越来越重要。让我们将一个大的测试拆分为多个涵盖各自行为的小测试。
2.1 测试结构
当前,trello-test.ts
模块有两个定义:
一个 test.beforeAll
函数,用于在所有测试之前清除数据库。 一个包含一个测试用例的 test
函数。 我们需要将测试拆分为多个测试。通常在使用 Playwright
时,最好将相关的测试组合在一起,以便它们可以共享常见对象并生成分层报告。
我们可以使用 test.describe
定义将测试组合在一起。为我们的新测试添加一个:
import { test, expect } from './fixtures/trello-test';
test.describe('Trello-like board', () => {
// ...
});
由于我们的测试将共享常见的名称,如“Chores”
和“TODO”
,让我们在 test.describe
块内将它们添加为常量:
const boardName = 'Chores';
const listName = 'TODO';
2.2 Before钩子
我们还应该转换设置步骤。之前,我们有一个 test.beforeAll
定义,它在所有测试之前运行一次。现在,我们需要在每个测试之前运行的设置。
此外,我们需要做的不仅仅是重置数据库。由于每个测试需要加载应用程序并创建一个看板,因此我们应该将这些交互添加到常见设置中。这将使我们避免在每个测试中重复步骤。
如下重写 before
钩子,并将其放入 test.describe
块中:
test.beforeEach(async ({ request, getStartedPage }) => {
await request.post('http://localhost:3000/api/reset');
await getStartedPage.load();
await getStartedPage.createFirstBoard(boardName);
});
2.3 测试看板创建
第一个测试应该确保创建新看板正常工作。由于 before
钩子已经执行了创建看板的交互,这个测试只需要进行断言。
在 test.describe
定义中添加以下测试定义:
test('should create the first board', async ({ boardPage }) => {
await boardPage.expectNewBoardLoaded(boardName);
});
编写仅包含断言的测试似乎有些奇怪。通常,我们希望在测试用例主体中包含交互(如 getStartedPage.createFirstBoard
)以定义交互和验证。然而,由于这一组中的其他测试都需要一个已创建的看板作为起点,依赖 before
钩子创建看板只是为了方便。
2.4 测试列表创建
创建新看板后,用户可以在该看板上创建一个新列表。添加一个这样的测试:
test('should create the first list in a board', async ({ boardPage }) => {
await boardPage.addList(listName);
await expect(boardPage.listName).toHaveValue(listName);
});
这个测试不需要重复前一个测试的断言。相反,它可以专注于手头的行为:在看板上创建一个新列表。
2.5 测试卡片创建
一旦创建了看板,也可以测试卡片创建。添加一个这样的测试:
test('should create a list with multiple cards', async ({ boardPage }) => {
await boardPage.addList(listName);
await boardPage.addCardToList(0, 'Buy groceries');
await boardPage.addCardToList(0, 'Mow the lawn');
await boardPage.addCardToList(0, 'Walk the dog');
await expect(boardPage.cardTexts).toHaveText(
['Buy groceries', 'Mow the lawn', 'Walk the dog']);
});
这个测试确实需要在添加卡片之前在看板上创建一个列表。
2.6 测试主页导航
最后一个测试是主页导航。无论看板上有什么或没有什么,都无关紧要:我们可以独立于列表和卡片来测试导航。添加一个这样的测试:
test('should navigate home from a board', async ({ boardPage, myBoardsPage }) => {
await boardPage.goHome();
await myBoardsPage.expectLoaded([boardName]);
});
2.7 重新运行最终代码
最终测试代码应如下所示:
import { test, expect } from './fixtures/trello-test';
test.describe('Trello-like board', () => {
const boardName = 'Chores';
const listName = 'TODO';
test.beforeEach(async ({ request, getStartedPage }) => {
await request.post('http://localhost:3000/api/reset');
await getStartedPage.load();
await getStartedPage.createFirstBoard(boardName);
});
test('should create the first board', async ({ boardPage }) => {
await boardPage.expectNewBoardLoaded(boardName);
});
test('should create the first list in a board', async ({ boardPage }) => {
await boardPage.addList(listName);
await expect(boardPage.listName).toHaveValue(listName);
});
test('should create a list with multiple cards', async ({ boardPage }) => {
await boardPage.addList(listName);
await boardPage.addCardToList(0, 'Buy groceries');
await boardPage.addCardToList(0, 'Mow the lawn');
await boardPage.addCardToList(0, 'Walk the dog');
await expect(boardPage.cardTexts).toHaveText(
['Buy groceries', 'Mow the lawn', 'Walk the dog']);
});
test('should navigate home from a board', async ({ boardPage, myBoardsPage }) => {
await boardPage.goHome();
await myBoardsPage.expectLoaded([boardName]);
});
});
运行这些新测试(npx playwright test --ui 或 npx playwright test tests/trello.spec.ts --workers 1)
以确保它们通过。(如果从UI
模式运行它们,请确保一次运行一个!)覆盖范围与之前相同。主要好处是每个独特行为都是独立测试和报告的。
2.8 优化设置步骤
优化端到端测试的最佳方法之一是使用API
调用来为测试设置数据。API
交互比UI交互快得多且不易出错。例如,我们可以使用API
调用在每个测试之前创建一个新看板,而不是依赖UI交互来输入文本并按下回车键。我们已经使用API
调用在每个测试之前重置数据库。
这种方法有一些权衡:
-
• 编写
API
调用可能比编写UI交互更复杂。 -
• 在所有情况下,
API
调用可能不会比UI
交互节省时间。 -
•
API
调用应仅用于设置和清理,而不是用于主要行为。
请根据您的最佳判断,在何时何地尝试使用API
调用优化测试设置和清理。
小结
在本章中,我们通过将不同的行为分离为独立的测试,成功地将一个大测试拆解成多个小测试。这种方法不仅使每个测试更易于维护,还提高了测试的可读性和可靠性。我们还探讨了如何通过API调用来优化测试的设置步骤,以提高测试的效率和稳定性。
在下一篇文章中,我们将深入探讨“并行执行”的主题,了解如何利用Playwright
的特性来同时运行多个测试,从而大幅缩短测试的总执行时间。
并行执行扩展测试
3. 尝试并行执行
自从我们开始为类似Trello
的应用编写测试以来,我们一直是串行运行测试,也就是一次运行一个。现在试着并行运行它们,看看会发生什么:
npx playwright test tests/trello.spec.ts
不可避免地,一些测试将因测试数据的冲突而失败。在每个测试之前,测试套件都会重置整个数据库。所有测试都针对运行在本地机器上的一个Trello-like
应用实例,因此重置数据库会影响所有正在运行的测试。在错误的时刻重置可能会破坏测试所需的板、列表和卡片。这不是我们想要的:每个行为都应该有自己的测试,以报告其结果。
4. 评估数据管理策略
Playwright
在一组工作线程中并行执行测试。工作线程并行运行,但每个单独工作线程上的测试串行执行。有几种方法可以避免这些工作线程之间的测试数据冲突。
-
• 一种方法是使用多个
Web
应用实例。我们可以为每个工作线程运行一个应用实例,其中每个应用实例都有自己的数据库。在这种情况下,一个工作线程不可能影响另一个工作线程的应用或数据。然而,启动多个应用实例可能难以设置,并且可能会使用大量系统资源。 -
• 第二种方法是使用每个工作线程的单独测试数据集。例如,在许多应用中,一个用户无法访问另一个用户的数据。如果每个工作线程专门使用其自己的用户账户,则工作线程不会发生冲突。在Playwright中,我们可以将工作线程的并行索引映射到特定的数据集(如用户)。单独的测试数据集可能比多个应用实例更容易管理,但仍需要仔细管理。
-
• 第三种方法是编写测试,使其测试数据不会发生冲突。这意味着任何多个测试(或工作线程)可能访问的数据必须视为不可变(或常量)。任何测试动态创建的数据必须由该测试独占使用。这是当管理环境和数据太困难(或不可能)时最简单的策略。然而,它通常限制了测试的覆盖范围。某些测试需要修改共享数据,这些测试必须要么被跳过,要么在并行执行之外串行运行。
5. 重写测试以避免冲突
对于本教程,数据管理策略#3是最容易实现的。让我们更改代码以实现它。
认识到失去的覆盖 不幸的是,这种数据管理策略将要求我们跳过“Get Started!”
页面。“Get Started!”
页面仅在应用没有创建板时出现。由于每个测试都需要一个板,并且所有板都是共享的,我们不能强制“Get Started!”
页面出现。
为了简化,我们将删除与“Get Started!”
页面的测试和交互。我们还将更改前置钩子,以通过API
为每个测试创建一个具有唯一名称的新板。这样,每个测试都可以有一个共同的起点。
添加与我的板页面的交互 由于我们将删除“Get Started!”
页面,我们需要添加与“我的板”页面的新交互。
当每个测试启动并创建其板时,它将加载主页,应该重定向到“我的板”页面,而不是“Get Started!”
页面。将以下方法添加到tests/pages/my-boards.ts
:
async load() {
await this.page.goto('http://localhost:3000/');
}
要加载板页面,测试需要通过名称打开该板。也为此添加一个方法:
async openBoard(boardName: string) {
await this.page.getByText(boardName).click();
}
重写测试设置 当前的test.beforeEach
钩子重置整个数据库并创建第一个板。其代码如下:
const boardName = 'Chores';
const listName = 'TODO';
test.beforeEach(async ({ request, getStartedPage }) => {
await request.post('http://localhost:3000/api/reset');
await getStartedPage.load();
await getStartedPage.createFirstBoard(boardName);
});
我们需要以几种方式更改它。首先,每个测试不能清除整个数据库。相反,每个测试需要创建自己的新板。每个板需要一个唯一的名称,以便其他测试不会意外使用它。一个简单的方法是为每个板分配一个随机数。
其次,每个测试需要加载Web
应用并打开它创建的板。这就是MyBoardsPage
的新方法将被调用的地方。
将代码更改为如下所示:
let boardName: string;
const listName = 'TODO';
test.beforeEach(async ({ request, myBoardsPage }) => {
const randomNumber = Math.trunc(Math.random() * 1000000);
boardName = 'Chores ' + `${randomNumber}`;
await request.post('http://localhost:3000/api/boards', {data: {name: boardName}});
await myBoardsPage.load();
await myBoardsPage.openBoard(boardName);
});
6. 重写测试用例
值得庆幸的是,测试用例步骤可以保持不变,因为前置钩子为每个测试创建了一个唯一的板。
唯一需要更改的是更改其中一个测试名称:“应创建第一个板”不再作为标题有意义,因为验证的板可能不是创建的“第一个”板。让我们将其更改为“应显示新板”:
test('should display the new board', async ({ boardPage }) => {
await boardPage.expectNewBoardLoaded(boardName);
});
理论上,我们还可以删除tests/pages/get-started.ts
文件,并从tests/fixtures/trello-test.ts
中删除它,因为它不再使用。
7. 大规模运行测试
使用更新的代码并行重新运行测试:
npx playwright test tests/trello.spec.ts
不仅Playwright
会跨多个工作线程运行测试,它还会跨所有三个浏览器(Chromium
、Firefox
和WebKit
)运行测试。
在并行运行测试时,请注意奇怪的、间歇性的失败。这些表明发生了冲突。
另一个需要考虑的因素是数据库清理。对于我们选择的数据策略,测试将继续添加越来越多的板。最终,数据库应重置,否则可能会破坏应用或运行应用的机器。数据库重置应定期在测试自动化代码之外进行。
总结
在本章中,我们通过在不同浏览器上并行运行测试,扩展了我们的测试规模。我们探讨了避免测试数据冲突的不同策略,并选择了最简单的策略:重写测试以避免数据冲突。尽管这一策略牺牲了一些测试覆盖范围,但它使我们的测试能够安全并行运行。
在下篇文章中,我们将深入探讨并行执行的高级主题,包括如何优化测试执行时间和管理并行测试环境。