要做出人们想要的东西,您必须倾听客户的意见。顾客可能并不总是对的,但在路易斯的面包店,每位员工都知道他们必须始终倾听顾客的意见——或者至少让顾客感到被倾听。
从业务角度来看,客户的输入推动了产品决策。例如,它可以帮助面包店生产更多客户想要的产品,减少客户不需要的产品。从软件的角度来看,用户输入会导致应用程序做出反应,改变其状态并显示新结果。
在浏览器中运行的应用程序不会直接接收数字或字符串等输入。相反,它们处理事件。当用户单击、键入和滚动时,他们会触发事件。这些事件包括有关用户交互的详细信息,例如他们提交的表单内容或单击的按钮。
在本节中,您将学习如何处理测试中的事件并准确模拟用户与应用程序交互的方式。通过精确表示用户的输入,您将获得更可靠的测试,因为它们将更类似于运行时发生的情况。
要查看事件的工作原理并了解如何测试它们,您将向应用程序添加一个新表单,该表单允许用户将项目添加到库存中。然后,您将使您的应用程序在用户与其交互时验证表单,并为这些交互编写更多测试。
首先,向 index.html 添加一个包含两个字段的表单:一个用于项目名称,另一个用于其数量。
<!DOCTYPE html>
<html lang="en">
< !-- ... -->
<body>
< !-- ... -->
<form id="add-item-form">
<input
type="text"
name="name"
placeholder="Item name"
>
<input
type="number"
name="quantity"
placeholder="Quantity"
>
<button type="submit">Add to inventory</button> ❶
</form>
<script src="bundle.js"></script>
</body>
</html>
❶ 导致表单被提交,触发提交事件
在 domController.js 文件中,创建一个名为 handleAddItem 的函数。 这个函数将接收一个事件作为它的第一个参数,检索提交的值,调用 addItem 来更新库存,然后调用 updateItemList 来更新 DOM。
// ...
const handleAddItem = event => {
event.preventDefault(); ❶
const { name, quantity } = event.target.elements;
addItem(name.value, parseInt(quantity.value, 10)); ❷
updateItemList(data.inventory);
};
❶ 阻止页面重新加载,因为它默认情况下
❷ 因为quantity字段值是一个字符串,所以我们需要使用parseInt将其转换为数字。
注意 默认情况下,当用户提交表单时,浏览器将重新加载页面。 调用事件的 preventDefault 方法将取消默认行为,导致浏览器无法重新加载页面。
最后,为了在用户提交新项目时调用 handleAddItem,您需要将提交事件的事件侦听器附加到表单。
现在您有了一个提交项目的表单,您不再需要在 main.js 文件中手动调用 addItem 和 updateItemList。 相反,您可以替换此文件的全部内容,并使其仅将事件侦听器附加到表单。
const { handleAddItem } = require("./domController");
const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem); ❶
❶ 每当用户提交表单时调用 handleAddItem
在这些更改之后,您应该有一个能够动态地将项目添加到库存中的应用程序。 要查看它的运行情况,请执行 npm run build 以重新生成 bundle.js、 npx http-server ./ 以提供 index.html 并访问 localhost:8080,就像您之前所做的那样。
现在,考虑一下您将如何测试刚刚添加的代码。
一种可能性是为 handleAddItem 函数本身添加一个测试。 该测试将创建一个类似事件的对象并将其作为参数传递给 handleAddItem,如下所示。
const { updateItemList, handleAddItem } = require("./domController");
// ...
describe("handleAddItem", () => {
test("adding items to the page", () => {
const event = { ❶
preventDefault: jest.fn(),
target: {
elements: {
name: { value: "cheesecake" },
quantity: { value: "6" }
}
}
};
handleAddItem(event); ❷
expect(event.preventDefault.mock.calls).toHaveLength(1); ❸
const itemList = document.getElementById("item-list");
expect(getByText(itemList, "cheesecake - Quantity: 6")) ❹
.toBeInTheDocument();
});
});
❶ 创建一个复制事件接口的对象
❷ 练习handleAddItem函数
❸ 检查表单的默认重新加载是否已被阻止
❹ 检查 itemList 是否包含具有预期文本的节点
为了通过之前的测试,您必须对事件的属性进行逆向工程,从头开始构建它。
这种技术的问题之一是它没有考虑页面中的任何实际输入元素。因为您自己构建了事件,所以您可以为名称和数量包含任意值。例如,如果您尝试从 index.html 中删除输入元素,即使您的应用程序可能无法运行,该测试仍会通过。
因为这个测试是直接调用handleAddItem的,如图6.4所示,所以它并不关心它是否作为submit事件的监听器附加到表单上。例如,如果您尝试从 main.js 中删除对 addEventListener 的调用,则此测试将继续通过。同样,您发现了另一种情况,在这种情况下,您的应用程序无法运行但您的测试会通过。
正如您刚刚所做的那样,手动构建事件有助于快速迭代并在构建侦听器时单独测试它们。但是,当谈到创建可靠的保证时,这种技术是不够的。此单元测试仅涵盖 handleAddItem 函数本身,因此无法保证当用户触发真实事件时应用程序会正常工作。
为了创建更可靠的保证,最好创建一个真实的事件实例,并使用节点的 dispatchEvent 方法通过 DOM 节点调度它。
准确再现运行时发生的事情的第一步是更新文档的正文,使其包含 index.html 中的标记,正如我们之前所做的那样。然后,最好使用 require("./main") 执行 main.js,以便它可以将 eventListener 附加到表单。如果您在再次使用 initialHTML 更新文档正文后不运行 main.js,其表单将不会附加事件侦听器。
此外,您必须在需要 main.js 之前调用 jest.resetModules。否则,Jest 将从其缓存中获取 ./main.js,以防止它再次被执行。
const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
beforeEach(() => {
document.body.innerHTML = initialHtml;
jest.resetModules(); ❶
require("./main"); ❷
});
❶ 这里你必须使用 jest.resetModules 因为,否则,Jest 会缓存 main.js 并且它不会再次运行。
❷ 您必须再次执行 main.js,以便它可以在每次主体更改时将事件侦听器附加到表单。
既然您的文档具有 index.html 中的内容,并且 main.js 已将侦听器附加到表单,您就可以编写测试本身了。 这个测试将填充页面的输入,创建一个类型为 submit 的事件,找到表单,并调用它的 dispatchEvent 方法。 分派事件后,它将检查列表是否包含它刚刚添加的项目的条目。
const { screen, getByText } = require("@testing-library/dom");
// ...
test("adding items through the form", () => {
screen.getByPlaceholderText("Item name").value = "cheesecake";
screen.getByPlaceholderText("Quantity").value = "6";
const event = new Event("submit"); ❶
const form = document.getElementById("add-item-form"); ❷
form.dispatchEvent(event);
const itemList = document.getElementById("item-list");
expect(getByText(itemList, "cheesecake - Quantity: 6")) ❸
.toBeInTheDocument();
});
❶ 创建一个“本地”事件实例,类型为 submit
❷ 通过页面的表单调度事件
❸ 检查分派的事件是否导致页面包含具有预期文本的元素
这个测试(也显示在图 6.5 中)更准确地代表了运行时发生的情况。 因为它的范围比之前的测试更广泛,所以这个测试在测试金字塔中更高,因此它的保证更可靠。 例如,如果您尝试从 index.html 中删除输入元素或从 main.js 中调用 addEventListener,则此测试将失败,与前一个不同。
// ...
const validItems = ["cheesecake", "apple pie", "carrot cake"];
const handleItemName = event => {
const itemName = event.target.value;
const errorMsg = window.document.getElementById("error-msg");
if (itemName === "") {
errorMsg.innerHTML = "";
} else if (!validItems.includes(itemName)) {
errorMsg.innerHTML = `${itemName} is not a valid item.`;
} else {
errorMsg.innerHTML = `${itemName} is valid!`;
}
};
// Don't forget to export `handleItemName`
module.exports = { updateItemList, handleAddItem, handleItemName };
现在,为了使 handleItemName 能够显示其消息,向 index.html 添加一个新的 p 标签,其 id 为 error-msg。
<!DOCTYPE html>
<html lang="en">
< !-- ... -->
<body>
< !-- ... -->
<p id="error-msg"></p> ❶
<form id="add-item-form">
< !-- ... -->
</form>
<script src="bundle.js"></script>
</body>
</html>
❶ 将根据项目名称是否有效向用户显示反馈的元素
如果您想单独测试 handleItemName 函数,作为练习,您可以尝试为其编写单元测试,就像我们之前为 handleAddItem 函数所做的那样。您可以在本书 GitHub 存储库的第 6/3_handling_events/1_handling_raw_events 文件夹中找到如何编写此测试的完整示例,网址为 https://github.com/lucasfcosta/testing-javascript-applications。
注意 如前所述,对这些函数进行单元测试在您迭代时会很有用,但分派实际事件的测试要可靠得多。考虑到这两种测试高度重叠并且需要相似数量的代码,如果您必须选择一种,我建议您坚持使用使用元素的 dispatchEvent 的测试。
如果您愿意编写处理程序函数而不在整个过程中单独测试它们,那么编写仅使用 dispatchEvent 的测试可能会更好。
验证工作的最后一步是附加一个事件侦听器,该侦听器处理在项目名称的输入中发生的输入事件。更新您的 main.js,并添加以下代码。
const { handleAddItem, handleItemName } = require("./domController");
// ...
const itemInput = document.querySelector(`input[name="name"]`);
itemInput.addEventListener("input", handleItemName); ❶
❶ 使用 handleItemName 处理来自 itemInput 的输入事件
提示要查看此新功能,请不要忘记在使用 npx http-server ./ 服务之前通过运行 npm run build 来重建 bundle.js。
现在您的验证功能可以正常工作,请为其编写测试。 此测试必须设置输入的值并通过输入节点分派输入事件。 派发事件后,它应该检查文档是否包含成功消息。
// ...
describe("item name validation", () => {
test("entering valid item names ", () => {
const itemField = screen.getByPlaceholderText("Item name");
itemField.value = "cheesecake";
const inputEvent = new Event("input"); ❶
itemField.dispatchEvent(inputEvent); ❷
expect(screen.getByText("cheesecake is valid!")) ❸
.toBeInTheDocument();
});
});
❶ 使用类型输入创建事件的“本机”实例
❷ 通过项目名称的字段调度事件
❸ 检查页面是否包含预期的反馈信息
作为练习,尝试为不愉快的路径编写一个测试。此测试应输入无效的项目名称,通过项目名称字段调度事件,并检查文档是否包含错误消息。
回到我们的应用程序需求——当商品名称无效时显示错误消息非常好,但是,如果我们不禁止用户提交表单,他们仍然可以将无效商品添加到库存中。我们也没有任何验证来防止用户在未指定数量的情况下提交表单,从而导致显示 NaN。
为了防止这些无效操作的发生,您需要重构处理程序。不是只侦听发生在项目名称字段上的输入事件,而是侦听发生在表单子项上的所有输入事件。然后,表单将检查其子项的值并决定是否应禁用提交按钮。
首先将 handleItemName 重命名为 checkFormValues 并使其验证表单的两个字段中的值。
// ...
const validItems = ["cheesecake", "apple pie", "carrot cake"];
const checkFormValues = () => {
const itemName = document.querySelector(`input[name="name"]`).value;
const quantity = document.querySelector(`input[name="quantity"]`).value;
const itemNameIsEmpty = itemName === "";
const itemNameIsInvalid = !validItems.includes(itemName);
const quantityIsEmpty = quantity === "";
const errorMsg = window.document.getElementById("error-msg");
if (itemNameIsEmpty) {
errorMsg.innerHTML = "";
} else if (itemNameIsInvalid) {
errorMsg.innerHTML = `${itemName} is not a valid item.`;
} else {
errorMsg.innerHTML = `${itemName} is valid!`;
}
const submitButton = document.querySelector(`button[type="submit"]`);
if (itemNameIsEmpty || itemNameIsInvalid || quantityIsEmpty) { ❶
submitButton.disabled = true;
} else {
submitButton.disabled = false;
}
};
// Don't forget to update your exports!
module.exports = { updateItemList, handleAddItem, checkFormValues };
❶ 禁用或启用表单的提交输入,取决于表单字段中的值是否有效
现在更新 main.js,而不是将 handleItemName 附加到名称输入,而是将新的 checkFormValues 附加到您的表单。 这个新的侦听器将响应从表单子项冒泡的任何输入事件。
form.addEventListener("input", checkFormValues); ❶
// Run `checkFormValues` once to see if the initial state is valid
checkFormValues();
❶ checkFormValues 函数现在将处理表单中触发的任何输入事件,包括将从表单的子级冒泡的输入事件。
注意要查看应用程序的工作情况,请在提供服务之前使用 npm run build 重建它,正如我们在本章中多次完成的那样。
鉴于您已保留用户输入无效项目名称时出现的错误消息,项目名称验证的先前测试应继续通过。但是,如果您尝试重新运行它们,您会发现它们失败了。
提示要仅运行 main.test.js 中的测试,您可以将 main.test.js 作为第一个参数传递给 jest 命令。
如果您从 node_modules 文件夹运行 jest,您的命令应该类似于 ./node_modules/.bin/jest main.test.js。
如果你添加了一个 NPM 脚本来运行 Jest,例如 test,你应该运行 npm run test -- main.test.js。
这些测试失败是因为您调度的事件不会冒泡。例如,当通过 item name 字段调度 input 事件时,它不会触发任何附加到其父级的侦听器,包括附加到表单的侦听器。因为表单监听器没有被执行,它不会向页面添加任何错误信息,导致你的测试失败。
要通过使事件冒泡来修复您的测试,您必须在实例化事件时传递一个额外的参数。此附加参数应包含名为气泡的属性,其值为 true。使用此选项创建的事件将冒泡并触发附加到元素父级的侦听器。
// ...
describe("item name validation", () => {
test("entering valid item names ", () => {
const itemField = screen.getByPlaceholderText("Item name");
itemField.value = "cheesecake";
const inputEvent = new Event("input", { bubbles: true }); ❶
itemField.dispatchEvent(inputEvent); ❷
expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
});
});
// ...
❶ 创建一个带有类型输入的 Event 的“原生”实例,它可以向上冒泡到元素的父元素,通过它分派它
❷ 通过项目名称的字段调度事件。因为事件的 bubble 属性设置为 true,所以它会冒泡到表单,触发它的监听器。
为了避免手动实例化和分派事件,dom-testing-library 包含一个名为 fireEvent 的实用程序。
使用 fireEvent,您可以准确模拟多种不同类型的事件,包括提交表单、按键和更新字段。由于 fireEvent 处理在特定组件上触发事件时您需要执行的所有操作,因此它可以帮助您编写更少的代码,而不必担心触发事件时发生的所有事情。
例如,通过使用 fireEvent 而不是手动创建输入事件,您可以避免必须为项目名称设置字段的 value 属性。 fireEvent 函数知道输入事件会更改通过其调度的组件的值。因此,它将为您处理更改值。
更新表单验证的测试,以便它们使用 dom-testing-library 中的 fireEvent 实用程序。
// ...
const { screen, getByText, fireEvent } = require("@testing-library/dom");
// ...
describe("item name validation", () => {
test("entering valid item names ", () => {
const itemField = screen.getByPlaceholderText("Item name");
fireEvent.input(itemField, { ❶
target: { value: "cheesecake" },
bubbles: true
});
expect(screen.getByText("cheesecake is valid!")).toBeInTheDocument();
});
});
❶ 不是创建一个事件然后调度它,而是使用 fireEvent.input 在字段上触发一个项目名称的输入事件。
提示 如果您需要更准确地模拟用户事件,例如用户以一定的速度打字,您可以使用用户事件库,该库也是由 testing-library 组织制作的。
例如,当您有使用去抖动验证的字段时,此库特别有用:仅在用户停止输入后的特定时间触发的验证。
您可以在 https://github.com/testing-library/user-event 查看@testing-library/user-event 的完整文档。
作为练习,尝试更新所有其他测试,以便它们使用 fireEvent。我还建议与库存管理器处理不同类型的交互并对其进行测试。例如,您可以尝试在用户双击项目列表中的姓名时删除项目。
在本节之后,您应该能够编写测试来验证用户将与您的页面进行的交互。尽管手动构建事件以便在迭代时获得快速反馈是可以的,但这不是创建最可靠质量保证的那种测试。相反,为了更准确地模拟用户的行为——因此,创造更可靠的保证——你可以使用 dispatchEvent 调度本机事件或使用第三方库来使这个过程更方便。当涉及到捕获错误时,这种相似性将使您的测试更有价值,并且因为您没有尝试手动重现事件的界面,它们将导致更少的维护开销。