要做出人們想要的東西,您必須傾聽(tīng)客戶的意見(jiàn)秘血。顧客可能并不總是對(duì)的,但在路易斯的面包店评甜,每位員工都知道他們必須始終傾聽(tīng)顧客的意見(jiàn)——或者至少讓顧客感到被傾聽(tīng)灰粮。
從業(yè)務(wù)角度來(lái)看,客戶的輸入推動(dòng)了產(chǎn)品決策忍坷。例如粘舟,它可以幫助面包店生產(chǎn)更多客戶想要的產(chǎn)品熔脂,減少客戶不需要的產(chǎn)品。從軟件的角度來(lái)看柑肴,用戶輸入會(huì)導(dǎo)致應(yīng)用程序做出反應(yīng)霞揉,改變其狀態(tài)并顯示新結(jié)果。
在瀏覽器中運(yùn)行的應(yīng)用程序不會(huì)直接接收數(shù)字或字符串等輸入晰骑。相反适秩,它們處理事件。當(dāng)用戶單擊硕舆、鍵入和滾動(dòng)時(shí)秽荞,他們會(huì)觸發(fā)事件。這些事件包括有關(guān)用戶交互的詳細(xì)信息岗宣,例如他們提交的表單內(nèi)容或單擊的按鈕蚂会。
在本節(jié)中,您將學(xué)習(xí)如何處理測(cè)試中的事件并準(zhǔn)確模擬用戶與應(yīng)用程序交互的方式耗式。通過(guò)精確表示用戶的輸入胁住,您將獲得更可靠的測(cè)試,因?yàn)樗鼈儗⒏愃朴谶\(yùn)行時(shí)發(fā)生的情況刊咳。
要查看事件的工作原理并了解如何測(cè)試它們彪见,您將向應(yīng)用程序添加一個(gè)新表單,該表單允許用戶將項(xiàng)目添加到庫(kù)存中娱挨。然后余指,您將使您的應(yīng)用程序在用戶與其交互時(shí)驗(yàn)證表單,并為這些交互編寫(xiě)更多測(cè)試跷坝。
首先酵镜,向 index.html 添加一個(gè)包含兩個(gè)字段的表單:一個(gè)用于項(xiàng)目名稱,另一個(gè)用于其數(shù)量柴钻。
<!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>
? 導(dǎo)致表單被提交淮韭,觸發(fā)提交事件
在 domController.js 文件中,創(chuàng)建一個(gè)名為 handleAddItem 的函數(shù)贴届。 這個(gè)函數(shù)將接收一個(gè)事件作為它的第一個(gè)參數(shù)靠粪,檢索提交的值,調(diào)用 addItem 來(lái)更新庫(kù)存毫蚓,然后調(diào)用 updateItemList 來(lái)更新 DOM占键。
// ...
const handleAddItem = event => {
event.preventDefault(); ?
const { name, quantity } = event.target.elements;
addItem(name.value, parseInt(quantity.value, 10)); ?
updateItemList(data.inventory);
};
? 阻止頁(yè)面重新加載,因?yàn)樗J(rèn)情況下
? 因?yàn)閝uantity字段值是一個(gè)字符串元潘,所以我們需要使用parseInt將其轉(zhuǎn)換為數(shù)字畔乙。
注意 默認(rèn)情況下,當(dāng)用戶提交表單時(shí)翩概,瀏覽器將重新加載頁(yè)面啸澡。 調(diào)用事件的 preventDefault 方法將取消默認(rèn)行為袖订,導(dǎo)致瀏覽器無(wú)法重新加載頁(yè)面。
最后嗅虏,為了在用戶提交新項(xiàng)目時(shí)調(diào)用 handleAddItem,您需要將提交事件的事件偵聽(tīng)器附加到表單上沐。
現(xiàn)在您有了一個(gè)提交項(xiàng)目的表單皮服,您不再需要在 main.js 文件中手動(dòng)調(diào)用 addItem 和 updateItemList。 相反参咙,您可以替換此文件的全部?jī)?nèi)容龄广,并使其僅將事件偵聽(tīng)器附加到表單。
const { handleAddItem } = require("./domController");
const form = document.getElementById("add-item-form");
form.addEventListener("submit", handleAddItem); ?
? 每當(dāng)用戶提交表單時(shí)調(diào)用 handleAddItem
在這些更改之后蕴侧,您應(yīng)該有一個(gè)能夠動(dòng)態(tài)地將項(xiàng)目添加到庫(kù)存中的應(yīng)用程序择同。 要查看它的運(yùn)行情況,請(qǐng)執(zhí)行 npm run build 以重新生成 bundle.js净宵、 npx http-server ./ 以提供 index.html 并訪問(wèn) localhost:8080敲才,就像您之前所做的那樣。
現(xiàn)在择葡,考慮一下您將如何測(cè)試剛剛添加的代碼紧武。
一種可能性是為 handleAddItem 函數(shù)本身添加一個(gè)測(cè)試。 該測(cè)試將創(chuàng)建一個(gè)類似事件的對(duì)象并將其作為參數(shù)傳遞給 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();
});
});
? 創(chuàng)建一個(gè)復(fù)制事件接口的對(duì)象
? 練習(xí)handleAddItem函數(shù)
? 檢查表單的默認(rèn)重新加載是否已被阻止
? 檢查 itemList 是否包含具有預(yù)期文本的節(jié)點(diǎn)
為了通過(guò)之前的測(cè)試,您必須對(duì)事件的屬性進(jìn)行逆向工程已添,從頭開(kāi)始構(gòu)建它妥箕。
這種技術(shù)的問(wèn)題之一是它沒(méi)有考慮頁(yè)面中的任何實(shí)際輸入元素。因?yàn)槟约簶?gòu)建了事件更舞,所以您可以為名稱和數(shù)量包含任意值畦幢。例如,如果您嘗試從 index.html 中刪除輸入元素疏哗,即使您的應(yīng)用程序可能無(wú)法運(yùn)行呛讲,該測(cè)試仍會(huì)通過(guò)。
因?yàn)檫@個(gè)測(cè)試是直接調(diào)用handleAddItem的返奉,如圖6.4所示贝搁,所以它并不關(guān)心它是否作為submit事件的監(jiān)聽(tīng)器附加到表單上。例如芽偏,如果您嘗試從 main.js 中刪除對(duì) addEventListener 的調(diào)用雷逆,則此測(cè)試將繼續(xù)通過(guò)。同樣污尉,您發(fā)現(xiàn)了另一種情況膀哲,在這種情況下往产,您的應(yīng)用程序無(wú)法運(yùn)行但您的測(cè)試會(huì)通過(guò)。
正如您剛剛所做的那樣某宪,手動(dòng)構(gòu)建事件有助于快速迭代并在構(gòu)建偵聽(tīng)器時(shí)單獨(dú)測(cè)試它們仿村。但是,當(dāng)談到創(chuàng)建可靠的保證時(shí)兴喂,這種技術(shù)是不夠的蔼囊。此單元測(cè)試僅涵蓋 handleAddItem 函數(shù)本身,因此無(wú)法保證當(dāng)用戶觸發(fā)真實(shí)事件時(shí)應(yīng)用程序會(huì)正常工作衣迷。
為了創(chuàng)建更可靠的保證畏鼓,最好創(chuàng)建一個(gè)真實(shí)的事件實(shí)例,并使用節(jié)點(diǎn)的 dispatchEvent 方法通過(guò) DOM 節(jié)點(diǎn)調(diào)度它壶谒。
準(zhǔn)確再現(xiàn)運(yùn)行時(shí)發(fā)生的事情的第一步是更新文檔的正文云矫,使其包含 index.html 中的標(biāo)記,正如我們之前所做的那樣汗菜。然后让禀,最好使用 require("./main") 執(zhí)行 main.js,以便它可以將 eventListener 附加到表單呵俏。如果您在再次使用 initialHTML 更新文檔正文后不運(yùn)行 main.js堆缘,其表單將不會(huì)附加事件偵聽(tīng)器。
此外普碎,您必須在需要 main.js 之前調(diào)用 jest.resetModules吼肥。否則,Jest 將從其緩存中獲取 ./main.js麻车,以防止它再次被執(zhí)行缀皱。
const fs = require("fs");
const initialHtml = fs.readFileSync("./index.html");
beforeEach(() => {
document.body.innerHTML = initialHtml;
jest.resetModules(); ?
require("./main"); ?
});
? 這里你必須使用 jest.resetModules 因?yàn)椋駝t动猬,Jest 會(huì)緩存 main.js 并且它不會(huì)再次運(yùn)行啤斗。
? 您必須再次執(zhí)行 main.js,以便它可以在每次主體更改時(shí)將事件偵聽(tīng)器附加到表單赁咙。
既然您的文檔具有 index.html 中的內(nèi)容钮莲,并且 main.js 已將偵聽(tīng)器附加到表單,您就可以編寫(xiě)測(cè)試本身了彼水。 這個(gè)測(cè)試將填充頁(yè)面的輸入崔拥,創(chuàng)建一個(gè)類型為 submit 的事件,找到表單凤覆,并調(diào)用它的 dispatchEvent 方法链瓦。 分派事件后,它將檢查列表是否包含它剛剛添加的項(xiàng)目的條目。
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();
});
? 創(chuàng)建一個(gè)“本地”事件實(shí)例慈俯,類型為 submit
? 通過(guò)頁(yè)面的表單調(diào)度事件
? 檢查分派的事件是否導(dǎo)致頁(yè)面包含具有預(yù)期文本的元素
這個(gè)測(cè)試(也顯示在圖 6.5 中)更準(zhǔn)確地代表了運(yùn)行時(shí)發(fā)生的情況渤刃。 因?yàn)樗姆秶戎暗臏y(cè)試更廣泛,所以這個(gè)測(cè)試在測(cè)試金字塔中更高贴膘,因此它的保證更可靠卖子。 例如,如果您嘗試從 index.html 中刪除輸入元素或從 main.js 中調(diào)用 addEventListener刑峡,則此測(cè)試將失敗揪胃,與前一個(gè)不同。
// ...
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 };
現(xiàn)在氛琢,為了使 handleItemName 能夠顯示其消息,向 index.html 添加一個(gè)新的 p 標(biāo)簽随闪,其 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>
? 將根據(jù)項(xiàng)目名稱是否有效向用戶顯示反饋的元素
如果您想單獨(dú)測(cè)試 handleItemName 函數(shù),作為練習(xí)铐伴,您可以嘗試為其編寫(xiě)單元測(cè)試撮奏,就像我們之前為 handleAddItem 函數(shù)所做的那樣。您可以在本書(shū) GitHub 存儲(chǔ)庫(kù)的第 6/3_handling_events/1_handling_raw_events 文件夾中找到如何編寫(xiě)此測(cè)試的完整示例当宴,網(wǎng)址為 https://github.com/lucasfcosta/testing-javascript-applications畜吊。
注意 如前所述,對(duì)這些函數(shù)進(jìn)行單元測(cè)試在您迭代時(shí)會(huì)很有用户矢,但分派實(shí)際事件的測(cè)試要可靠得多玲献。考慮到這兩種測(cè)試高度重疊并且需要相似數(shù)量的代碼梯浪,如果您必須選擇一種捌年,我建議您堅(jiān)持使用使用元素的 dispatchEvent 的測(cè)試。
如果您愿意編寫(xiě)處理程序函數(shù)而不在整個(gè)過(guò)程中單獨(dú)測(cè)試它們挂洛,那么編寫(xiě)僅使用 dispatchEvent 的測(cè)試可能會(huì)更好礼预。
驗(yàn)證工作的最后一步是附加一個(gè)事件偵聽(tīng)器,該偵聽(tīng)器處理在項(xiàng)目名稱的輸入中發(fā)生的輸入事件虏劲。更新您的 main.js托酸,并添加以下代碼。
const { handleAddItem, handleItemName } = require("./domController");
// ...
const itemInput = document.querySelector(`input[name="name"]`);
itemInput.addEventListener("input", handleItemName); ?
? 使用 handleItemName 處理來(lái)自 itemInput 的輸入事件
提示要查看此新功能柒巫,請(qǐng)不要忘記在使用 npx http-server ./ 服務(wù)之前通過(guò)運(yùn)行 npm run build 來(lái)重建 bundle.js励堡。
現(xiàn)在您的驗(yàn)證功能可以正常工作,請(qǐng)為其編寫(xiě)測(cè)試吻育。 此測(cè)試必須設(shè)置輸入的值并通過(guò)輸入節(jié)點(diǎn)分派輸入事件念秧。 派發(fā)事件后,它應(yīng)該檢查文檔是否包含成功消息布疼。
// ...
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();
});
});
? 使用類型輸入創(chuàng)建事件的“本機(jī)”實(shí)例
? 通過(guò)項(xiàng)目名稱的字段調(diào)度事件
? 檢查頁(yè)面是否包含預(yù)期的反饋信息
作為練習(xí)摊趾,嘗試為不愉快的路徑編寫(xiě)一個(gè)測(cè)試币狠。此測(cè)試應(yīng)輸入無(wú)效的項(xiàng)目名稱,通過(guò)項(xiàng)目名稱字段調(diào)度事件砾层,并檢查文檔是否包含錯(cuò)誤消息漩绵。
回到我們的應(yīng)用程序需求——當(dāng)商品名稱無(wú)效時(shí)顯示錯(cuò)誤消息非常好,但是肛炮,如果我們不禁止用戶提交表單止吐,他們?nèi)匀豢梢詫o(wú)效商品添加到庫(kù)存中。我們也沒(méi)有任何驗(yàn)證來(lái)防止用戶在未指定數(shù)量的情況下提交表單侨糟,從而導(dǎo)致顯示 NaN碍扔。
為了防止這些無(wú)效操作的發(fā)生,您需要重構(gòu)處理程序秕重。不是只偵聽(tīng)發(fā)生在項(xiàng)目名稱字段上的輸入事件不同,而是偵聽(tīng)發(fā)生在表單子項(xiàng)上的所有輸入事件。然后溶耘,表單將檢查其子項(xiàng)的值并決定是否應(yīng)禁用提交按鈕二拐。
首先將 handleItemName 重命名為 checkFormValues 并使其驗(yàn)證表單的兩個(gè)字段中的值。
// ...
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 };
? 禁用或啟用表單的提交輸入凳兵,取決于表單字段中的值是否有效
現(xiàn)在更新 main.js百新,而不是將 handleItemName 附加到名稱輸入,而是將新的 checkFormValues 附加到您的表單庐扫。 這個(gè)新的偵聽(tīng)器將響應(yīng)從表單子項(xiàng)冒泡的任何輸入事件饭望。
form.addEventListener("input", checkFormValues); ?
// Run `checkFormValues` once to see if the initial state is valid
checkFormValues();
? checkFormValues 函數(shù)現(xiàn)在將處理表單中觸發(fā)的任何輸入事件,包括將從表單的子級(jí)冒泡的輸入事件聚蝶。
注意要查看應(yīng)用程序的工作情況杰妓,請(qǐng)?jiān)谔峁┓?wù)之前使用 npm run build 重建它,正如我們?cè)诒菊轮卸啻瓮瓿傻哪菢印?/p>
鑒于您已保留用戶輸入無(wú)效項(xiàng)目名稱時(shí)出現(xiàn)的錯(cuò)誤消息碘勉,項(xiàng)目名稱驗(yàn)證的先前測(cè)試應(yīng)繼續(xù)通過(guò)巷挥。但是,如果您嘗試重新運(yùn)行它們验靡,您會(huì)發(fā)現(xiàn)它們失敗了倍宾。
提示要僅運(yùn)行 main.test.js 中的測(cè)試,您可以將 main.test.js 作為第一個(gè)參數(shù)傳遞給 jest 命令胜嗓。
如果您從 node_modules 文件夾運(yùn)行 jest高职,您的命令應(yīng)該類似于 ./node_modules/.bin/jest main.test.js。
如果你添加了一個(gè) NPM 腳本來(lái)運(yùn)行 Jest辞州,例如 test怔锌,你應(yīng)該運(yùn)行 npm run test -- main.test.js。
這些測(cè)試失敗是因?yàn)槟{(diào)度的事件不會(huì)冒泡。例如埃元,當(dāng)通過(guò) item name 字段調(diào)度 input 事件時(shí)涝涤,它不會(huì)觸發(fā)任何附加到其父級(jí)的偵聽(tīng)器,包括附加到表單的偵聽(tīng)器岛杀。因?yàn)楸韱伪O(jiān)聽(tīng)器沒(méi)有被執(zhí)行阔拳,它不會(huì)向頁(yè)面添加任何錯(cuò)誤信息,導(dǎo)致你的測(cè)試失敗类嗤。
要通過(guò)使事件冒泡來(lái)修復(fù)您的測(cè)試糊肠,您必須在實(shí)例化事件時(shí)傳遞一個(gè)額外的參數(shù)。此附加參數(shù)應(yīng)包含名為氣泡的屬性遗锣,其值為 true货裹。使用此選項(xiàng)創(chuàng)建的事件將冒泡并觸發(fā)附加到元素父級(jí)的偵聽(tīng)器。
// ...
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();
});
});
// ...
? 創(chuàng)建一個(gè)帶有類型輸入的 Event 的“原生”實(shí)例精偿,它可以向上冒泡到元素的父元素泪酱,通過(guò)它分派它
? 通過(guò)項(xiàng)目名稱的字段調(diào)度事件。因?yàn)槭录?bubble 屬性設(shè)置為 true还最,所以它會(huì)冒泡到表單,觸發(fā)它的監(jiān)聽(tīng)器毡惜。
為了避免手動(dòng)實(shí)例化和分派事件拓轻,dom-testing-library 包含一個(gè)名為 fireEvent 的實(shí)用程序。
使用 fireEvent经伙,您可以準(zhǔn)確模擬多種不同類型的事件扶叉,包括提交表單、按鍵和更新字段帕膜。由于 fireEvent 處理在特定組件上觸發(fā)事件時(shí)您需要執(zhí)行的所有操作,因此它可以幫助您編寫(xiě)更少的代碼,而不必?fù)?dān)心觸發(fā)事件時(shí)發(fā)生的所有事情巨税。
例如鸵隧,通過(guò)使用 fireEvent 而不是手動(dòng)創(chuàng)建輸入事件,您可以避免必須為項(xiàng)目名稱設(shè)置字段的 value 屬性荒典。 fireEvent 函數(shù)知道輸入事件會(huì)更改通過(guò)其調(diào)度的組件的值酪劫。因此,它將為您處理更改值寺董。
更新表單驗(yàn)證的測(cè)試覆糟,以便它們使用 dom-testing-library 中的 fireEvent 實(shí)用程序。
// ...
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();
});
});
? 不是創(chuàng)建一個(gè)事件然后調(diào)度它遮咖,而是使用 fireEvent.input 在字段上觸發(fā)一個(gè)項(xiàng)目名稱的輸入事件滩字。
提示 如果您需要更準(zhǔn)確地模擬用戶事件,例如用戶以一定的速度打字,您可以使用用戶事件庫(kù)麦箍,該庫(kù)也是由 testing-library 組織制作的漓藕。
例如,當(dāng)您有使用去抖動(dòng)驗(yàn)證的字段時(shí)内列,此庫(kù)特別有用:僅在用戶停止輸入后的特定時(shí)間觸發(fā)的驗(yàn)證撵术。
您可以在 https://github.com/testing-library/user-event 查看@testing-library/user-event 的完整文檔。
作為練習(xí)话瞧,嘗試更新所有其他測(cè)試嫩与,以便它們使用 fireEvent。我還建議與庫(kù)存管理器處理不同類型的交互并對(duì)其進(jìn)行測(cè)試交排。例如划滋,您可以嘗試在用戶雙擊項(xiàng)目列表中的姓名時(shí)刪除項(xiàng)目。
在本節(jié)之后埃篓,您應(yīng)該能夠編寫(xiě)測(cè)試來(lái)驗(yàn)證用戶將與您的頁(yè)面進(jìn)行的交互处坪。盡管手動(dòng)構(gòu)建事件以便在迭代時(shí)獲得快速反饋是可以的,但這不是創(chuàng)建最可靠質(zhì)量保證的那種測(cè)試架专。相反同窘,為了更準(zhǔn)確地模擬用戶的行為——因此,創(chuàng)造更可靠的保證——你可以使用 dispatchEvent 調(diào)度本機(jī)事件或使用第三方庫(kù)來(lái)使這個(gè)過(guò)程更方便部脚。當(dāng)涉及到捕獲錯(cuò)誤時(shí)想邦,這種相似性將使您的測(cè)試更有價(jià)值,并且因?yàn)槟鷽](méi)有嘗試手動(dòng)重現(xiàn)事件的界面委刘,它們將導(dǎo)致更少的維護(hù)開(kāi)銷(xiāo)丧没。