設(shè)備齊全的廚房并不一定意味著烤好的甜點(diǎn)。當(dāng)談到它在烘焙令人驚嘆的蛋糕方面的作用時(shí),廚房只與其中的糕點(diǎn)廚師一樣好。同樣,在 Web 開(kāi)發(fā)這個(gè)不那么美味但同樣有趣的世界中,只有當(dāng)您的應(yīng)用程序與它們正確交互時(shí),瀏覽器為您提供的奇妙 API 才有用。
正如我在本章前面提到的,由于瀏覽器為您的代碼提供了方法,您可以構(gòu)建功能豐富的應(yīng)用程序。例如,您可以獲取用戶的位置、發(fā)送通知、瀏覽應(yīng)用程序的歷史記錄或?qū)?shù)據(jù)存儲(chǔ)在瀏覽器中,這些數(shù)據(jù)將在各部分之間保持不變。現(xiàn)代瀏覽器甚至允許您與藍(lán)牙設(shè)備交互并進(jìn)行語(yǔ)音識(shí)別。
在本章中,您將學(xué)習(xí)如何測(cè)試涉及這些 API 的功能。您將了解它們來(lái)自何處、如何檢查它們以及如何編寫(xiě)足夠的測(cè)試替身來(lái)幫助您處理事件處理程序而不干擾您的應(yīng)用程序代碼。
您將學(xué)習(xí)如何通過(guò)將其中兩個(gè) API 與前端應(yīng)用程序集成來(lái)測(cè)試這些 DOM API:localStorage 和 history。通過(guò)使用 localStorage,您將使您的應(yīng)用程序?qū)⑵鋽?shù)據(jù)保存在瀏覽器中,并在頁(yè)面加載時(shí)將其恢復(fù)。然后,使用 History API,您將允許用戶撤消向庫(kù)存添加項(xiàng)目的操作。
6.4.1 測(cè)試 localStorage 集成
localStorage 是一種機(jī)制,它是 Web Storage API 的一部分。它使應(yīng)用程序能夠在瀏覽器中存儲(chǔ)鍵值對(duì)并在以后檢索它們。您可以在 https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Local_storage 找到有關(guān) localStorage 的文檔。
通過(guò)學(xué)習(xí)如何測(cè)試諸如 localStorage 之類的 API,您將了解它們?cè)跍y(cè)試環(huán)境中的工作方式以及如何驗(yàn)證您的應(yīng)用程序與它們的集成。
在這些示例中,您將用于更新頁(yè)面的清單持久保存到 localStorage。然后,當(dāng)頁(yè)面加載時(shí),您將從 localStorage 檢索庫(kù)存并使用它再次填充列表。此功能將使您的應(yīng)用程序不會(huì)在會(huì)話之間丟失數(shù)據(jù)。
首先更新 updateItemList,以便它將傳遞給它的對(duì)象存儲(chǔ)在 localStorage 中的清單鍵下。因?yàn)?localStorage 不能存儲(chǔ)對(duì)象,所以你需要在持久化數(shù)據(jù)之前使用 JSON.stringify 序列化清單。
// ...
const updateItemList = inventory => {
if (!inventory === null) return;
localStorage.setItem("inventory", JSON.stringify(inventory)); ?
// ...
}
? 將序列化的清單存儲(chǔ)在瀏覽器的 localStorage 中
現(xiàn)在您正在將用于填充頁(yè)面的項(xiàng)目列表保存到 localStorage,更新 main.js,并在頁(yè)面加載時(shí)使其檢索庫(kù)存鍵下的數(shù)據(jù)。 然后,用它調(diào)用 updateItemList。
// ...
const storedInventory = JSON.parse(localStorage.getItem("inventory")); ?
if (storedInventory) {
data.inventory = storedInventory; ?
updateItemList(data.inventory); ?
}
? 在頁(yè)面加載時(shí)從本地存儲(chǔ)中檢索和反序列化庫(kù)存
? 用之前存儲(chǔ)的數(shù)據(jù)更新應(yīng)用程序的狀態(tài)
? 使用恢復(fù)的庫(kù)存更新項(xiàng)目列表
進(jìn)行此更改后,當(dāng)您重建應(yīng)用程序并刷新您正在提供服務(wù)的頁(yè)面時(shí),您將看到數(shù)據(jù)在會(huì)話之間持續(xù)存在。如果您將一些項(xiàng)目添加到庫(kù)存并再次刷新頁(yè)面,您將看到上一會(huì)話中的項(xiàng)目將保留在列表中。
為了測(cè)試這些功能,我們將再次依賴 JSDOM。同理,在瀏覽器中,localStorage 是一個(gè)全局可用的 window 下,在 JSDOM 中,它在你的 JSDOM 實(shí)例中的 window 屬性下也是可用的。由于 Jest 的環(huán)境設(shè)置,這個(gè)實(shí)例在每個(gè)測(cè)試文件的全局命名空間中可用。
由于此基礎(chǔ)架構(gòu),您可以使用與在瀏覽器控制臺(tái)中相同的代碼行來(lái)測(cè)試應(yīng)用程序與 localStorage 的集成。通過(guò)使用 JSDOM 的實(shí)現(xiàn)而不是存根,您的測(cè)試將更接近于瀏覽器的運(yùn)行時(shí),因此將更有價(jià)值。
提示 根據(jù)經(jīng)驗(yàn),每當(dāng) JSDOM 實(shí)現(xiàn)您集成的瀏覽器 API 時(shí),就使用它。通過(guò)避免測(cè)試替身,您的測(cè)試將更接近運(yùn)行時(shí)發(fā)生的情況,因此將變得更加可靠。
繼續(xù)添加一個(gè)測(cè)試來(lái)驗(yàn)證 updateItemList 及其與 localStorage 的集成。此測(cè)試將遵循三個(gè) As 模式。它將創(chuàng)建一個(gè)清單,執(zhí)行 updateItemList 函數(shù),并檢查 localStorage 的清單鍵是否包含預(yù)期值。
此外,您應(yīng)該添加一個(gè) beforeEach 鉤子,在每次測(cè)試運(yùn)行之前清除 localStorage。這個(gè)鉤子將確保任何其他使用 localStorage 的測(cè)試不會(huì)干擾這個(gè)測(cè)試的執(zhí)行。
// ...
describe("updateItemList", () => {
beforeEach(() => localStorage.clear());
// ...
test("updates the localStorage with the inventory", () => {
const inventory = { cheesecake: 5, "apple pie": 2 };
updateItemList(inventory);
expect(localStorage.getItem("inventory")).toEqual(
JSON.stringify(inventory)
);
});
});
// ...
正如我之前提到的,由于 JSDOM 和 Jest 的環(huán)境設(shè)置,您可以在測(cè)試和被測(cè)單元中使用全局命名空間中可用的 localStorage,如圖 6.6 所示。

請(qǐng)注意,此測(cè)試并不能提供非??煽康馁|(zhì)量保證。它不會(huì)檢查應(yīng)用程序是否使用 updateItemList 作為任何事件的處理程序,或者它是否在頁(yè)面重新加載時(shí)恢復(fù)庫(kù)存。盡管它并沒(méi)有告訴你應(yīng)用程序的整體功能,但它是一個(gè)很好的快速迭代測(cè)試,或者獲得細(xì)粒度的反饋,特別是考慮到它是多么容易編寫(xiě)。
從這里開(kāi)始,您可以在不同的隔離級(jí)別編寫(xiě)許多不同類型的測(cè)試。例如,您可以編寫(xiě)一個(gè)測(cè)試來(lái)填充表單、單擊提交按鈕并檢查 localStorage 以查看它是否已更新。這個(gè)測(cè)試的范圍比上一個(gè)更廣泛,因此它在測(cè)試金字塔中更高,但它仍然不會(huì)告訴你應(yīng)用程序是否在用戶刷新頁(yè)面后重新加載數(shù)據(jù)。
或者,您可以直接進(jìn)行更復(fù)雜的端到端測(cè)試,該測(cè)試將填寫(xiě)表單,單擊提交按鈕,檢查 localStorage 中的內(nèi)容,然后刷新頁(yè)面以查看項(xiàng)目列表是否在會(huì)話之間保持填充。因?yàn)檫@種端到端測(cè)試與運(yùn)行時(shí)發(fā)生的情況非常相似,所以它提供了更可靠的保證。此測(cè)試與我之前提到的測(cè)試完全重疊,因此可以節(jié)省您重復(fù)測(cè)試代碼的工作。本質(zhì)上,它只是將更多操作打包到單個(gè)測(cè)試中,并幫助您保持測(cè)試代碼庫(kù)小且易于維護(hù)。
因?yàn)槟粫?huì)重新加載頁(yè)面的腳本,所以您可以將 HTML 的內(nèi)容重新分配給 document.body.innerHTML 并再次執(zhí)行 main.js,就像您在 main.test.js 中的 beforeEach 掛鉤中所做的那樣。
盡管目前該測(cè)試將是該文件中唯一使用 localStorage 的測(cè)試,但最好在每次測(cè)試之前添加一個(gè) beforeEach 鉤子以清除 localStorage。通過(guò)現(xiàn)在添加這個(gè)鉤子,你以后就不會(huì)浪費(fèi)時(shí)間想知道為什么涉及這個(gè) API 的任何其他測(cè)試都失敗了。
這是該測(cè)試的樣子。
// ...
beforeEach(() => localStorage.clear());
test("persists items between sessions", () => {
const itemField = screen.getByPlaceholderText("Item name");
fireEvent.input(itemField, {
target: { value: "cheesecake" },
bubbles: true
});
const quantityField = screen.getByPlaceholderText("Quantity");
fireEvent.input(quantityField, { target: { value: "6" }, bubbles: true });
const submitBtn = screen.getByText("Add to inventory");
fireEvent.click(submitBtn); ?
const itemListBefore = document.getElementById("item-list");
expect(itemListBefore.childNodes).toHaveLength(1);
expect(
getByText(itemListBefore, "cheesecake - Quantity: 6")
).toBeInTheDocument();
document.body.innerHTML = initialHtml; ?
jest.resetModules(); ?
require("./main"); ?
const itemListAfter = document.getElementById("item-list"); ?
expect(itemListAfter.childNodes).toHaveLength(1); ?
expect( ?
getByText(itemListAfter, "cheesecake - Quantity: 6")
).toBeInTheDocument();
});
// ...
? 填寫(xiě)完表格后提交,以便應(yīng)用程序可以存儲(chǔ)庫(kù)存狀態(tài)
? 在這種情況下,這種重新分配相當(dāng)于重新加載頁(yè)面。
? 為了讓main.js在再次導(dǎo)入時(shí)運(yùn)行,不要忘記必須清除Jest的緩存。
? 再次執(zhí)行 main.js 為應(yīng)用程序恢復(fù)存儲(chǔ)的狀態(tài)
? 在重新加載之前檢查頁(yè)面的狀態(tài)是否與存儲(chǔ)的狀態(tài)相對(duì)應(yīng)
既然您已經(jīng)了解了瀏覽器 API 的來(lái)源、如何將它們提供給您的測(cè)試以及如何使用它們來(lái)模擬瀏覽器的行為,那么請(qǐng)嘗試添加類似的功能并自行測(cè)試。作為練習(xí),您也可以嘗試保留操作日志,以便在會(huì)話之間保持完整。
6.4.2 測(cè)試歷史 API 集成
History API 使開(kāi)發(fā)人員能夠在特定選項(xiàng)卡或框架內(nèi)與用戶的導(dǎo)航歷史進(jìn)行交互。應(yīng)用程序可以將新?tīng)顟B(tài)推入歷史并展開(kāi)或倒帶它。您可以在 https://developer.mozilla.org/en-US/docs/Web/API/History 找到 History API 的文檔。
通過(guò)學(xué)習(xí)如何測(cè)試 History API,您將學(xué)習(xí)如何使用測(cè)試替身操作事件偵聽(tīng)器以及如何執(zhí)行依賴于異步觸發(fā)的事件的斷言。這些知識(shí)不僅對(duì)測(cè)試涉及 History API 的功能很有用,而且在您需要與默認(rèn)情況下不一定有權(quán)訪問(wèn)的偵聽(tīng)器進(jìn)行交互時(shí)也很有用。
在開(kāi)始測(cè)試之前,您將實(shí)現(xiàn)“撤消”功能。
要允許用戶將項(xiàng)目撤消到庫(kù)存,請(qǐng)更新 handleAddItem,以便在用戶添加項(xiàng)目時(shí)將新?tīng)顟B(tài)推送到庫(kù)存。
// ...
const handleAddItem = event => {
event.preventDefault();
const { name, quantity } = event.target.elements;
addItem(name.value, parseInt(quantity.value, 10));
history.pushState( ?
{ inventory: { ...data.inventory } },
document.title
);
updateItemList(data.inventory);
};
// ...
? 將一個(gè)包含清單內(nèi)容的新框架推入歷史
注意 JSDOM 的歷史實(shí)現(xiàn)有一個(gè)錯(cuò)誤,即推送狀態(tài)在分配給狀態(tài)之前不會(huì)被克隆。相反,JSDOM 的歷史記錄將保存對(duì)傳遞對(duì)象的引用。
因?yàn)槟谟脩籼砑禹?xiàng)目時(shí)改變庫(kù)存,所以 JSDOM 歷史記錄中的前一幀將包含最新版本的庫(kù)存,而不是前一幀。因此,恢復(fù)到以前的狀態(tài)將無(wú)法正常工作。
為避免此問(wèn)題,您可以使用 { ... data.inventory } 自己創(chuàng)建一個(gè)新的 data.inventory。
JSDOM 對(duì) DOM API 的實(shí)現(xiàn)不應(yīng)該與瀏覽器中的不同,但是,因?yàn)樗且粋€(gè)完全不同的軟件,所以可能會(huì)發(fā)生這種情況。
這個(gè)問(wèn)題已經(jīng)在 https://github.com/jsdom/jsdom/issues/2970 上進(jìn)行了調(diào)查,但是如果你碰巧發(fā)現(xiàn)了這樣的 JSDOM 錯(cuò)誤,最快的解決方案是通過(guò)更新你的代碼來(lái)自己修復(fù)它的行為在 JSDOM 中就像在瀏覽器中一樣。如果你有時(shí)間,我強(qiáng)烈建議你也向上游 jsdom 存儲(chǔ)庫(kù)提交一個(gè)問(wèn)題,如果可能,創(chuàng)建一個(gè)拉取請(qǐng)求來(lái)修復(fù)它,這樣其他人將來(lái)就不會(huì)遇到同樣的問(wèn)題。
現(xiàn)在,創(chuàng)建一個(gè)將在用戶單擊撤消按鈕時(shí)觸發(fā)的函數(shù)。如果用戶還沒(méi)有在歷史的第一個(gè)項(xiàng)目中,這個(gè)函數(shù)應(yīng)該通過(guò)調(diào)用 history.back 返回。
// ...
const handleUndo = () => {
if (history.state === null) return; ?
history.back(); ?
};
module.exports = {
updateItemList,
handleAddItem,
checkFormValues,
handleUndo ?
};
? 如果 history.state 為空,則意味著我們已經(jīng)在歷史的最開(kāi)始。
? 如果 history.state 不為空,則使用 history.back 彈出歷史的最后一幀
? 你必須使用 handleUndo 來(lái)處理事件。 不要忘記導(dǎo)出它。
由于 history.back 是異步發(fā)生的,因此您還必須創(chuàng)建一個(gè)用于窗口 popstate 事件的處理程序,該事件在 history.back 完成時(shí)被調(diào)度。
const handlePopstate = () => {
data.inventory = history.state ? history.state.inventory : {};
updateItemList(data.inventory);
};
// Don't forget to update your exports.
module.exports = {
updateItemList,
handleAddItem,
checkFormValues,
handleUndo,
handlePopstate ?
};
? 也導(dǎo)出 handlePopstate,以便您稍后可以將其附加到 main.js 中窗口的 popstate 事件。
在 index.html 中添加一個(gè) Undo 按鈕,稍后我們將使用它來(lái)觸發(fā) handleUndo。
<!DOCTYPE html>
<html lang="en">
<!-- ... -->
<body>
<!-- ... -->
<button id="undo-button">Undo</button> ?
<script src="bundle.js"></script>
</body>
</html>
? 觸發(fā)“撤銷”動(dòng)作的按鈕
最后,讓我們將所有內(nèi)容放在一起并更新 main.js 以在用戶單擊撤消按鈕時(shí)調(diào)用 handleUndo,以便在觸發(fā) popstate 事件時(shí)更新列表。
注意 popstate 事件的有趣之處在于,它們也會(huì)在用戶按下瀏覽器的后退按鈕時(shí)觸發(fā)。 因?yàn)?popstate 的處理程序與 handleUndo 是分開(kāi)的,所以當(dāng)用戶按下瀏覽器的后退按鈕時(shí),撤消功能也將起作用。
const {
handleAddItem,
checkFormValues,
handleUndo,
handlePopstate
} = require("./domController");
// ...
const undoButton = document.getElementById("undo-button");
undoButton.addEventListener("click", handleUndo); ?
window.addEventListener("popstate", handlePopstate);
// ...
? 每當(dāng)用戶單擊撤消按鈕時(shí)調(diào)用 handleUndo
就像您之前所做的那樣,通過(guò)運(yùn)行 Browserify 重建 bundle.js,并使用 http-server 為其提供服務(wù),以便您可以看到它在 localhost:8080 上工作。
實(shí)現(xiàn)此功能后,是時(shí)候測(cè)試它了。由于此功能涉及多個(gè)功能,因此我們將其測(cè)試分為幾個(gè)不同的部分。首先,您將學(xué)習(xí)如何測(cè)試 handleUndo 函數(shù),檢查它在調(diào)用時(shí)是否返回歷史記錄。然后您將編寫(xiě)一個(gè)測(cè)試來(lái)檢查 handlePopstate 是否與 updateItemList 充分集成。最后,您將編寫(xiě)一個(gè)端到端的測(cè)試來(lái)填充表單、提交一個(gè)項(xiàng)目、單擊撤消按鈕并檢查列表是否按預(yù)期更新。
從 handleUndo 的單元測(cè)試開(kāi)始。它應(yīng)該遵循三個(gè) As 模式:安排、行動(dòng)、斷言。它會(huì)將一個(gè)狀態(tài)推送到全局歷史記錄中——這要?dú)w功于 JSDOM——調(diào)用 handleUndo,并檢查歷史記錄是否恢復(fù)到其初始狀態(tài)。
注意因?yàn)?history.back 是異步的,正如我已經(jīng)提到的,你必須在 popstate 事件被觸發(fā)后才執(zhí)行你的斷言。
在這種情況下,使用 done 回調(diào)來(lái)指示您的測(cè)試何時(shí)應(yīng)該完成可能會(huì)更簡(jiǎn)單、更清晰,而不是像我們迄今為止大部分時(shí)間使用的異步測(cè)試回調(diào)。
如果你不記得 done 是如何工作的,以及它與使用 Promise 的比較,請(qǐng)?jiān)倏纯吹?2 章“集成測(cè)試”部分中的示例。
const {
updateItemList,
handleAddItem,
checkFormValues,
handleUndo
} = require("./domController");
// ...
describe("tests with history", () => {
describe("handleUndo", () => {
test("going back from a non-initial state", done => {
window.addEventListener("popstate", () => { ?
expect(history.state).toEqual(null);
done();
});
history.pushState( ?
{ inventory: { cheesecake: 5 } },
"title"
);
handleUndo(); ?
});
});
});
// ...
? 檢查歷史是否恢復(fù)到初始狀態(tài),并在觸發(fā) popstate 事件時(shí)完成測(cè)試
? 將新框架推入歷史
? 使用handleUndo函數(shù)觸發(fā)popstate事件
單獨(dú)運(yùn)行此測(cè)試時(shí),它會(huì)通過(guò),但是,如果它在同一文件中的其他測(cè)試之后運(yùn)行,則會(huì)失敗。 因?yàn)槠渌麥y(cè)試以前使用過(guò) handleAddItem,所以它們干擾了 handleUndo 測(cè)試開(kāi)始的初始狀態(tài)。 要解決這個(gè)問(wèn)題,您必須在每次測(cè)試之前重置歷史記錄。
繼續(xù)并創(chuàng)建一個(gè) beforeEach 鉤子,它不斷調(diào)用 history.back 直到它回到初始狀態(tài)。 一旦達(dá)到初始狀態(tài),它應(yīng)該分離自己的偵聽(tīng)器,以免干擾測(cè)試。
// ...
describe("tests with history", () => {
beforeEach(done => {
const clearHistory = () => {
if (history.state === null) { ?
window.removeEventListener("popstate", clearHistory);
return done();
}
history.back(); ?
};
window.addEventListener("popstate", clearHistory); ?
clearHistory(); ?
});
describe("handleUndo", () => { /* ... */ });
});
? 如果你已經(jīng)處于歷史的初始狀態(tài),則將自己從聽(tīng) popstate 事件中分離出來(lái)并完成鉤子
? 如果歷史還沒(méi)有處于初始狀態(tài),則通過(guò)調(diào)用 history.back 函數(shù)觸發(fā)另一個(gè) popstate 事件
? 使用clearHistory函數(shù)處理popstate事件
?第一次調(diào)用clearHistory,導(dǎo)致歷史倒帶
您剛剛編寫(xiě)的測(cè)試的另一個(gè)問(wèn)題是它將偵聽(tīng)器附加到全局窗口,并且在測(cè)試完成后不會(huì)將其刪除。因?yàn)楸O(jiān)聽(tīng)器沒(méi)有被移除,所以每次 popstate 事件發(fā)生時(shí)它仍然會(huì)被觸發(fā),即使在測(cè)試完成之后也是如此。這些激活可能會(huì)導(dǎo)致其他測(cè)試失敗,因?yàn)橐淹瓿蓽y(cè)試的斷言將再次運(yùn)行。
要在每次測(cè)試后分離 popstate 事件的所有偵聽(tīng)器,我們必須監(jiān)視窗口的 addEventListener 方法,以便我們可以檢索測(cè)試期間添加的偵聽(tīng)器并將其刪除,如圖 6.7 所示。

要查找和分離事件偵聽(tīng)器,請(qǐng)將以下代碼添加到您的測(cè)試中。
// ...
describe("tests with history", () => {
beforeEach(() => jest.spyOn(window, "addEventListener")); ?
afterEach(() => {
const popstateListeners = window ?
.addEventListener
.mock
.calls
.filter(([ eventName ]) => {
return eventName === "popstate"
});
popstateListeners.forEach(([eventName, handlerFn]) => { ?
window.removeEventListener(eventName, handlerFn);
});
jest.restoreAllMocks();
});
describe("handleUndo", () => { /* ... */ });
});
? 使用 spy 來(lái)跟蹤添加到窗口的每個(gè)監(jiān)聽(tīng)器
? 查找 popstate 事件的所有監(jiān)聽(tīng)器
? 從窗口中移除 popstate 事件的所有監(jiān)聽(tīng)器
接下來(lái),我們需要確保如果用戶已經(jīng)處于初始狀態(tài),handleUndo 不會(huì)調(diào)用 history.back。 在此測(cè)試中,您不能在執(zhí)行斷言之前等待 popstate 事件,因?yàn)槿绻?handleUndo 沒(méi)有按預(yù)期調(diào)用 history.back,它將永遠(yuǎn)不會(huì)發(fā)生。 您也不能在調(diào)用 handleUndo 后立即編寫(xiě)斷言,因?yàn)樵谀臄嘌赃\(yùn)行時(shí),history.back 可能已被調(diào)用但可能尚未完成。 為了充分執(zhí)行這個(gè)斷言,我們將監(jiān)視 history.back 并斷言它沒(méi)有被調(diào)用——這是我們?cè)诘?3 章中討論過(guò)的少數(shù)情況下否定斷言足夠的情況之一。
// ...
describe("tests with history", () => {
// ...
describe("handleUndo", () => {
// ...
test("going back from an initial state", () => {
jest.spyOn(history, "back");
handleUndo();
expect(history.back.mock.calls).toHaveLength(0); ?
});
});
});
? 這個(gè)斷言并不關(guān)心 history.back 是否完成了歷史堆棧的展開(kāi)。 它只檢查 history.back 是否已被調(diào)用。
您剛剛編寫(xiě)的測(cè)試僅涵蓋 handleUndo 及其與 history.back 的交互。 在測(cè)試金字塔中,它們介于單元測(cè)試和集成測(cè)試之間。
現(xiàn)在,編寫(xiě)涵蓋 handlePopstate 的測(cè)試,它也使用 handleAddItem。 此測(cè)試的范圍更廣,因此它在測(cè)試金字塔中的位置比前一個(gè)更高。
這些測(cè)試應(yīng)該將狀態(tài)推送到歷史記錄中,調(diào)用 handlePopstate,并檢查應(yīng)用程序是否充分更新了項(xiàng)目列表。 在這種情況下,您需要編寫(xiě) DOM 斷言,就像我們?cè)谏弦还?jié)中所做的那樣。
const {
updateItemList,
handleAddItem,
checkFormValues,
handleUndo,
handlePopstate
} = require("./domController");
// ...
describe("tests with history", () => {
// ...
describe("handlePopstate", () => {
test("updating the item list with the current state", () => {
history.pushState( ?
{ inventory: { cheesecake: 5, "carrot cake": 2 } },
"title"
);
handlePopstate(); ?
const itemList = document.getElementById("item-list");
expect(itemList.childNodes).toHaveLength(2); ?
expect(getByText(itemList, "cheesecake - Quantity: 5")) ?
.toBeInTheDocument();
expect(
getByText(itemList, "carrot cake - Quantity: 2") ?
).toBeInTheDocument();
});
});
});
? 將一個(gè)包含清單內(nèi)容的新框架推入歷史
? 調(diào)用 handlePopstate 以便應(yīng)用程序使用當(dāng)前歷史框架中的狀態(tài)更新自身
? 斷言項(xiàng)目列表正好有兩個(gè)項(xiàng)目
? 找到一個(gè)元素,表明庫(kù)存中有 5 個(gè)芝士蛋糕,然后斷言它在文檔中
? 找到一個(gè)元素,表明庫(kù)存中有 2 個(gè)胡蘿卜蛋糕,然后斷言它在文檔中
注意如果您想完全隔離地測(cè)試 handlePopstate,您可以找到一種為 updateItemList 創(chuàng)建存根的方法,但是,正如我們之前討論過(guò)的,您使用的測(cè)試替身越多,您的測(cè)試與運(yùn)行時(shí)情況的相似度就越低,因此,它們變得越不可靠。
以下是運(yùn)行您剛剛編寫(xiě)的測(cè)試時(shí)發(fā)生的情況,包括它的鉤子:
最上面的 beforeEach 鉤子將 initialHtml 分配給文檔正文的 innerHTML。
此測(cè)試中的第一個(gè) beforeEach 鉤子會(huì)監(jiān)視窗口的 addEventListener 方法,以便它可以跟蹤將附加到它的所有偵聽(tīng)器。
此測(cè)試的 describe 塊中的第二個(gè) beforeEach 掛鉤將瀏覽器的歷史記錄重置為其初始狀態(tài)。它通過(guò)將一個(gè)事件偵聽(tīng)器附加到窗口來(lái)實(shí)現(xiàn),該偵聽(tīng)器為每個(gè) popstate 事件調(diào)用 history.back 直到狀態(tài)為空。一旦歷史被清除,它就會(huì)分離偵聽(tīng)器,從而清除歷史。
測(cè)試本身運(yùn)行。它將狀態(tài)推送到歷史記錄,執(zhí)行 handlePopstate,并檢查頁(yè)面是否包含預(yù)期元素。
測(cè)試的 afterEach 鉤子運(yùn)行。它使用 window.addEventListener.mock.calls 中的記錄來(lái)發(fā)現(xiàn)響應(yīng)窗口 popstate 事件的偵聽(tīng)器并分離它們。
作為練習(xí),嘗試編寫(xiě)一個(gè)測(cè)試來(lái)涵蓋 handleAddItem 和 History API 之間的集成。創(chuàng)建一個(gè)調(diào)用 handleAddItem 的測(cè)試,并檢查狀態(tài)是否已使用添加到庫(kù)存的項(xiàng)目進(jìn)行更新。
現(xiàn)在您已經(jīng)學(xué)習(xí)了如何測(cè)試 handleUndo 隔離和 handlePopstate 及其與 updateItemList 的集成,您將編寫(xiě)一個(gè)將所有內(nèi)容組合在一起的端到端測(cè)試。這種端到端測(cè)試是您可以創(chuàng)建的最可靠的保證。它將像用戶一樣與應(yīng)用程序交互,通過(guò)頁(yè)面元素觸發(fā)事件并檢查 DOM 的最終狀態(tài)。
要運(yùn)行此端到端測(cè)試,您還需要清除全局歷史堆棧。否則,可能導(dǎo)致歷史更改的其他測(cè)試可能會(huì)導(dǎo)致它失敗。為避免在多個(gè)測(cè)試之間復(fù)制和粘貼相同的代碼,請(qǐng)使用清除歷史記錄的功能創(chuàng)建一個(gè)單獨(dú)的文件,如下所示。
const clearHistoryHook = done => {
const clearHistory = () => {
if (history.state === null) {
window.removeEventListener("popstate", clearHistory);
return done();
}
history.back();
};
window.addEventListener("popstate", clearHistory);
clearHistory();
};
module.exports = { clearHistoryHook };
現(xiàn)在您已經(jīng)將清除歷史堆棧的函數(shù)移到了一個(gè)單獨(dú)的文件中,您可以在鉤子中導(dǎo)入和使用它,而不是每次都重寫(xiě)相同的內(nèi)聯(lián)函數(shù)。 例如,您可以返回 domController.test.js 并使用 clearHistoryHook 替換您在那里編寫(xiě)的冗長(zhǎng)的內(nèi)聯(lián)鉤子。
// ...
const { clearHistoryHook } = require("./testUtils");
// ...
describe("tests with history", () => {
// ...
beforeEach(clearHistoryHook); ?
// ...
});
? 代替內(nèi)聯(lián)函數(shù),使用單獨(dú)的 clearHistoryHook 將歷史重置為其初始狀態(tài)
最后,將相同的鉤子添加到 main.test.js,并編寫(xiě)一個(gè)測(cè)試,通過(guò)表單添加項(xiàng)目,單擊撤消按鈕,并檢查列表的內(nèi)容,就像用戶一樣。
const { clearHistoryHook } = require("./testUtils.js");
describe("adding items", () => {
beforeEach(clearHistoryHook);
// ...
test("undo to empty list", done => {
const itemField = screen.getByPlaceholderText("Item name");
const submitBtn = screen.getByText("Add to inventory");
fireEvent.input(itemField, { ?
target: { value: "cheesecake" },
bubbles: true
});
const quantityField = screen.getByPlaceholderText("Quantity");
fireEvent.input(quantityField, { ?
target: { value: "6" },
bubbles: true
});
fireEvent.click(submitBtn); ?
expect(history.state).toEqual({ inventory: { cheesecake: 6 } }); ?
window.addEventListener("popstate", () => { ?
const itemList = document.getElementById("item-list");
expect(itemList).toBeEmpty();
done();
});
fireEvent.click(screen.getByText("Undo")); ?
});
});
? 填寫(xiě)項(xiàng)目名稱的字段
? 填寫(xiě)項(xiàng)目數(shù)量的字段
? 提交表格
? 檢查歷史是否處于預(yù)期狀態(tài)
? 當(dāng)popstate事件發(fā)生時(shí),檢查item列表是否為空,并完成測(cè)試
? 通過(guò)點(diǎn)擊 Undo 按鈕觸發(fā) popstate 事件
正如之前發(fā)生的那樣,這個(gè)測(cè)試在單獨(dú)執(zhí)行時(shí)總是會(huì)通過(guò),但如果它與同一文件中的其他測(cè)試一起運(yùn)行,觸發(fā) popstate 事件,它可能會(huì)導(dǎo)致它們失敗。 發(fā)生此故障是因?yàn)樗鼘в袛嘌缘膫陕?tīng)器附加到窗口,即使在測(cè)試完成后,該偵聽(tīng)器仍會(huì)繼續(xù)運(yùn)行,就像以前一樣。
如果您想看到它失敗,請(qǐng)嘗試在此測(cè)試之前添加一個(gè)也會(huì)觸發(fā) popstate 事件的測(cè)試。 例如,您可以編寫(xiě)一個(gè)新測(cè)試,將多個(gè)項(xiàng)目添加到清單中,并僅單擊一次“撤消”按鈕,如下所示。
// ...
describe("adding items", () => {
// ...
test("undo to one item", done => {
const itemField = screen.getByPlaceholderText("Item name");
const quantityField = screen.getByPlaceholderText("Quantity");
const submitBtn = screen.getByText("Add to inventory");
// Adding a cheesecake
fireEvent.input(itemField, {
target: { value: "cheesecake" },
bubbles: true
});
fireEvent.input(quantityField, {
target: { value: "6" },
bubbles: true
});
fireEvent.click(submitBtn); ?
// Adding a carrot cake
fireEvent.input(itemField, {
target: { value: "carrot cake" },
bubbles: true
});
fireEvent.input(quantityField, {
target: { value: "5" },
bubbles: true
});
fireEvent.click(submitBtn); ?
window.addEventListener("popstate", () => { ?
const itemList = document.getElementById("item-list");
expect(itemList.children).toHaveLength(1);
expect(
getByText(itemList, "cheesecake - Quantity: 6")
).toBeInTheDocument();
done();
});
fireEvent.click(screen.getByText("Undo")); ?
});
test("undo to empty list", done => { /* ... */ });
});
// ...
? 提交表單,將 6 個(gè)芝士蛋糕添加到庫(kù)存中
? 再次提交表單,將5個(gè)胡蘿卜蛋糕添加到庫(kù)存中
? 當(dāng)popstate事件發(fā)生時(shí),檢查item列表中是否包含你期望的元素并完成測(cè)試
? 通過(guò)單擊 Undo 按鈕觸發(fā) popstate 事件
當(dāng)運(yùn)行你的測(cè)試時(shí),你會(huì)看到它們失敗了,因?yàn)樗兄盀榇翱诘?popstate 事件附加的處理程序都被執(zhí)行了,不管之前的測(cè)試是否完成。
您可以使用與 domController.test.js 中的測(cè)試相同的方式解決此問(wèn)題:通過(guò)跟蹤對(duì) window.addEventListener 的調(diào)用并在每次測(cè)試后分離處理程序。
因?yàn)槟鷮⒅赜迷?domController.test.js 中編寫(xiě)的鉤子,所以也將其移至 testUtils.js,如下所示。
// ...
const detachPopstateHandlers = () => {
const popstateListeners = window.addEventListener.mock.calls ?
.filter(([eventName]) => {
return eventName === "popstate";
});
popstateListeners.forEach(([eventName, handlerFn]) => { ?
window.removeEventListener(eventName, handlerFn);
});
jest.restoreAllMocks();
}
module.exports = { clearHistoryHook, detachPopstateHandlers };
? 查找 popstate 事件的所有監(jiān)聽(tīng)器
? 分離所有 popstate 監(jiān)聽(tīng)器
現(xiàn)在,您可以在 domController.test.js 中使用 detachPopstateHandlers 而不是編寫(xiě)內(nèi)聯(lián)函數(shù)。
const {
clearHistoryHook,
detachPopstateHandlers
} = require("./testUtils");
// ...
describe("tests with history", () => {
beforeEach(() => jest.spyOn(window, "addEventListener")); ?
afterEach(detachPopstateHandlers); ?
// ...
});
? 使用 spy 來(lái)跟蹤添加到窗口的每個(gè)事件監(jiān)聽(tīng)器
? 使用 detachPopstateHandlers,而不是使用內(nèi)聯(lián)函數(shù)來(lái)分離 popstate 事件的偵聽(tīng)器
在 main.test.js 中使用 detachPopstateHandlers 時(shí),在每次測(cè)試后分離所有窗口的偵聽(tīng)器時(shí)必須小心,否則,main.js 附加的偵聽(tīng)器也可能被意外分離。 為避免刪除main.js 附帶的監(jiān)聽(tīng)器,請(qǐng)確保在執(zhí)行main.js 后才監(jiān)聽(tīng)window.addEventListener,如圖6.8 所示。

然后,使用 detachPopstateHandlers 添加 afterEach 鉤子。
// ...
const {
clearHistoryHook,
detachPopstateHandlers
} = require("./testUtils");
beforeEach(clearHistoryHook);
beforeEach(() => {
document.body.innerHTML = initialHtml;
jest.resetModules();
require("./main");
jest.spyOn(window, "addEventListener"); ?
});
afterEach(detachPopstateHandlers);
describe("adding items", () => { /* ... */ });
? 只有在 main.js 被執(zhí)行后,你才能監(jiān)視 window.add-EventListener。否則, detachPopstateHandlers 也會(huì)分離 main.js 附加到頁(yè)面的處理程序。
注意重要的是要注意這些測(cè)試具有高度重疊。
因?yàn)槟呀?jīng)為作為此功能一部分的單個(gè)功能和整個(gè)功能(包括與 DOM 的交互)編寫(xiě)了測(cè)試,所以您將有一些多余的檢查。
根據(jù)您希望反饋的細(xì)化程度以及可用時(shí)間,您應(yīng)該考慮只編寫(xiě)端到端測(cè)試,它提供了所有測(cè)試中最突出的覆蓋范圍。另一方面,如果您有時(shí)間,并且希望在編寫(xiě)代碼時(shí)有一個(gè)更快的反饋循環(huán),那么編寫(xiě)粒度測(cè)試也會(huì)很有用。
作為練習(xí),嘗試添加“重做”功能并為其編寫(xiě)測(cè)試。
現(xiàn)在您已經(jīng)測(cè)試了與 localStorage 和 History API 的集成,您應(yīng)該知道 JSDOM 負(fù)責(zé)在您的測(cè)試環(huán)境中模擬它們。感謝 Jest,JSDOM 存儲(chǔ)在其實(shí)例的 window 屬性中的這些值將通過(guò)全局命名空間可用于您的測(cè)試。您可以像在瀏覽器中一樣使用它們,而無(wú)需存根。避免這些存根增加了測(cè)試創(chuàng)建的可靠性保證,因?yàn)樗鼈兊膶?shí)現(xiàn)應(yīng)該反映瀏覽器運(yùn)行時(shí)發(fā)生的情況。
正如我們?cè)诒菊轮兴龅哪菢樱跍y(cè)試您的前端應(yīng)用程序時(shí),請(qǐng)注意您的測(cè)試有多少重疊以及您想要實(shí)現(xiàn)的反饋粒度??紤]這些因素來(lái)決定應(yīng)該編寫(xiě)哪些測(cè)試,不應(yīng)該編寫(xiě)哪些測(cè)試,就像我們?cè)谇耙徽轮杏懻摰哪菢印?/p>