最好的厨师都知道甜点不仅应该味道正确;它也必须看起来不错。
在上一节中,您学习了如何设置 Jest 以便您可以测试您的脚本,但是您还没有检查页面是否向您的用户显示了正确的输出。在本节中,您将了解您的脚本如何与页面的标记交互并学习如何在 DOM 上进行断言。
在我们开始编写测试之前,重构上一节的应用程序,使其可以管理多个库存项目,而不仅仅是芝士蛋糕。
因为您使用 Browserify 来捆绑您的应用程序,所以您可以创建一个单独的inventoryController.js 文件来管理库存中的项目,并将其存储在内存中。
注意现在,我们将所有数据存储在内存中,并专注于测试我们的 Web 客户端。在本章的最后一节中,您将学习如何将前端应用程序连接到第 4 章中的服务器并测试其后端集成。
const data = { inventory: {} };
const addItem = (itemName, quantity) => {
const currentQuantity = data.inventory[itemName] || 0;
data.inventory[itemName] = currentQuantity + quantity;
};
module.exports = { data, addItem };
正如我们在上一节中所做的那样,您可以通过导入inventoryController.js、为inventory 属性分配一个空对象、执行addItem 函数并检查inventory 的内容——通常的三个As 模式来为这个函数添加一个测试。
const { addItem, data } = require("./inventoryController");
describe("addItem", () => {
test("adding new items to the inventory", () => {
data.inventory = {}; ❶
addItem("cheesecake", 5); ❷
expect(data.inventory.cheesecake).toBe(5); ❸
});
});
❶排列:为库存分配一个空对象,代表其初始状态
❷ Act:练习addItem函数,在库存中添加5个芝士蛋糕
❸ Assert:检查库存是否包含正确数量的芝士蛋糕
运行 Jest 应该表明您的测试通过了,但即使 addItem 有效,它也不会使用清单的内容更新页面。 要使用库存商品列表更新页面,请更新您的 index.html 文件,使其包含一个无序列表,我们将在其中添加商品,如下所示。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Inventory Manager</title>
</head>
<body>
<h1>Inventory Contents</h1>
<ul id="item-list"></ul>
<script src="bundle.js"></script>
</body>
</html>
创建这个无序列表后,创建一个名为 domController.js 的文件,并编写一个 updateItemList 函数。 此功能应接收库存并相应地更新项目列表。
const updateItemList = inventory => {
const inventoryList = window.document.getElementById("item-list");
inventoryList.innerHTML = ""; ❶
Object.entries(inventory).forEach(([itemName, quantity]) => { ❷
const listItem = window.document.createElement("li");
listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
inventoryList.appendChild(listItem);
});
};
module.exports = { updateItemList };
❶ 清除列表
❷ 为库存中的每件物品,创建一个 li 元素,将其内容设置为包括物品的名称和数量,并将其附加到物品列表中
最后,您可以将所有这些放在 main.js 文件中。 继续尝试通过使用 addItem 和调用 updateItemList 将一些项目添加到清单中,并将新清单传递给它。
const { addItem, data } = require("./inventoryController");
const { updateItemList } = require("./domController");
addItem("cheesecake", 3);
addItem("apple pie", 8);
addItem("carrot cake", 7);
updateItemList(data.inventory);
注意 因为你应该完全重写 main.js,它在 main.test.js 的测试不再适用,因此可以删除。
别忘了,因为我们使用 Node.js 的模块系统来启用测试,所以我们必须通过 Browserify 运行 main.js,以便它可以生成一个能够在浏览器中运行的 bundle.js 文件。 bundle .js 不依赖像 require 和 module 这样的 API,而是包含了针对inventoryController.js 和 domController.js 的代码。
使用 ./node_modules/.bin/browserify main.js -o bundle.js 构建 bundle.js 后,您可以使用 npx http-server ./ 为您的应用程序提供服务,并访问 localhost:8080 以查看清单项目列表。
到目前为止,您只测试了 addItem 是否充分更新了应用程序的状态,但您根本没有检查 updateItemList。即使 addItem 的单元测试通过了,也不能保证 updateItemList 可以在您提供当前库存时更新页面。
因为 updateItemList 依赖于页面的标记,所以你必须设置 Jest 的 JSDOM 使用的文档的 innerHTML,就像我们在上一节中所做的那样。
const fs = require("fs");
document.body.innerHTML = fs.readFileSync("./index.html");
提示 除了窗口,文档在您的测试中也是全局的。 您可以通过访问文档而不是 window.document 来节省自己的几次击键次数。
使用 index.html 页面的内容设置 JSDOM 实例后,再次使用三个 As 模式测试 updateItemList:通过创建一个包含几个项目的清单来设置场景,将其传递给 updateItemList,并检查它是否更新了 DOM 适当地。
鉴于此,多亏了 Jest 和 JSDOM,全局文档就像在浏览器中一样工作,您可以使用浏览器 API 来查找 DOM 节点并对其进行断言。
例如,尝试使用 querySelector 查找作为 body 的直接子节点的无序列表,并对其包含的 childNode 数量进行断言。
// ...
document.body.innerHTML = fs.readFileSync("./index.html"); ❶
const { updateItemList } = require("./domController");
describe("updateItemList", () => {
test("updates the DOM with the inventory items", () => {
const inventory = { ❷
cheesecake: 5,
"apple pie": 2,
"carrot cake": 6
};
updateItemList(inventory); ❸
const itemList = document.querySelector("body > ul"); ❹
expect(itemList.childNodes).toHaveLength(3); ❺
});
});
❶ 因为您将 index.html 文件的内容分配给 body 的 innerHTML,所以当测试运行时页面将处于其初始状态。
❷ 创建一个包含几个不同项目的库存表示
❸ Act:练习updateItemList函数
❹ 根据列表在 DOM 中的位置查找列表
❺ Assert:检查列表是否包含正确数量的子节点
JSDOM 中的 DOM 元素包含与浏览器中相同的属性,因此您可以通过对列表中每个项目的 innerHTML 进行断言来进行更严格的测试。
// ...
test("updates the DOM with the inventory items", () => {
const inventory = { /* ... */ };
updateItemList(inventory);
const itemList = document.getElementById("item-list");
expect(itemList.childNodes).toHaveLength(3);
// The `childNodes` property has a `length`, but it's _not_ an Array
const nodesText = Array.from(itemList.childNodes).map( ❶
node => node.innerHTML
);
expect(nodesText).toContain("cheesecake - Quantity: 5");
expect(nodesText).toContain("apple pie - Quantity: 2");
expect(nodesText).toContain("carrot cake - Quantity: 6");
});
// ...
❶ 从 itemList 中的每个节点中提取 innerHTML,创建一个字符串数组。
因为您将直接调用 updateItemList 但检查 DOM 以断言该函数是否产生了正确的输出,所以我将这个 updateItemList 测试归类为集成测试。 它专门测试 updateItemList 是否正确更新了页面的标记。
您可以在图 6.3 中看到此测试如何与其他模块交互。
请注意测试金字塔如何渗透到您的所有测试中。 您用于测试后端应用程序的相同原则也适用于前端应用程序。
之前测试的问题在于它与页面的标记紧密耦合。 它依赖于 DOM 的结构来查找节点。 如果页面的标记发生变化,节点不在完全相同的位置,则测试将失败,即使从用户的角度来看,应用程序仍然完美无缺。
例如,假设您想将无序列表包装在一个 div 中以用于文体目的,如下所示。
< !-- ... -->
<body>
<h1>Inventory Contents</h1>
<div class="beautiful-styles">
<ul id="item-list"></ul>
</div>
<script src="bundle.js"></script>
</body>
< !-- ... -->
此更改将使您在 domController 中的测试失败,因为它将不再找到无序列表。因为测试依赖于列表是 body 的直接后代,所以只要你将 item-list 包装在任何其他元素中,它就会失败。
在这种情况下,您不必担心列表是否是 body 的直接后代。相反,您需要保证的是它存在并且它包含正确的项目。仅当您的目标是确保 ul 直接来自 body 时,此查询才足够。
您应该将测试中的查询视为内置断言。例如,如果你想断言一个元素是另一个元素的直接后代,你应该编写一个依赖于它的 DOM 位置的查询。
注意 我们之前已经在第 5 章的最后一节讨论了如何将断言转换为前提条件。依赖于元素特定特性的查询以相同的原则运行。
在编写前端时,您很快就会注意到 DOM 结构会经常更改,而不会影响应用程序的整体功能。因此,在绝大多数情况下,您应该避免将测试与 DOM 结构耦合。否则,您将不得不过于频繁地更新测试,从而产生额外的成本,即使应用程序仍然有效。
为避免依赖于 DOM 的结构,请更新 domController 中的测试,以便它通过 id 找到列表,如下面的代码所示。
// ...
test("updates the DOM with the inventory items", () => {
const inventory = { /* ... */ };
updateItemList(inventory);
const itemList = document.getElementById("item-list"); ❶
expect(itemList.childNodes).toHaveLength(3);
// ...
});
// ...
❶ 根据 id 查找列表
通过根据 id 查找列表,您可以自由地在 DOM 中移动它并将其包装在您想要的任意数量的元素中。 只要它有相同的 id,你的测试就会通过。
提示您要断言的元素并不总是具有 id 属性。 例如,您的应用程序可能不使用 id 属性来查找元素。
在这种情况下,将具有诸如 id 之类的强语义的属性附加到您的元素并不是最佳选择。 相反,您可以添加一个唯一的 data-testid 属性,并使用它通过 document.querySelector ('[data-testid="your-element-testid"]') 来查找您的元素。
现在,为了指示自页面首次加载以来发生了哪些操作,请更新您的 updateItemList 函数,以便它在运行时将新段落附加到文档正文。
// ...
const updateItemList = inventory => {
// ...
const inventoryContents = JSON.stringify(inventory);
const p = window.document.createElement("p"); ❶
p.innerHTML = `The inventory has been updated - ${inventoryContents}`; ❷
window.document.body.appendChild(p); ❸
};
module.exports = { updateItemList };
❶ 创建段落元素
❷ 设置段落内容
❸ 将段落附加到文档正文中
更新 updateItemList 后,使用 Browserify 通过运行 browserify main.js -o bundle.js 重建 bundle.js,并使用 npx http-server ./ 为应用程序提供服务。访问 localhost:8080 时,您应该会在页面底部看到一个段落,指示上次更新是什么。
现在是时候添加一个覆盖此功能的测试了。由于附加到正文的段落没有 id 或 data-testid,因此您必须添加这些属性之一或找到另一种查找此元素的方法。
在这种情况下,向段落添加标识符属性似乎是个坏主意。为了确保这些标识符是唯一的,你必须让 domController 有状态,这样它每次都能生成一个新的 ID。通过这样做,您将添加大量代码只是为了使此功能可测试。除了添加更多需要更多维护的代码之外,您还会将实现与测试紧密耦合。
为了避免这种开销,不是通过唯一标识符查找段落,而是通过要断言的特征查找段落:它们的内容。
向 domController.test.js 添加一个新测试,该测试可查找页面中的所有段落并按其内容过滤它们。
警告您现在有多个测试在同一个文档上运行,因此您必须在每个测试之间重置其内容。不要忘记将 document.body.innerHTML 的赋值封装在 beforeEach 钩子中。
const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
// ...
beforeEach(() => {
document.body.innerHTML = initialHtml; ❶
});
describe("updateItemList", () => {
// ...
test("adding a paragraph indicating what was the update", () => {
const inventory = { cheesecake: 5, "apple pie": 2 };
updateItemList(inventory); ❷
const paragraphs = Array.from(document.querySelector("p")); ❸
const updateParagraphs = paragraphs.filter(p => { ❹
return p.includes("The inventory has been updated");
});
expect(updateParagraphs).toHaveLength(1); ❺
expect(updateParagraphs[0].innerHTML).toBe( ❻
`The inventory has been updated - ${JSON.stringify(inventory)}`
);
});
});
❶ 在每次测试之前,您将通过将 index.html 的内容重新分配给文档主体来将文档主体重置为其初始状态。
❷ 练习updateItemList函数
❸ 查找页面中的所有段落
❹ 按文本过滤页面的所有段落,以找到包含所需文本的段落
❺ 检查是否只有一个段落带有预期的文本
❻ 检查段落的全部内容
通过内容查找元素比依赖 DOM 的结构或唯一 id 更好。尽管所有这些技术都是有效的并且适用于不同的场景,但通过其内容查找元素是避免应用程序和测试之间耦合的最佳方法。或者,您可以通过其他属性来查找元素,这些属性不仅可以唯一标识它,而且还构成元素应该是什么的组成部分。例如,您可以通过其角色属性查找元素,从而在您的选择器中构建可访问性检查。
在测试您的前端应用程序时,请记住不仅要断言您的功能是否有效,还要断言您的页面是否显示正确的元素和正确的内容。为此,请在 DOM 中查找元素,并确保编写断言来验证它们。在编写这些断言时,请注意如何找到这些元素。尝试始终坚持元素应该是什么的组成部分的特征。通过对这些特性进行断言,您将使您的测试更加健壮,并且在您重构应用程序但一切仍在运行时不会产生额外的维护开销。
6.2.1 更容易查找元素
如果路易斯每次想烤蛋糕都要花一个小时才能找到合适的平底锅,他早就放弃烘焙了。为了防止每次添加新功能时都放弃编写有价值的测试,最好让查找元素像 Louis 找到他的平底锅一样轻松。
到目前为止,我们一直在使用原生 API 来查找元素。有时,这会变得非常麻烦。
例如,如果您通过 test-id 查找元素,则必须重写许多类似的选择器。在之前的测试中,要通过文本查找段落,我们不仅必须使用选择器,还必须编写大量代码来过滤页面的 p 元素。例如,如果您试图通过值或标签查找输入,则可能会发生类似的棘手情况。
为了更直接地查找元素,您可以使用像 dom-testing-library 这样的库,该库附带的函数可以让您轻松查找 DOM 节点。
现在您了解了如何对 DOM 进行断言,您将通过运行 npm install --save-dev @testing-library/dom 来安装 dom-testing-library 作为开发依赖项并重构您的测试,以便它们使用该库的查询。
从检查页面项目列表的测试开始。在该测试中,您将使用 dom-testing-library 导出的 getByText 函数。使用 getByText,您无需使用每个项目的 innerHTML 创建一个数组并检查该数组是否包含您想要的文本。相反,您将告诉 getByText 在列表中查找所需的文本片段。 getByText 函数将要搜索的 HTMLElement 和要查找的文本作为参数。
因为如果 getByText 没有找到一个元素,它会返回一个错误的结果,你可以使用 toBeTruthy 来断言它确实找到了一个匹配的节点。现在,toBeTruthy 就足够了,但在下一小节中,您将学习如何编写更精确的断言。
const { getByText } = require("@testing-library/dom");
// ...
describe("updateItemList", () => {
// ...
test("updates the DOM with the inventory items", () => {
const inventory = {
cheesecake: 5,
"apple pie": 2,
"carrot cake": 6
};
updateItemList(inventory);
const itemList = document.getElementById("item-list");
expect(itemList.childNodes).toHaveLength(3);
expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeTruthy(); ❶
expect(getByText(itemList, "apple pie - Quantity: 2")).toBeTruthy(); ❶
expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeTruthy(); ❶
});
// ...
});
❶ 在这些断言中,您使用 getByText 更容易地找到所需的元素。
现在,您不必编写通过文本查找元素的逻辑,而是将该任务委托给 dom-testing-library。
为了使您的选择更加彻底,您还可以向 getByText 传递第三个参数,告诉它只考虑属于 li 元素的节点。 尝试传递 { selector: "li" } 作为 getByText 的第三个参数,你会看到测试仍然通过。
继续对 domController.test.js 中的其他测试执行相同操作。 这一次,您可以使用 dom-testing-library 导出的 screen 命名空间中的 getByText 方法,而不是传递 getByText 应该在其中搜索的元素。 与直接导出的 getByText 不同,screen.getByText 默认在全局文档中查找项目。
const { screen, getByText } = require("@testing-library/dom");
// ...
describe("updateItemList", () => {
// ...
test("adding a paragraph indicating what was the update", () => {
const inventory = { cheesecake: 5, "apple pie": 2 };
updateItemList(inventory);
expect(
screen.getByText( ❶
`The inventory has been updated - ${JSON.stringify(inventory)}`
)
).toBeTruthy();
});
});
❶ 不使用 getByText,而是使用 screen.getByText 来搜索全局文档中的元素,从而避免必须事先找到 itemList。
dom-testing-library 包还包括许多其他有用的查询,例如 getByAltText、getByRole 和 getByLabelText。作为练习,尝试向页面添加新元素,例如输入字段中的图像,并使用这些查询在您将编写的测试中找到它们。
注意您可以在 https://testing-library.com/docs/dom-testing-library/api-queries 上找到有关 dom-testing-library 查询的完整文档。
您的选择器,就像您的断言一样,应该基于构成元素应该是什么的组成部分的内容。例如,一个 id 是任意的,因此,通过它们的 id 查找元素会将您的测试与您的标记紧密耦合。不是通过任意属性查找元素,您应该通过对用户重要的元素来查找元素,比如他们的文本或他们的角色。通过使用健壮且易于编写的选择器,您的测试将可以更快地编写,并且对不影响应用程序是否正常工作的更改更具弹性。
6.2.2 编写更好的断言
在上一节中,您使用 toBeTruthy 断言 dom-testing-library 能够找到您想要的元素。 尽管它对于这些例子来说已经足够好了,但是像 toBeTruthy 这样的断言太松散了,会使测试更难理解。
就像我们在第 3 章中使用 jest-extended 库用新的匹配器扩展 Jest 一样,我们可以使用 jest-dom 用专门用于测试 DOM 的新匹配器来扩展它。 这些匹配器可以帮助您减少需要在测试中编写的代码量并使它们更具可读性。
要使用 jest-dom,首先,通过运行 npm install --save-dev @testing-library/jest-dom 将其安装为开发依赖项。 安装后,将 jest.config.js 文件添加到您的应用程序目录,并配置 Jest 以运行名为 setupJestDom.js 的安装文件。
module.exports = {
setupFilesAfterEnv: ['<rootDir>/setupJestDom.js'],
};
在 setupJestDom.js 中,调用 expect.extend 并将其传递给 jest-dom 的主要导出。
const jestDom = require("@testing-library/jest-dom");
expect.extend(jestDom);
将 setupJestDom.js 添加到 setupFilesAfterEnv 配置将导致它在 Jest 初始化后运行,并将 jest-dom 中的匹配器添加到 expect 中。
更新 Jest 配置后,您可以使用 jest-dom 中的 toBeInTheDocument 断言替换 toBeTruthy。 此更改将使您的测试更具可读性和精确性。 如果 dom-testing-library 找到的元素不再附加到文档,例如, toBeInTheDocument 将失败,而 toBeTruthy 将通过。
// ...
describe("updateItemList", () => {
// ...
test("updates the DOM with the inventory items", () => {
// ...
expect(getByText(itemList, "cheesecake - Quantity: 5")).toBeInTheDocument();
expect(getByText(itemList, "apple pie - Quantity: 2")).toBeInTheDocument();
expect(getByText(itemList, "carrot cake - Quantity: 6")).toBeInTheDocument();
});
// ...
});
要尝试不同的断言,请更新您的应用程序,使其以红色突出显示数量小于 5 的项目的名称。
const updateItemList = inventory => {
// ...
Object.entries(inventory).forEach(([itemName, quantity]) => { ❶
const listItem = window.document.createElement("li");
listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
if (quantity < 5) { ❷
listItem.style.color = "red";
}
inventoryList.appendChild(listItem);
});
// ...
};
// ...
❶ 遍历清单中的每个条目
❷ 如果物品的数量少于五个,则将其颜色设置为红色
要断言元素的样式,您可以使用 toHaveStyle,而不是手动访问其样式属性并检查颜色的值。
继续添加一个新测试,以检查您的应用程序是否以红色突出显示数量小于 5 的元素,如下所示。
describe("updateItemList", () => {
// ...
test("highlighting in red elements whose quantity is below five", () => {
const inventory = { cheesecake: 5, "apple pie": 2, "carrot cake": 6 };
updateItemList(inventory);
expect(screen.getByText("apple pie - Quantity: 2")).toHaveStyle({
color: "red"
});
});
});
使用 toHaveStyle,您还可以对通过样式表应用的样式进行断言。 例如,尝试在 index.html 中添加一个样式标签,其中包含一个几乎售罄的类,该类将元素的颜色设置为红色。
<!DOCTYPE html>
<html lang="en">
<head>
< !-- ... -->
<style>
.almost-soldout {
color: red;
}
</style>
</head>
< !-- ... -->
</html>
然后,当商品数量少于 5 时,不要手动设置商品的 style.color 属性,而是将其 className 属性设置为almost-soldout。
const updateItemList = inventory => {
// ...
Object.entries(inventory).forEach(([itemName, quantity]) => {
const listItem = window.document.createElement("li");
listItem.innerHTML = `${itemName} - Quantity: ${quantity}`;
if (quantity < 5) {
listItem.className = "almost-soldout"; ❶
}
inventoryList.appendChild(listItem);
});
// ...
};
// ...
❶ 不是直接设置元素的颜色,而是将其类设置为几乎售罄,这会导致元素的颜色变为红色
即使您的脚本没有应用这些样式,您的测试仍然应该通过。为了在不使用 jest-dom 的情况下实现相同的目标,您需要在测试中编写更多代码。
作为练习,尝试向应用程序添加新功能,例如将售罄商品的可见性设置为隐藏或添加一个按钮,用于清空库存并在库存已经为空时保持禁用状态。然后,使用像 toBeVisible、toBeEnabled 和 toBeDisabled 这样的断言来测试这些新功能。
注意您可以在 https://github.com/testing-library/jest-dom 上找到 jest-dom 的完整文档,包括可用匹配器的完整列表。
在本节中,您应该已经学会了如何在测试中查找 DOM 元素,无论是使用本机浏览器 API 还是使用 dom-testing-library 中的实用程序,这都会使您的测试更具可读性。到现在为止,您还应该了解应该使用哪些技术来避免维护开销。例如,您应该知道,根据层次链查找元素并不是一个好主意,最好通过标签查找元素,以便您可以在选择器中构建验证。此外,您应该能够在 jest-dom 的帮助下为您的测试编写精确且可读的断言。