前言
本文承接上文 如何测试驱动开发 React 组件?,这次我将继续使用 @testing-library/react 来测试我们的 React 应用,并简要简要说明如何测试异步组件。
异步组件的测试内容
我们知道异步请求主要用于从服务器上获取数据,这个异步请求可能是主动触发的,也可能是(鼠标)事件响应,本文主要包含 2 方面内容:
- 如何测试在
componentDidMount
生命周期中发出的异步请求 ? - 如何测试(鼠标)事件发出的异步请求 ?
对于异步组件,有两件步骤需要进行测试:
第一:测试异步方法本身有没有被调用,并且传了正确的参数。
第二:在调用之后,应用程序应该做出响应。
一起来看看代码中该如何实现?
假设你有一个用 React 编写的小型博客应用程序。有一个登录页面,还有有一个文章列表页面,内容就跟我的博客一样。
登录测试
先来实现登录页,先脑补一个效果图吧
我们先来写下测试用例
- 界面包含账号和密码输入框
- 接口请求包含 username 和 password
- 防止登录重复点击
- 登录成功跳转页面
- 登录失败显示错误信息
测试渲染
代码未动,测试先行,先确保我们的组件可以渲染。
import React from "react";
import { render } from "@testing-library/react";
import Login from "./index";
describe("Login component", () => {
it("should render", () => {
const { getByText } = render(<Login onSubmit={());
expect(getByText(/账号/)).toBeInTheDocument();
expect(getByText(/密码/)).toBeInTheDocument();
});
});
登录组件实现
为了保证是一个纯组件,将提交方法onSubmit
作为一个 props
传入,接下来我们实现下组件代码
import React from "react";
function Login({ onSubmit }) {
function handleSubmit(event) {
event.preventDefault();
const { username, password } = event.target.elements;
onSubmit({
username: username.value,
password: password.value
});
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username-field">账号:</label>
<input id="username-field" name="username" type="text"
</div>
<div>
<label htmlFor="password-field">密码:</label>
<input id="password-field" name="password" type="password"
</div>
<div>
<button type="submit">登录</button>
</div>
</form>
);
}
export default Login;
为了方便理解我们这边没有使用其他三方库,若在生产环境中,我推荐使用 react-hook-form
测试提交
接下来测试下 onSubmit
方法必须包含 username
和 password
,
我们需要模拟用户输入,这个时候我们需要安装 @test-library/user-event
import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Login from "./index";
test("onSubmit calls with username and password", () => {
let submittedData;
const handleSubmit = data => (submittedData = data);
render(
<Login onSubmit={handleSubmit}
);
const username = "user";
const password = "password";
userEvent.type(screen.getByPlaceholderText(/username/i), username);
userEvent.type(screen.getByPlaceholderText(/password/i), password);
userEvent.click(screen.getByRole("button", { name: /登录/ }));
expect(submittedData).toEqual({
username,
password
});
});
我们可以选用 @testing-library
的 get*By*
函数获取dom 中的元素, 这里使用 getByPlaceholderText
以上测试用例只测试了登录函数,但是我们并未写登录成功或者失败的逻辑,接下来来我们通过 jest 的 mock 函数功能来模拟登录。
测试登录成功
由于测试登录成功的例子已经包含了"测试提交"和"测试渲染"的功能,所以,可以将前面2个单元测试删除。登录后,按钮改成 loading 状态 disabled。
const fakeData = {
username: "username",
password: "username",
};
test("onSubmit success", async () => {
// mock 登录函数
const login = jest.fn().mockResolvedValueOnce({ data: { success: true } });
render(<Login onSubmit={login});
userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username);
userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password);
userEvent.click(screen.getByRole("button", { name: /登录/ }));
//登录后,按钮改成 loading 状态 disabled
const button = screen.getByRole("button", { name: /loading/ });
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData));
expect(login).toHaveBeenCalledTimes(1);
// 文档中没有 loading 按钮
expect(
screen.queryByRole("button", { name: "loading" })
).not.toBeInTheDocument();
});
接下来我们修改组件, 顺便把登录失败的逻辑也写了,登录失败在登录框下显示服务端返回信息。
import React, { useState } from "react";
function Login({ onSubmit }) {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
function handleSubmit(event) {
event.preventDefault();
const { username, password } = event.target.elements;
setLoading(true);
onSubmit({
username: username.value,
password: password.value,
})
.then((res) => {
setLoading(false);
})
.catch((res) => {
setLoading(false);
setMessage(res.data.message);
});
}
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="username-field">账号:</label>
<input
id="username-field"
name="username"
placeholder="username"
type="text"
</div>
<div>
<label htmlFor="password-field">密码:</label>
<input
id="password-field"
name="password"
placeholder="password"
type="password"
</div>
<div>
<button disabled={loading} type="submit">
{loading ? "loading" : "登录"}
</button>
</div>
<div>{message}</div>
</form>
);
}
export default Login;
测试登录失败
我们直接复制成功的测试用例,修改失败的逻辑。测试用例:
- 失败后文档中显示服务端的消息
- 失败后按钮又显示登录并且可以点击
test("onSubmit failures", async () => {
const message = "账号或密码错误!";
// mock 登录函数失败
const login = jest.fn().mockRejectedValueOnce({
data: { message },
});
render(<Login onSubmit={login});
userEvent.type(screen.getByPlaceholderText(/username/i), fakeData.username);
userEvent.type(screen.getByPlaceholderText(/password/i), fakeData.password);
userEvent.click(screen.getByRole("button", { name: /登录/ }));
const button = screen.getByRole("button", { name: /loading/ });
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
await waitFor(() => expect(login).toHaveBeenCalledWith(fakeData));
expect(login).toHaveBeenCalledTimes(1);
// 确保文档中有服务端返回的消息
expect(screen.getByText(message)).toBeInTheDocument();
// 文档中没有 loading 按钮
expect(
screen.queryByRole("button", { name: "loading" })
).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /登录/ })).not.toBeDisabled();
});
博客列表测试
相信经过登录的测试,我们在来写博客列表的测试已经不难了,我们先来写下测试用例:
- 接口请求中页面显示 loading
- 请求成功显示博客列表
- 列表为空显示暂无数据
- 请求失败显示服务端错误
博客列表代码
下面的代码中, 使用了 react-use
,首先我们先要安装这个包
import React from "react";
import { fetchPosts } from "./api/posts";
import { useAsync } from "react-use";
export default function Posts() {
const posts = useAsync(fetchPosts, []);
if (posts.loading) return "Loading...";
if (posts.error) return "服务端错误";
if (posts.value && posts.value.length===0) return "暂无数据";
return (
<>
<h1>My Posts</h1>
<ul>
{posts.value.map((post) => (
<li key={post.id}>
<a href={post.url}>{post.title}</a>
</li>
))}
</ul>
</>
当然我们也可以写 class 的实现方式,代码如下:
import React from "react";
import { fetchPosts } from "./api/posts";
export default class Posts extends React.Component {
state = { loading: true, error: null, posts: null };
async componentDidMount() {
try {
this.setState({ loading: true });
const posts = await fetchPosts();
this.setState({ loading: false, posts });
} catch (error) {
this.setState({ loading: false, error });
}
}
render() {
if (this.state.loading) return "Loading...";
if (this.state.error) return "服务端错误";
if (this.state.posts && this.state.posts.length===0) return "暂无数据";
return (
<>
<h1>My Posts</h1>
<ul>
{this.state.posts.map((post) => (
<li key={post.id}>
<a href={post.url}>{post.title}</a>
</li>
))}
</ul>
</>
Mock 接口
jest.mock("./api/posts");
我们可以在官方文档中阅读关于 jest.mock 的更多信息。
它所做的就是告诉 Jest 替换/api/posts
模块。
现在我们已经有了 mock,让我们来渲染组件,并且界面显示 loading:
import React from "react";
import { render, screen } from "@testing-library/react";
import Post from "./index";
jest.mock("./api/posts");
test("should show loading", () => {
render(<Posts);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
这是第一步,现在我们需要确保我们的 fetchPosts
方法被正确调用:
import React from "react";
import { render, screen } from "@testing-library/react";
import Posts from "./index";
import { fetchPosts } from "./api/posts";
jest.mock("./api/posts");
test("should show a list of posts", () => {
render(<Posts);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(fetchPosts).toHaveBeenCalledTimes(1);
expect(fetchPosts).toHaveBeenCalledWith();
});
通过 toHaveBeenCalledTimes
测试调用次数,通过 toHaveBeenCalledWith
测试调用方法的参数,虽然这边是空数据,但是我们也可以写,确保调用参数是空。
此时我们的测试代码会报错,因为我们没有 Mock fetchPosts
方法
import React from "react";
import { render, screen, wait } from "@testing-library/react";
import Posts from "./index";
import { fetchPosts } from "./api/posts";
jest.mock("./api/posts");
test("should show a list of posts", async () => {
// mock 接口
const posts = [{ id: 1, title: "My post", url: "/1" }];
fetchPosts.mockResolvedValueOnce(posts);
render(<Posts);
expect(screen.getByText("Loading...")).toBeInTheDocument();
expect(fetchPosts).toHaveBeenCalledWith();
expect(fetchPosts).toHaveBeenCalledTimes(1);
//等待标题渲染
await waitFor(() => screen.findByText("My Posts"));
posts.forEach((post) =>
expect(screen.getByText(post.title)).toBeInTheDocument()
);
});
我们使用 mockResolvedValueOnce
返回一些假数据。然后,我们等待异步方法解析并等待 Posts 组件重新渲染。为此,我们使用 waitFor
方法,同时检查标题是否呈现,之后遍历检查,确保每一个标题在页面上。
测试接口错误
接下来我们要测试错误是否被正确呈现,那么只需要修改 mock:
test("should show an error message on failures", async () => {
fetchPosts.mockRejectedValueOnce("Error!");
render(<Posts);
expect(screen.getByText("Loading...")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("服务端错误")).toBeInTheDocument();
});
expect(fetchPosts).toHaveBeenCalledWith();
expect(fetchPosts).toHaveBeenCalledTimes(1);
});
小结
以下是测试异步组件的步骤:
- 通过 mock 使组件可以获取静态假数据;
- 测试加载状态;
- 测试异步方法是否被正确调用,并且带上了正确的参数;
- 测试组件是否正确地渲染了数据
- 测试异步方法错误时,组件是是否渲染了正确的状态
文中关于登录成功后页面跳转并未测试,那么如何测试 react 路由 ?请关注我,我会尽快出 React test 系列的下文。
希望这篇文章对大家有所帮助,也可以参考我往期的文章或者在评论区交流你的想法和心得,欢迎一起探索前端。
标签:username,异步,screen,posts,TDD,React,expect,import,password From: https://blog.51cto.com/u_15757429/5734861