使用 Jest 测试异步生命周期方法
最近在帮一位同事做项目的时候,我们误入了 Promise 的泥潭,慢慢偏离了异步编程的正轨,陷入了不断失败的 Promise 的泥潭,只能等待命运的安排。如果你有一些 JavaScript 异步代码编写经验,或许能理解我的意思。如果没有,别担心,我稍后会详细讲解,这样你也能了解 Promise 和 async/await,但更重要的是,当生命周期方法中使用异步代码时,如何编写单元测试。因为我们就是陷入了这个泥潭。不过,我们最终还是慢慢地走出了困境,我很乐意与你分享这次经历的收获。
在开始讲解实际代码之前,本文假设您已具备 React、Jest 和 Enzyme 的基础知识。我会解释一些基本概念,但不会涵盖全部内容,因为那将是一个相当大的领域。我已经在本地创建了一个演示,并将其上传到 GitHub,您可以在https://github.com/JHotterbeekx/demo-jest-testing-with-async-lifecycle找到它。
承诺
什么是 Promise?我说的不是你对配偶说的那些承诺,也不是那种你只要祈祷就能兑现却不必遵守的承诺。我说的是 JavaScript 中的 Promise。它们其实和你在现实生活中做出的承诺并没有太大区别:你做出承诺,它们可以被实现(我们称之为 resolve),也可以被拒绝(我们称之为 reject)。Promise 方法定义了一个它将要执行的操作,以及它将如何处理该操作的结果(包括失败和成功)。实际的操作现在还没有执行,但之后会执行。是不是有点晕了?让我们来看一些代码来解释一下。让我们看看控制台日志!
function add(x, y) {
return x + y
}
add(12, 13);
这段代码的作用是什么?它会在控制台输出数字 25。没什么特别的。我们把它转换成 Promise 对象。
function add(x, y) {
return new Promise((resolve) => resolve(x + y));
}
console.log(add(12, 13));
现在我们在控制台中看到什么?如果你速度很快,非常快,我是说像闪电侠那样快,或者拥有像《黑客帝国》里人物那样的时间减速能力,你会看到一条简短的日志,显示:
Promise {<pending>}
这意味着我们做出了一个承诺,它会执行某些操作,在本例中是将数字相加,但它尚未履行该承诺。现在,如果您等待一两微秒,该承诺就会生效。您会注意到日志发生了变化。变化?是的,发生了变化,因为它仍然是同一个承诺,所以所有操作都会在同一个控制台日志中显示。您现在将看到其他人几乎立即看到的内容:
Promise {<resolved>: 25}
这意味着 Promise 已完成,返回值为 25。但如果我们想记录这个值,该如何获取呢?别着急,我们稍后会介绍矩阵,或者直接处理 Promise 的结果。
function add(x, y) {
return new Promise((resolve) => resolve(x + y));
}
add(12, 13).then((sum) => console.log(sum));
你现在看到了什么?25?哇,这简直太神奇了!差不多,但还差一点,不过我保证,这已经是目前最好的结果了。你看到我们在这里做了什么改动吗?我们在 `add()` 之后添加了 `.then()` 部分。这样引擎就知道 Promise 解析后该如何处理结果,在这个例子中,就是将其记录到控制台。是不是很棒?!
但我感觉你心里有个问题,就像我们隔着互联网心有灵犀一样,你好像在问我:“能不能让它更棒?拜托了?!”。当然可以!看看这个:
function add(x, y) {
return new Promise((resolve) => resolve(x + y));
}
add(12, 13)
.then((sum) => {
console.log(sum)
return sum + 10;
})
.then((sum) => {
console.log(sum)
return sum + 7;
})
.then((sum) => {
console.log(sum)
})
你的控制台现在显示什么?会不会是这个问题?
25
35
42
我们找到了生命的意义!但这怎么可能呢?这就是所谓的 Promise 链式调用。这意味着,当你从 resolve 处理程序(也就是 then 内部的代码)返回一个结果时,你会生成另一个 resolve。而这个新的 resolve 又可以被处理,并生成另一个,如此循环往复。在这个例子中,我们不断地增加结果的值,这在日志中有所体现。
关于 Promise 就讲到这里了吗?远不止如此,但这足以构成本文的基础。如果您想了解更多,我推荐这篇文章:https://codeburst.io/javascript-learn-promises-f1eaa00c5461或者您也可以使用 Google 搜索,因为 Promise 已经存在一段时间了,您可以找到很多相关资料。接下来,我们将进入下一个阶段:async/await。
异步/等待
虽然 Promise 已经存在一段时间了,但大约两年前 ES2017 发布时,我们才真正收到了一份来自圣诞老人的新礼物。此前,关于使用 Promise、嵌套 Promise 和 Promise 链式调用时代码混乱的抱怨不绝于耳。有些人永远不知足,即使有了强大的异步代码功能,他们也会滥用,让代码再次变得混乱……但他们的抱怨不无道理,代码确实很快就会变得混乱。试想一下,如果要按特定顺序处理两三个 Promise,那该是多么可怕!这就是 async/await 的由来。让我们来看一个包含三个 Promise 的例子,其中最后一个例子我特意使用了同一个 Promise:
function add(x, y) {
return new Promise((resolve) => resolve(x + y));
}
add(12, 13)
.then((sum) => {
console.log(sum);
return add(sum, 10);
})
.then((sum) => {
console.log(sum);
return add(sum, 7);
})
.then((sum) => {
console.log(sum);
});
虽然结果令人惊艳,但代码读起来并不容易,对吧?让我们使用 await 看看会发生什么变化。
function add(x, y) {
return new Promise(resolve => resolve(x + y));
}
async function showMeaningOfLife() {
let sum = 0;
sum = await add(12, 13);
sum = await add(sum, 10);
sum = await add(sum, 7);
console.log(sum);
}
showMeaningOfLife();
这样简洁多了,对吧?虽然我们确实需要把异步调用封装在一个方法里才能使用 await,但所有的链式调用和嵌套都消失了,这使得代码更容易阅读。
基本上,async/await 并没有太多新意,它只是让 Promise 更简洁易读的一种方式。如果您想了解更多关于 async/await 的区别和优势,我推荐您阅读:https://codeburst.io/javascript-es-2017-learn-async-await-by-example-48acc58bad65。我们今天就先讲到这里,接下来该看看测试了!
实际应用案例
好的,假设我们有一个包含两个组件的应用程序。第一个组件是 DataDisplayer,它显示从 DataRetriever 获取的结果。但是,这个检索器是异步工作的,所以我们不能直接使用结果,必须正确处理。我在代码中添加了注释来解释我们的操作,现在我们来看看 DataDisplayer。
import React from "react";
import RetrieveData from "./DataRetriever";
export default class DataDisplayer extends React.Component {
constructor(props) {
super(props);
// We initialize the state with a property that contains a boolean telling us if data is
// available, which will be set to 'true' once the data is available. And a data
// property which will be filled with a title.
this.state = {
dataAvailable: false,
data: null
};
}
// We use the componentDidMount to trigger the retrieval of the data once the component is
// mounted. Which means the component first mounts with its default state and than triggers
// this method so data is retrieved. We make the method asynchronous so we are able to use
// await. This gives us a better readable and debuggable way to handle the promise received
// from RetrieveData().
async componentDidMount () {
// We call the retrieve method and wait for the promise to resolve, the result of this resolved
// promise will be the title, which is placed in the variable title. We validate if we indeed
// got a title before updating the state and marking the data as available.
const title = await RetrieveData();
if(title){
this.setState({
dataAvailable: true,
data: title
});
}
}
// This render method will initially render the text 'Data not available', because in the
// initial state the property dataAvailable is false. Once data is retrieved and the callback
// async code has resolved the state will update, which triggers a re-render, so the render
// is executed again. Now the dataAvailable will be true and the content in data will be shown.
render() {
if (!this.state.dataAvailable) return <div>Data not available</div>;
return (
<div>
Data value: <strong>{this.state.data}</strong>
</div>
);
}
}
好的,我们来看看页面的基本功能。它会渲染页面并显示“数据不可用”的信息,在组件挂载时会触发对数据检索器的调用。这是一个异步方法,所以我们选择使用 await 来等待结果。因为我们要使用 await,所以我们也需要将 componentDidMount 写成异步的。
现在,除了使用 await 之外,我们还可以直接处理 Promise。为此,componentDidMount 的内容将如下所示:
RetrieveData().then(title => {
if(title){
this.setState({
dataAvailable: true,
data: title
});
}
});
代码几乎相同,只是将处理逻辑嵌套在一个函数中,而该函数又嵌套在一个处理程序中。
我们来看看数据检索器,它可以异步地为我们检索数据。
// This demo method calls an open API, then translates the response to JSON. Once that is done
// it returns the 'title' property from this data. So when the API gives us { title: 'myTitle' },
// the code will return 'myTitle'. Since we want to use await to give us a readable way to handle
// the promises we encounter, we have to make the method itselfs asynchronous. Which results in
// the actual return value being a promise that resolves to the title.
export default async() => {
const todoData = await fetch("https://jsonplaceholder.typicode.com/todos/1");
const todoDataJson = await todoData.json();
return todoDataJson.title;
}
这段代码读起来很简单,对吧?我们调用一个 REST API,等待结果返回。我们将结果转换为 JSON 格式,等待转换完成。剩下的就是从中返回标题。我们也可以把它写成一个 Promise,那样代码看起来会像这样。
// This demo method calls an open API, then translates the response to JSON. Once that is done
// it calls the passed in callbackMethod with the title property as parameter. So when the API
// gives us { title: 'myTitle' }, the code will perform callbackMethod('myTitle')
export default () => {
return fetch("https://jsonplaceholder.typicode.com/todos/1")
.then(response => {
return response.json();
})
.then(responseJson => responseJson.title);
}
虽然仍然可读性尚可,但不如 await 语法简洁。不过现在两种方法你都看到了。是时候想想怎么测试一下了。
测试异步生命周期方法
无论我们使用 Promise 式的处理方式还是 async/await,componentDidMount 中执行的操作都是异步的。虽然我通常会使用 Enzyme 来挂载组件以触发生命周期方法,但在这种情况下行不通。为什么会这样呢?问得好!生命周期方法会在组件挂载时被触发,但由于代码是异步调用的,执行会继续进行,而不会等待 Promise 解析完成。因此,当你执行到 expect 调用时,Promise 仍在解析中,数据尚未被处理。
那么我们该如何等待这些承诺得到解决呢?其实并不难。我们一起来看看。首先,我们来搭建一个测试框架。
import React from "react";
import { shallow } from "enzyme";
import DataDisplayer from "./DataDisplayer";
// We want to test DataDisplayer in an isolated state, but DataDisplayer uses DataRetriever.
// To keep the isolation we will need to mock out the DataRetriever. This way we control
// what this component does and we can predict the outcome. To do this we need to do a manual
// mock, we can do this by importing the component we want to mock, and then defining a mock
// om that import.
import * as DataRetriever from "./DataRetriever";
DataRetriever.default = jest.fn();
describe("DataDisplayer", () => {
beforeEach(() => {
// Before each test we want to reset the state of the mocked component, so each test can
// mock the component in the way it needs to be mocked. Should you have any default mock
// needed that is required for every test, this is the place to do this.
DataRetriever.default.mockClear();
});
});
这样说清楚了吗?我们再来一遍。这是 DataDisplayer 的测试文件,它使用了 DataRetriever。我们像导入 DataDisplayer 一样,将 DataRetriever 导入到测试中。但是导入之后,我们会替换组件中默认的模拟方法导出。为了确保所有测试都能独立运行,避免受到其他测试模拟操作的影响,我们在每个测试之前都会清除模拟对象。但是我们现在能预测和控制模拟对象的行为吗?还不行,但我们已经准备好了实现这一目标的工具。现在让我们来编写第一个测试。
// In this test we will mock the DataRetriever in a way that it will create a promise for us
// which it will resolve with "fakeTitle" as argument. This simulates that the API has
// given us a result with { title: "fakeTitle" } in it. We make the test asynchronous, since
// we want to be able to use await in the code to wait for a promise to resolve.
it("Should show the data, When retrieved", async () => {
// We are going to set up the mock value that DataRetriever resolves to, we tell it when the
// code uses DataRetiever instead of actually calling it and fetching data from the API. It
// instantly resolves to a value 'fakeTitle'.
DataRetriever.default.mockResolvedValue('fakeTitle');
// We shallow mount the component through enzyme. This renders the component with a fake DOM
// making us able to see the result that would be rendered. We specifically use the shallow
// mount in this case. Not only is this the preferred render for unit tests, since it isolates
// the component completely when rendering, we also use it because we don't want to trigger
// the lifecycle methods. Since our lifecycle method handles code asynchronously, we want
// to be able to wait for that code to complete, this requires manually calling this method.
var wrapper = shallow(<DataDisplayer />);
// We need to get the actual instance from the virtual DOM, so we can call any method that
// is available on it.
const instance = wrapper.instance();
// Now we call the componentDidMount event, telling the component that it mounted. But because
// we called it manually we are able to await for it to resolve. This makes sure the promise
// for the method is completed before going on with the code.
await instance.componentDidMount();
// Since we fake a result coming back from the retriever, we expect the text to actually show
// the word "fakeTitle" in the component.
expect(wrapper.text()).toContain("fakeTitle");
});
看起来和普通的单元测试差不多吧?我们来一步步分析。首先,我们模拟 resolve 的值。这意味着任何对 DataRetriever 方法的调用都会返回一个 Promise,该 Promise 会解析为字符串 'fakeTitle'。接下来,我们对组件进行浅挂载。为什么要浅挂载呢?我们需要生命周期方法,对吧?没错,但我们希望手动调用它们,稍后你就会明白为什么了。下一行,我们从 Enzyme 获取组件实例,以便与之交互。这为下一行做好准备,而神奇之处就在这里。我们手动调用生命周期方法 componentDidMount,但使用了 await,这样它会等待 Promise 解析完成。很棒吧?现在我们知道状态已经达到了我们想要的位置,所以我们可以查看组件中是否渲染了 'fakeTitle'。
现在我们想测试另一种情况:如果 API 调用失败怎么办?或者它没有返回任何标题怎么办?让我们来试试。
// In this test we will mock the DataRetriever in a way that it will return a different promise
// which resolves without value. This simulates the API returning unexpected data.
// We make the test asynchronous, since we want to be able to use await in the code to wait
// for a promise to resolve.
it("Should show not available, When data has not been retrieved", async () => {
// We are going to set up the mock value that DataRetriever resolves to, we tell it when the
// code uses DataRetiever instead of actually calling it and fetching data from the API. It
// instantly resolves to an undefined value, so we can handle nothing coming back from the API.
DataRetriever.default.mockResolvedValue(undefined);
// We are shallow mounting the component again, using its instance, calling the
// componentDidMount and waiting for it to resolve. Only this time it will resolve to a value
// of undefined.
var wrapper = shallow(<DataDisplayer />);
const instance = wrapper.instance();
await instance.componentDidMount();
// Since we fake no result coming back from the retriever we don't expect any title appearing
// on the page, but instead we expect to see the text "not available"
expect(wrapper.text()).toContain("not available");
所以我们再次重复同样的步骤。我们准备模拟对象的 resolve 值,将组件浅显化,获取实例,然后手动触发 componentDidMount 并等待其完成。现在我们知道它没有返回任何值,所以我们仍然期望看到“不可用”的文本。这并不难,对吧?
资源
虽然网上关于 Jest、Enyme、异步和单元测试的资料很多,但找到这个解决方案却相当困难。我们尝试了很多复杂的方法,但都不太奏效。最终,我们在 Stack Overflow 上偶然发现了一个帖子,才找到了这个解决方案:https://stackoverflow.com/questions/51895198/jest-enzyme-mock-async-function-in-lifecycle-method。当然,你也可以查看本文提供的源代码示例应用,地址是:https://github.com/JHotterbeekx/demo-jest-testing-with-async-lifecycle。一个小提示:最终结果是使用 async/await 语法,但如果你查看历史记录,也可以找到 Promise 的实现。
总结
我们越来越多地采用异步方式,但这确实带来了一些额外的挑战。这是唯一的方法吗?当然不是。这是最佳方法吗?可能也不是。把你读到的东西转化成你自己的理解,然后尝试把你的解决方案教给别人。记住,阅读能让你学习,实践能让你学习,分享能让你牢记。
文章来源:https://dev.to/jhotterbeekx/testing-asynchronous-lifecycle-methods-with-jest-13jo