6.4 测试和浏览器 API

设备齐全的厨房并不一定意味着烤好的甜点。当谈到它在烘焙令人惊叹的蛋糕方面的作用时,厨房只与其中的糕点厨师一样好。同样,在 Web 开发这个不那么美味但同样有趣的世界中,只有当您的应用程序与它们正确交互时,浏览器为您提供的奇妙 API 才有用。

正如我在本章前面提到的,由于浏览器为您的代码提供了方法,您可以构建功能丰富的应用程序。例如,您可以获取用户的位置、发送通知、浏览应用程序的历史记录或将数据存储在浏览器中,这些数据将在各部分之间保持不变。现代浏览器甚至允许您与蓝牙设备交互并进行语音识别。

在本章中,您将学习如何测试涉及这些 API 的功能。您将了解它们来自何处、如何检查它们以及如何编写足够的测试替身来帮助您处理事件处理程序而不干扰您的应用程序代码。

您将学习如何通过将其中两个 API 与前端应用程序集成来测试这些 DOM API:localStorage 和 history。通过使用 localStorage,您将使您的应用程序将其数据保存在浏览器中,并在页面加载时将其恢复。然后,使用 History API,您将允许用户撤消向库存添加项目的操作。

6.4.1 测试 localStorage 集成

localStorage 是一种机制,它是 Web Storage API 的一部分。它使应用程序能够在浏览器中存储键值对并在以后检索它们。您可以在 https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API/Local_storage 找到有关 localStorage 的文档。

通过学习如何测试诸如 localStorage 之类的 API,您将了解它们在测试环境中的工作方式以及如何验证您的应用程序与它们的集成。

在这些示例中,您将用于更新页面的清单持久保存到 localStorage。然后,当页面加载时,您将从 localStorage 检索库存并使用它再次填充列表。此功能将使您的应用程序不会在会话之间丢失数据。

首先更新 updateItemList,以便它将传递给它的对象存储在 localStorage 中的清单键下。因为 localStorage 不能存储对象,所以你需要在持久化数据之前使用 JSON.stringify 序列化清单。

// ...
 
const updateItemList = inventory => {
  if (!inventory === null) return;
 
  localStorage.setItem("inventory", JSON.stringify(inventory));      ❶
 
  // ...
}

❶ 将序列化的清单存储在浏览器的 localStorage 中

现在您正在将用于填充页面的项目列表保存到 localStorage,更新 main.js,并在页面加载时使其检索库存键下的数据。 然后,用它调用 updateItemList。

// ...
 
const storedInventory = JSON.parse(localStorage.getItem("inventory"));     ❶
 
if (storedInventory) {
  data.inventory = storedInventory;                                        ❷
  updateItemList(data.inventory);                                          ❸
}

❶ 在页面加载时从本地存储中检索和反序列化库存

❷ 用之前存储的数据更新应用程序的状态

❸ 使用恢复的库存更新项目列表

进行此更改后,当您重建应用程序并刷新您正在提供服务的页面时,您将看到数据在会话之间持续存在。如果您将一些项目添加到库存并再次刷新页面,您将看到上一会话中的项目将保留在列表中。

为了测试这些功能,我们将再次依赖 JSDOM。同理,在浏览器中,localStorage 是一个全局可用的 window 下,在 JSDOM 中,它在你的 JSDOM 实例中的 window 属性下也是可用的。由于 Jest 的环境设置,这个实例在每个测试文件的全局命名空间中可用。

由于此基础架构,您可以使用与在浏览器控制台中相同的代码行来测试应用程序与 localStorage 的集成。通过使用 JSDOM 的实现而不是存根,您的测试将更接近于浏览器的运行时,因此将更有价值。

提示 根据经验,每当 JSDOM 实现您集成的浏览器 API 时,就使用它。通过避免测试替身,您的测试将更接近运行时发生的情况,因此将变得更加可靠。

继续添加一个测试来验证 updateItemList 及其与 localStorage 的集成。此测试将遵循三个 As 模式。它将创建一个清单,执行 updateItemList 函数,并检查 localStorage 的清单键是否包含预期值。

此外,您应该添加一个 beforeEach 钩子,在每次测试运行之前清除 localStorage。这个钩子将确保任何其他使用 localStorage 的测试不会干扰这个测试的执行。

// ...
 
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 的环境设置,您可以在测试和被测单元中使用全局命名空间中可用的 localStorage,如图 6.6 所示。


图6-6

请注意,此测试并不能提供非常可靠的质量保证。它不会检查应用程序是否使用 updateItemList 作为任何事件的处理程序,或者它是否在页面重新加载时恢复库存。尽管它并没有告诉你应用程序的整体功能,但它是一个很好的快速迭代测试,或者获得细粒度的反馈,特别是考虑到它是多么容易编写。

从这里开始,您可以在不同的隔离级别编写许多不同类型的测试。例如,您可以编写一个测试来填充表单、单击提交按钮并检查 localStorage 以查看它是否已更新。这个测试的范围比上一个更广泛,因此它在测试金字塔中更高,但它仍然不会告诉你应用程序是否在用户刷新页面后重新加载数据。

或者,您可以直接进行更复杂的端到端测试,该测试将填写表单,单击提交按钮,检查 localStorage 中的内容,然后刷新页面以查看项目列表是否在会话之间保持填充。因为这种端到端测试与运行时发生的情况非常相似,所以它提供了更可靠的保证。此测试与我之前提到的测试完全重叠,因此可以节省您重复测试代码的工作。本质上,它只是将更多操作打包到单个测试中,并帮助您保持测试代码库小且易于维护。

因为您不会重新加载页面的脚本,所以您可以将 HTML 的内容重新分配给 document.body.innerHTML 并再次执行 main.js,就像您在 main.test.js 中的 beforeEach 挂钩中所做的那样。

尽管目前该测试将是该文件中唯一使用 localStorage 的测试,但最好在每次测试之前添加一个 beforeEach 钩子以清除 localStorage。通过现在添加这个钩子,你以后就不会浪费时间想知道为什么涉及这个 API 的任何其他测试都失败了。

这是该测试的样子。

// ...
 
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();
});
 
// ...

❶ 填写完表格后提交,以便应用程序可以存储库存状态

❷ 在这种情况下,这种重新分配相当于重新加载页面。

❸ 为了让main.js在再次导入时运行,不要忘记必须清除Jest的缓存。

❹ 再次执行 main.js 为应用程序恢复存储的状态

❺ 在重新加载之前检查页面的状态是否与存储的状态相对应

既然您已经了解了浏览器 API 的来源、如何将它们提供给您的测试以及如何使用它们来模拟浏览器的行为,那么请尝试添加类似的功能并自行测试。作为练习,您也可以尝试保留操作日志,以便在会话之间保持完整。

6.4.2 测试历史 API 集成
History API 使开发人员能够在特定选项卡或框架内与用户的导航历史进行交互。应用程序可以将新状态推入历史并展开或倒带它。您可以在 https://developer.mozilla.org/en-US/docs/Web/API/History 找到 History API 的文档。

通过学习如何测试 History API,您将学习如何使用测试替身操作事件侦听器以及如何执行依赖于异步触发的事件的断言。这些知识不仅对测试涉及 History API 的功能很有用,而且在您需要与默认情况下不一定有权访问的侦听器进行交互时也很有用。

在开始测试之前,您将实现“撤消”功能。

要允许用户将项目撤消到库存,请更新 handleAddItem,以便在用户添加项目时将新状态推送到库存。

// ...
 
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);
};
 
// ...

❶ 将一个包含清单内容的新框架推入历史

注意 JSDOM 的历史实现有一个错误,即推送状态在分配给状态之前不会被克隆。相反,JSDOM 的历史记录将保存对传递对象的引用。

因为您在用户添加项目时改变库存,所以 JSDOM 历史记录中的前一帧将包含最新版本的库存,而不是前一帧。因此,恢复到以前的状态将无法正常工作。

为避免此问题,您可以使用 { ... data.inventory } 自己创建一个新的 data.inventory。

JSDOM 对 DOM API 的实现不应该与浏览器中的不同,但是,因为它是一个完全不同的软件,所以可能会发生这种情况。

这个问题已经在 https://github.com/jsdom/jsdom/issues/2970 上进行了调查,但是如果你碰巧发现了这样的 JSDOM 错误,最快的解决方案是通过更新你的代码来自己修复它的行为在 JSDOM 中就像在浏览器中一样。如果你有时间,我强烈建议你也向上游 jsdom 存储库提交一个问题,如果可能,创建一个拉取请求来修复它,这样其他人将来就不会遇到同样的问题。

现在,创建一个将在用户单击撤消按钮时触发的函数。如果用户还没有在历史的第一个项目中,这个函数应该通过调用 history.back 返回。

// ...
 
const handleUndo = () => {
  if (history.state === null) return;        ❶
  history.back();                            ❷
};
 
module.exports = {
  updateItemList,
  handleAddItem,
  checkFormValues,
  handleUndo                                 ❸
};

❶ 如果 history.state 为空,则意味着我们已经在历史的最开始。

❷ 如果 history.state 不为空,则使用 history.back 弹出历史的最后一帧

❸ 你必须使用 handleUndo 来处理事件。 不要忘记导出它。

由于 history.back 是异步发生的,因此您还必须创建一个用于窗口 popstate 事件的处理程序,该事件在 history.back 完成时被调度。

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           ❶
};

❶ 也导出 handlePopstate,以便您稍后可以将其附加到 main.js 中窗口的 popstate 事件。

在 index.html 中添加一个 Undo 按钮,稍后我们将使用它来触发 handleUndo。



  
  
    
 
    
  

❶ 触发“撤销”动作的按钮

最后,让我们将所有内容放在一起并更新 main.js 以在用户单击撤消按钮时调用 handleUndo,以便在触发 popstate 事件时更新列表。

注意 popstate 事件的有趣之处在于,它们也会在用户按下浏览器的后退按钮时触发。 因为 popstate 的处理程序与 handleUndo 是分开的,所以当用户按下浏览器的后退按钮时,撤消功能也将起作用。

const {
  handleAddItem,
  checkFormValues,
  handleUndo,
  handlePopstate
} = require("./domController");
 
// ...
 
const undoButton = document.getElementById("undo-button");
undoButton.addEventListener("click", handleUndo);               ❶
 
window.addEventListener("popstate", handlePopstate);
 
// ...

❶ 每当用户单击撤消按钮时调用 handleUndo

就像您之前所做的那样,通过运行 Browserify 重建 bundle.js,并使用 http-server 为其提供服务,以便您可以看到它在 localhost:8080 上工作。

实现此功能后,是时候测试它了。由于此功能涉及多个功能,因此我们将其测试分为几个不同的部分。首先,您将学习如何测试 handleUndo 函数,检查它在调用时是否返回历史记录。然后您将编写一个测试来检查 handlePopstate 是否与 updateItemList 充分集成。最后,您将编写一个端到端的测试来填充表单、提交一个项目、单击撤消按钮并检查列表是否按预期更新。

从 handleUndo 的单元测试开始。它应该遵循三个 As 模式:安排、行动、断言。它会将一个状态推送到全局历史记录中——这要归功于 JSDOM——调用 handleUndo,并检查历史记录是否恢复到其初始状态。

注意因为 history.back 是异步的,正如我已经提到的,你必须在 popstate 事件被触发后才执行你的断言。

在这种情况下,使用 done 回调来指示您的测试何时应该完成可能会更简单、更清晰,而不是像我们迄今为止大部分时间使用的异步测试回调。

如果你不记得 done 是如何工作的,以及它与使用 Promise 的比较,请再看看第 2 章“集成测试”部分中的示例。

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();                                             ❸
    });
  });
});
 
// ...

❶ 检查历史是否恢复到初始状态,并在触发 popstate 事件时完成测试

❷ 将新框架推入历史

❸ 使用handleUndo函数触发popstate事件

单独运行此测试时,它会通过,但是,如果它在同一文件中的其他测试之后运行,则会失败。 因为其他测试以前使用过 handleAddItem,所以它们干扰了 handleUndo 测试开始的初始状态。 要解决这个问题,您必须在每次测试之前重置历史记录。

继续并创建一个 beforeEach 钩子,它不断调用 history.back 直到它回到初始状态。 一旦达到初始状态,它应该分离自己的侦听器,以免干扰测试。

// ...
 
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", () => { /* ... */ });
});

❶ 如果你已经处于历史的初始状态,则将自己从听 popstate 事件中分离出来并完成钩子

❷ 如果历史还没有处于初始状态,则通过调用 history.back 函数触发另一个 popstate 事件

❸ 使用clearHistory函数处理popstate事件

❹第一次调用clearHistory,导致历史倒带

您刚刚编写的测试的另一个问题是它将侦听器附加到全局窗口,并且在测试完成后不会将其删除。因为监听器没有被移除,所以每次 popstate 事件发生时它仍然会被触发,即使在测试完成之后也是如此。这些激活可能会导致其他测试失败,因为已完成测试的断言将再次运行。

要在每次测试后分离 popstate 事件的所有侦听器,我们必须监视窗口的 addEventListener 方法,以便我们可以检索测试期间添加的侦听器并将其删除,如图 6.7 所示。


图6-7

要查找和分离事件侦听器,请将以下代码添加到您的测试中。

// ...
 
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 来跟踪添加到窗口的每个监听器

❷ 查找 popstate 事件的所有监听器

❸ 从窗口中移除 popstate 事件的所有监听器

接下来,我们需要确保如果用户已经处于初始状态,handleUndo 不会调用 history.back。 在此测试中,您不能在执行断言之前等待 popstate 事件,因为如果 handleUndo 没有按预期调用 history.back,它将永远不会发生。 您也不能在调用 handleUndo 后立即编写断言,因为在您的断言运行时,history.back 可能已被调用但可能尚未完成。 为了充分执行这个断言,我们将监视 history.back 并断言它没有被调用——这是我们在第 3 章中讨论过的少数情况下否定断言足够的情况之一。

// ...
 
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);       ❶
    });
  });
});

❶ 这个断言并不关心 history.back 是否完成了历史堆栈的展开。 它只检查 history.back 是否已被调用。

您刚刚编写的测试仅涵盖 handleUndo 及其与 history.back 的交互。 在测试金字塔中,它们介于单元测试和集成测试之间。

现在,编写涵盖 handlePopstate 的测试,它也使用 handleAddItem。 此测试的范围更广,因此它在测试金字塔中的位置比前一个更高。

这些测试应该将状态推送到历史记录中,调用 handlePopstate,并检查应用程序是否充分更新了项目列表。 在这种情况下,您需要编写 DOM 断言,就像我们在上一节中所做的那样。

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();
    });
  });
});

❶ 将一个包含清单内容的新框架推入历史

❷ 调用 handlePopstate 以便应用程序使用当前历史框架中的状态更新自身

❸ 断言项目列表正好有两个项目

❹ 找到一个元素,表明库存中有 5 个芝士蛋糕,然后断言它在文档中

❺ 找到一个元素,表明库存中有 2 个胡萝卜蛋糕,然后断言它在文档中

注意如果您想完全隔离地测试 handlePopstate,您可以找到一种为 updateItemList 创建存根的方法,但是,正如我们之前讨论过的,您使用的测试替身越多,您的测试与运行时情况的相似度就越低,因此,它们变得越不可靠。

以下是运行您刚刚编写的测试时发生的情况,包括它的钩子:

最上面的 beforeEach 钩子将 initialHtml 分配给文档正文的 innerHTML。

此测试中的第一个 beforeEach 钩子会监视窗口的 addEventListener 方法,以便它可以跟踪将附加到它的所有侦听器。

此测试的 describe 块中的第二个 beforeEach 挂钩将浏览器的历史记录重置为其初始状态。它通过将一个事件侦听器附加到窗口来实现,该侦听器为每个 popstate 事件调用 history.back 直到状态为空。一旦历史被清除,它就会分离侦听器,从而清除历史。

测试本身运行。它将状态推送到历史记录,执行 handlePopstate,并检查页面是否包含预期元素。

测试的 afterEach 钩子运行。它使用 window.addEventListener.mock.calls 中的记录来发现响应窗口 popstate 事件的侦听器并分离它们。

作为练习,尝试编写一个测试来涵盖 handleAddItem 和 History API 之间的集成。创建一个调用 handleAddItem 的测试,并检查状态是否已使用添加到库存的项目进行更新。

现在您已经学习了如何测试 handleUndo 隔离和 handlePopstate 及其与 updateItemList 的集成,您将编写一个将所有内容组合在一起的端到端测试。这种端到端测试是您可以创建的最可靠的保证。它将像用户一样与应用程序交互,通过页面元素触发事件并检查 DOM 的最终状态。

要运行此端到端测试,您还需要清除全局历史堆栈。否则,可能导致历史更改的其他测试可能会导致它失败。为避免在多个测试之间复制和粘贴相同的代码,请使用清除历史记录的功能创建一个单独的文件,如下所示。

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 };

现在您已经将清除历史堆栈的函数移到了一个单独的文件中,您可以在钩子中导入和使用它,而不是每次都重写相同的内联函数。 例如,您可以返回 domController.test.js 并使用 clearHistoryHook 替换您在那里编写的冗长的内联钩子。

// ...
 
const { clearHistoryHook } = require("./testUtils");
 
// ...
 
describe("tests with history", () => {
   // ...
 
  beforeEach(clearHistoryHook);             ❶
 
  // ...
});

❶ 代替内联函数,使用单独的 clearHistoryHook 将历史重置为其初始状态

最后,将相同的钩子添加到 main.test.js,并编写一个测试,通过表单添加项目,单击撤消按钮,并检查列表的内容,就像用户一样。

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"));                             ❻
  });
});

❶ 填写项目名称的字段

❷ 填写项目数量的字段

❸ 提交表格

❹ 检查历史是否处于预期状态

❺ 当popstate事件发生时,检查item列表是否为空,并完成测试

❻ 通过点击 Undo 按钮触发 popstate 事件

正如之前发生的那样,这个测试在单独执行时总是会通过,但如果它与同一文件中的其他测试一起运行,触发 popstate 事件,它可能会导致它们失败。 发生此故障是因为它将带有断言的侦听器附加到窗口,即使在测试完成后,该侦听器仍会继续运行,就像以前一样。

如果您想看到它失败,请尝试在此测试之前添加一个也会触发 popstate 事件的测试。 例如,您可以编写一个新测试,将多个项目添加到清单中,并仅单击一次“撤消”按钮,如下所示。

// ...
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 个芝士蛋糕添加到库存中

❷ 再次提交表单,将5个胡萝卜蛋糕添加到库存中

❸ 当popstate事件发生时,检查item列表中是否包含你期望的元素并完成测试

❹ 通过单击 Undo 按钮触发 popstate 事件

当运行你的测试时,你会看到它们失败了,因为所有之前为窗口的 popstate 事件附加的处理程序都被执行了,不管之前的测试是否完成。

您可以使用与 domController.test.js 中的测试相同的方式解决此问题:通过跟踪对 window.addEventListener 的调用并在每次测试后分离处理程序。

因为您将重用在 domController.test.js 中编写的钩子,所以也将其移至 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 事件的所有监听器

❷ 分离所有 popstate 监听器

现在,您可以在 domController.test.js 中使用 detachPopstateHandlers 而不是编写内联函数。

const {
  clearHistoryHook,
  detachPopstateHandlers
} = require("./testUtils");
 
// ...
 
describe("tests with history", () => {
  beforeEach(() => jest.spyOn(window, "addEventListener"));      ❶
  afterEach(detachPopstateHandlers);                             ❷
 
  // ...
});

❶ 使用 spy 来跟踪添加到窗口的每个事件监听器

❷ 使用 detachPopstateHandlers,而不是使用内联函数来分离 popstate 事件的侦听器

在 main.test.js 中使用 detachPopstateHandlers 时,在每次测试后分离所有窗口的侦听器时必须小心,否则,main.js 附加的侦听器也可能被意外分离。 为避免删除main.js 附带的监听器,请确保在执行main.js 后才监听window.addEventListener,如图6.8 所示。


图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 被执行后,你才能监视 window.add-EventListener。否则, detachPopstateHandlers 也会分离 main.js 附加到页面的处理程序。

注意重要的是要注意这些测试具有高度重叠。

因为您已经为作为此功能一部分的单个功能和整个功能(包括与 DOM 的交互)编写了测试,所以您将有一些多余的检查。

根据您希望反馈的细化程度以及可用时间,您应该考虑只编写端到端测试,它提供了所有测试中最突出的覆盖范围。另一方面,如果您有时间,并且希望在编写代码时有一个更快的反馈循环,那么编写粒度测试也会很有用。

作为练习,尝试添加“重做”功能并为其编写测试。

现在您已经测试了与 localStorage 和 History API 的集成,您应该知道 JSDOM 负责在您的测试环境中模拟它们。感谢 Jest,JSDOM 存储在其实例的 window 属性中的这些值将通过全局命名空间可用于您的测试。您可以像在浏览器中一样使用它们,而无需存根。避免这些存根增加了测试创建的可靠性保证,因为它们的实现应该反映浏览器运行时发生的情况。

正如我们在本章中所做的那样,在测试您的前端应用程序时,请注意您的测试有多少重叠以及您想要实现的反馈粒度。考虑这些因素来决定应该编写哪些测试,不应该编写哪些测试,就像我们在前一章中讨论的那样。

你可能感兴趣的:(6.4 测试和浏览器 API)