React 测试速成课程
项目即将完工,只剩下一个功能。你实现了最后一个功能,但系统的不同部分出现了 bug。你修复了这些 bug,但又出现了另一个。你开始玩打地鼠游戏,玩了好几轮之后,感觉自己已经搞砸了。不过,有一个解决方案,一个能让项目重现辉煌的救星:为未来功能和现有功能编写测试。这可以保证正常运行的功能不会出现 bug。
在本教程中,我将向您展示如何为 React 应用程序编写单元、集成和端到端测试。
有关更多测试示例,您可以查看我的React TodoMVC或React Hooks TodoMVC 实现。
1.类型
测试有三种类型:单元测试、集成测试和端到端测试。这些测试类型通常被可视化为金字塔。
金字塔表明,较低层的测试编写成本更低、运行速度更快、维护也更轻松。那么,为什么我们不只编写单元测试呢?因为较高层的测试能让我们更有信心地评估系统,并检查各个组件是否能够良好地协同工作。
总结一下测试类型之间的区别:单元测试仅单独处理单个代码单元(类、函数),集成测试检查多个单元是否按预期协同工作(组件层次结构、组件+存储),而端到端测试从外部世界(浏览器)观察应用程序。
2. 测试运行器
对于新项目,添加测试最简单的方法是使用Create React App工具。生成项目 ( npx create-react-app myapp
) 时,无需启用测试。单元/集成测试可以编写在src
以*.spec.js
或 为*.test.js
后缀的目录中。Create React App 使用Jest测试框架来运行这些文件。Jest 不仅仅是一个测试运行器,它还包含一个与Mocha不同的断言库。
3. 单体
到目前为止一切顺利,但我们还没有编写任何测试。让我们编写第一个单元测试!
describe('toUpperCase', () => {
it('should convert string to upper case', () => {
// Arrange
const toUpperCase = info => info.toUpperCase();
// Act
const result = toUpperCase('Click to modify');
// Assert
expect(result).toEqual('CLICK TO MODIFY');
});
});
上面是一个例子,验证toUpperCase
函数是否将给定的字符串转换为大写。
第一个任务(arrange)是将目标(这里是一个函数)置于可测试状态。这意味着导入函数、实例化对象并设置其参数。第二个任务是执行该函数/方法(act)。函数返回结果后,我们会对结果进行断言。
Jest 提供了两个函数:describe
和it
。使用describe
函数,我们可以围绕单元组织测试用例:单元可以是类、函数、组件等。 函数it
代表编写实际的测试用例。
Jest 内置了一个断言库,我们可以用它来设定对结果的期望。Jest 内置了许多不同的断言。然而,这些断言并不能覆盖所有用例。这些缺失的断言可以通过 Jest 的插件系统导入,从而为库添加新的断言类型(例如Jest Extended和Jest DOM)。
大多数时候,您将为组件层次结构之外的业务逻辑编写单元测试,例如状态管理或后端 API 处理。
4. 组件展示
下一步是为组件编写集成测试。为什么是集成测试?因为我们不再只测试 JavaScript 代码,而是测试 DOM 与相应组件逻辑之间的交互。
在组件示例中,我将使用Hooks,但如果您使用旧语法编写组件,则不会影响测试,它们是相同的。
import React, { useState } from 'react';
export function Footer() {
const [info, setInfo] = useState('Click to modify');
const modify = () => setInfo('Modified by click');
return (
<div>
<p className="info" data-testid="info">{info}</p>
<button onClick={modify} data-testid="button">Modify</button>
</div>
);
}
我们测试的第一个组件是显示其状态并在我们单击按钮时修改状态的组件。
import React from 'react';
import { render } from '@testing-library/react';
import { Footer } from './Footer.js';
describe('Footer', () => {
it('should render component', () => {
const { getByTestId } = render(<Footer />);
const element = getByTestId('info');
expect(element).toHaveTextContent('Click to modify');
expect(element).toContainHTML('<p class="info" data-testid="info">Click to modify</p>');
expect(element).toHaveClass('info');
expect(element).toBeInstanceOf(HTMLParagraphElement);
});
});
要在测试中渲染组件,我们可以使用推荐的React 测试库 render
方法。该render
函数需要一个有效的JSX元素进行渲染。返回参数是一个对象,其中包含渲染 HTML 的选择器。在示例中,我们使用了getByTestId
通过属性检索 HTML 元素的方法data-testid
。它还有更多 getter 和查询方法,您可以在文档中找到它们。
在断言中,我们可以使用Jest Dom 插件中的方法,该插件扩展了 Jests 的默认断言集合,使 HTML 测试更加容易。所有 HTML 断言方法都以 HTML 节点作为输入,并访问其原生属性。
5. 组件交互
我们已经测试了在 DOM 中可以看到的内容,但尚未与组件进行任何交互。我们可以通过 DOM 与组件交互,并通过其内容观察变化。我们可以触发按钮上的点击事件并观察显示的文本。
import { render, fireEvent } from '@testing-library/react';
it('should modify the text after clicking the button', () => {
const { getByTestId } = render(<Footer />);
const button = getByTestId('button');
fireEvent.click(button);
const info = getByTestId('info');
expect(info).toHaveTextContent('Modified by click');
});
我们需要一个可以触发事件的 DOM 元素。该方法返回的 getterrender
会返回该元素。该fireEvent
对象可以通过其在元素上的方法触发所需的事件。我们可以像之前一样通过观察文本内容来检查事件的结果。
6. 亲子互动
我们已经单独研究了一个组件,但实际的应用程序由多个部分组成。父组件通过 与其子组件通信props
,子组件也通过 函数 与其父组件通信props
。
让我们修改它接收显示文本的组件props
,并通过函数通知父组件有关修改的信息prop
。
import React from 'react';
export function Footer({ info, onModify }) {
const modify = () => onModify('Modified by click');
return (
<div>
<p className="info" data-testid="info">{info}</p>
<button onClick={modify} data-testid="button">Modify</button>
</div>
);
}
在测试中,我们必须提供props
作为输入并检查组件是否调用onModify
函数 prop。
it('should handle interactions', () => {
const info = 'Click to modify';
let callArgument = null;
const onModify = arg => callArgument = arg;
const { getByTestId } = render(<Footer info={info} onModify={onModify} />);
const button = getByTestId('button');
fireEvent.click(button);
expect(callArgument).toEqual('Modified by click');
});
info
我们通过 JSX 将prop 和函数 prop传递onModify
给组件。当触发按钮的点击事件时,该onModify
方法会被调用,并callArgument
用其参数修改变量。最后的断言检查callArgument
它是否被子组件的函数 prop 修改。
7.商店整合
在前面的示例中,状态始终位于组件内部。在复杂的应用程序中,我们需要在不同位置访问和修改相同的状态。Redux是一个可以轻松连接到 React 的状态管理库,它可以帮助您将状态管理集中到一处,并确保其可预测地进行修改。
import { createStore } from 'redux';
function info(state, action) {
switch (action.type) {
case 'MODIFY':
return action.payload;
default:
return state;
}
}
const onModify = info => ({ type: 'MODIFY', payload: info });
const store = createStore(info, 'Click to modify');
Store 只有一个状态,与我们在组件上看到的一样。我们可以通过onModify
将输入参数传递给 Reducer 并修改状态的操作来修改状态。
让我们构建存储并编写集成测试。这样,我们可以检查各个方法是否能够协同工作,而不是抛出错误。
it('should modify state', () => {
store.dispatch(onModify('Modified by click'));
expect(store.getState()).toEqual('Modified by click');
});
我们可以通过该方法修改 store dispatch
。该方法的参数应该是一个带有type
属性 和 的动作payload
。我们始终可以通过该方法检查当前状态getState
。
当将商店与组件一起使用时,我们必须将商店实例作为提供程序传递给render
函数。
const { getByTestId } = render(
<Provider store={store}>
<Header />
</Provider>
);
8. 路由
展示如何在 React 应用程序内测试路由的最简单方法是创建一个显示当前路由的组件。
import React from 'react';
import { withRouter } from 'react-router';
import { Route, Switch } from 'react-router-dom';
const Footer = withRouter(({ location }) => (
<div data-testid="location-display">{location.pathname}</div>
));
const App = () => {
return (
<div>
<Switch>
<Route component={Footer} />
</Switch>
</div>
)
};
组件Footer
被方法包裹,该方法会为组件withRouter
添加附加功能。我们需要另一个组件( )来包裹并定义路由。在测试中,我们可以断言元素的内容。props
App
Footer
Footer
import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history';
import { render } from '@testing-library/react';
describe('Routing', () => {
it('should display route', () => {
const history = createMemoryHistory();
history.push('/modify');
const { getByTestId } = render(
<Router history={history}>
<App/>
</Router>
);
expect(getByTestId('location-display')).toHaveTextContent('/modify');
});
});
我们通过不在元素上定义路径,将组件添加为一个“捕获所有”的路由Route
。在测试中,不建议修改浏览器的 History API,相反,我们可以创建一个内存实现,并将其与组件history
的 prop一起传递Router
。
9. HTTP 请求
初始状态的突变通常发生在 HTTP 请求之后。虽然在测试中让该请求到达目的地很诱人,但这也会使测试变得脆弱,并且依赖于外部世界。为了避免这种情况,我们可以在运行时更改请求的实现,这称为模拟。我们将使用 Jest 的内置模拟功能来实现这一点。
const onModify = async ({ commit }, info) => {
const response = await axios.post('https://example.com/api', { info });
commit('modify', { info: response.body });
};
我们有一个函数:输入参数首先通过 POST 请求发送,然后将结果传递给该commit
方法。代码变为异步的,并将Axios作为外部依赖项。在运行测试之前,我们需要更改(模拟)该外部依赖项。
it('should set info coming from endpoint', async () => {
const commit = jest.fn();
jest.spyOn(axios, 'post').mockImplementation(() => ({
body: 'Modified by post'
}));
await onModify({ commit }, 'Modified by click');
expect(commit).toHaveBeenCalledWith('modify', { info: 'Modified by post' });
});
我们正在为 创建一个伪实现,commit
并使用jest.fn
并更改 的原始实现axios.post
。这些伪实现会捕获传递给它们的参数,并响应我们指定的返回值(mockImplementation
)。commit
由于我们没有指定值,该方法将返回一个空值。axios.post
将返回一个Promise
解析为具有 body 属性的对象。
async
通过在测试函数前面添加修饰符,该函数变为异步函数:Jest 可以检测并等待异步函数完成。在函数内部,我们等待该onModify
方法完成await
,然后断言是否commit
使用 post 调用返回的参数调用了伪造方法。
10.浏览器
从代码角度来看,我们已经触及了应用程序的各个方面。但还有一个问题我们仍然无法回答:该应用程序能在浏览器中运行吗?使用Cypress编写的端到端测试可以回答这个问题。
Create React App 没有内置的端到端测试解决方案,我们必须手动进行协调:启动应用程序并在浏览器中运行 Cypress 测试,然后关闭应用程序。这意味着需要安装 Cypress 来运行测试,并安装start-server-and-test库来启动服务器。如果要以无头模式运行 Cypress 测试,则必须在命令中添加 --headless 标志。
describe('New todo', () => {
it('it should change info', () => {
cy.visit('/');
cy.contains('.info', 'Click to modify');
cy.get('button').click();
cy.contains('.info', 'Modified by click');
});
});
测试的组织方式与单元测试相同:describe
代表分组,it
代表运行测试。我们有一个全局变量,cy
代表 Cypress 运行器。我们可以同步命令运行器在浏览器中执行操作。
访问主页 ( visit
) 后,我们可以通过 CSS 选择器访问显示的 HTML。我们可以使用 contains 断言元素的内容。交互的工作方式相同:首先,选择元素 ( get
),然后进行交互 ( click
)。在测试结束时,我们检查内容是否已更改。
概括
我们已经完成了测试用例的讲解。希望您喜欢这些示例,它们阐明了许多与测试相关的知识。我希望降低开始编写 React 应用程序测试的门槛。我们已经从函数的基本单元测试过渡到在真实浏览器中运行的端到端测试。
在我们的过程中,我们为 React 应用程序的构建块(组件、store、路由器)创建了集成测试,并初步掌握了实现模拟。借助这些技术,您现有和未来的项目可以保持零错误。
文章来源:https://dev.to/emarsys/react-testing-crash-course-ccl