React 测试驱动开发:从用户故事到生产

已发表: 2022-03-11

在这篇文章中,我们将使用从用户故事到开发的测试驱动开发 (TDD) 来开发一个 React 应用程序。 此外,我们将在 TDD 中使用 Jest 和 Enzyme。 完成本指南后,您将能够:

  • 根据需求创建史诗和用户故事。
  • 根据用户故事创建测试。
  • 使用 TDD 开发 React 应用程序。
  • 使用 Enzyme 和 Jest 测试一个 React 应用程序。
  • 使用/重用 CSS 变量进行响应式设计。
  • 创建一个可重用的 React 组件,该组件根据提供的 props 以不同的方式呈现和运行。
  • 使用 React PropTypes 类型检查组件道具。

本文假设您具有 React 的基本知识。 如果您是 React 的新手,我建议您完成官方教程并查看 Toptal 的 2019 React 教程:第 1 部分和第 2 部分。

我们的测试驱动的 React 应用程序概述

我们将构建一个包含一些 UI 组件的基本番茄钟应用程序。 每个组件在相应的测试文件中都有一组单独的测试。 首先,我们可以根据我们的项目需求创建如下的史诗和用户故事。

史诗用户的故事验收标准
作为用户,我需要使用计时器来管理我的时间。 作为用户,我需要启动计时器以便倒计时。 确保用户能够:

*启动计时器
*看计时器开始倒计时

即使用户多次单击开始按钮,倒计时也不应中断。
作为用户,我需要停止计时器,以便仅在需要时才可以倒计时。 确保用户能够:

*停止计时器
*看到计时器停止

即使用户多次单击停止按钮,也不会发生任何事情。
作为用户,我需要重置计时器,以便从头开始倒计时。 确保用户能够:

*重置计时器
*见定时器重置为默认值

线框

线框

项目设置

首先,我们将使用Create React App创建一个 React 项目,如下所示:

 $ npx create-react-app react-timer $ cd react-timer $ npm start

您将看到在 URL http://localhost:3000 处打开一个新的浏览器选项卡。 您可以使用Ctrl+C停止正在运行的 React 应用程序。

现在,我们将添加 Jest 和 Enzyme 以及一些依赖项,如下所示:

 $ npm i -D enzyme $ npm i -D react-test-renderer enzyme-adapter-react-16

此外,我们将在src目录中添加或更新一个名为setupTests.js的文件:

 import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() });

由于Create React App在每次测试之前都会运行setupTests.js文件,因此它将执行并正确配置 Enzyme。

配置 CSS

我们将编写变量和基本的 CSS 重置,因为我们希望 CSS 变量在应用程序中全局可用。 我们将从 :root 范围定义变量。 定义变量的语法是使用自定义属性表示法,每个都以 - 开头,后跟变量名。

导航到index.css文件并添加以下内容:

 :root { --main-font: “Roboto”, sans-serif; } body, div, p { margin: 0; padding: 0; }

现在,我们需要将 CSS 导入我们的应用程序。 更新 index.js 文件如下:

 import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode> document.getElementById(“root”) )

浅渲染测试

您可能已经知道,TDD 流程如下所示:

  1. 添加一个测试。
  2. 运行所有测试,您将看到测试失败。
  3. 编写代码以通过测试。
  4. 运行所有测试。
  5. 重构。
  6. 重复。

因此,我们将为浅渲染测试添加第一个测试,然后编写代码以通过测试。 将一个名为App.spec.js的新规范文件添加到src/components/App目录,如下所示:

 import React from 'react'; import { shallow } from 'enzyme'; import App from './App'; describe('App', () => { it('should render a <div />', () => { const container = shallow(<App />); expect(container.find('div').length).toEqual(1); }); });

然后,您可以运行测试:

 $ npm test

您将看到测试失败。

应用组件

现在,我们将继续创建 App 组件以通过测试。 导航到src/components/App目录下的App.jsx并添加如下代码:

 import React from 'react'; const App = () => <div className=”app-container” />; export default App;

现在,再次运行测试。

 $ npm test

第一个测试现在应该通过了。

添加应用 CSS

我们将在目录src/components/App中创建一个文件App.css来为 App 组件添加一些样式,如下所示:

 .app-container { height: 100vh; width: 100vw; align-items: center; display: flex; justify-content: center; }

现在,我们准备将 CSS 导入App.jsx文件:

 import React from 'react'; import './App.css'; const App = () => <div className=”app-container” />; export default App;

接下来,我们必须更新index.js文件以导入 App 组件,如下所示:

 import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./components/App/App" import * as serviceWorker from "./serviceWorker" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") ) // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister()

添加定时器组件

最后,应用程序将包含 Timer 组件,因此我们将更新App.spec.js文件以检查应用程序中是否存在 Timer 组件。 此外,我们将在第一个测试用例之外声明容器变量,因为需要在每个测试用例之前完成浅渲染测试。

 import React from "react" import { shallow } from "enzyme" import App from "./App" import Timer from "../Timer/Timer" describe("App", () => { let container beforeEach(() => (container = shallow(<App />))) it("should render a <div />", () => { expect(container.find("div").length).toEqual(1) }) it("should render the Timer Component", () => { expect(container.containsMatchingElement(<Timer />)).toEqual(true) }) })

如果您在此阶段运行npm test ,则测试将失败,因为 Timer 组件尚不存在。

编写定时器浅层渲染测试

现在,我们将在src/components目录下名为Timer的新目录中创建一个名为Timer.spec.js的文件。

此外,我们将在Timer.spec.js文件中添加浅渲染测试:

 import React from "react" import { shallow } from "enzyme" import Timer from "./Timer" describe("Timer", () => { let container beforeEach(() => (container = shallow(<Timer />))) it("should render a <div />", () => { expect(container.find("div").length).toBeGreaterThanOrEqual(1) }) })

正如预期的那样,测试将失败。

创建定时器组件

接下来,让我们创建一个名为Timer.jsx的新文件,并根据用户故事定义相同的变量和方法:

 import React, { Component } from 'react'; class Timer extends Component { constructor(props) { super(props); this.state = { minutes: 25, seconds: 0, isOn: false }; } startTimer() { console.log('Starting timer.'); } stopTimer() { console.log('Stopping timer.'); } resetTimer() { console.log('Resetting timer.'); } render = () => { return <div className="timer-container" />; }; } export default Timer;

这应该可以通过测试,并且应该Timer.spec.js文件中渲染一个<div /> ,但是测试不应该渲染 Timer 组件,因为我们还没有在 app 组件中添加 Timer 组件。

我们将在App.jsx文件中添加 Timer 组件,如下所示:

 import React from 'react'; import './App.css'; import Timer from '../Timer/Timer'; const App = () => ( <div className="app-container"> <Timer /> </div> ); export default App;

现在所有的测试都应该通过了。

添加计时器 CSS

我们将添加与计时器相关的 CSS 变量,并为较小的设备添加媒体查询。

更新文件index.css如下:

 :root { --timer-background-color: #FFFFFF; --timer-border: 1px solid #000000; --timer-height: 70%; --timer-width: 70%; } body, div, p { margin: 0; padding: 0; } @media screen and (max-width: 1024px) { :root { --timer-height: 100%; --timer-width: 100%; } }

此外,我们将在components/Timer目录下创建Timer.css文件:

 .timer-container { background-color: var(--timer-background-color); border: var(--timer-border); height: var(--timer-height); width: var(--timer-width); }

我们必须更新Timer.jsx以导入Timer.css文件。

 import React, { Component } from "react" import "./Timer.css"

如果您现在运行 React 应用程序,您将在浏览器上看到一个带有边框的简单屏幕。

编写 TimerButton 浅层渲染测试

我们需要三个按钮:开始、停止重置,因此我们将创建TimerButton 组件

首先,我们需要更新Timer.spec.js文件以检查Timer组件中是否存在TimerButton组件:

 it("should render instances of the TimerButton component", () => { expect(container.find("TimerButton").length).toEqual(3) })

现在,让我们将TimerButton.spec.js文件添加到src/components目录下名为TimerButton的新目录中,并将测试添加到文件中,如下所示:

 import React from "react" import { shallow } from "enzyme" import TimerButton from "./TimerButton" describe("TimerButton", () => { let container beforeEach(() => { container = shallow( <TimerButton buttonAction={jest.fn()} buttonValue={""} /> ) }) it("should render a <div />", () => { expect(container.find("div").length).toBeGreaterThanOrEqual(1) }) })

现在,如果您运行测试,您将看到测试失败。

让我们为TimerButton组件创建TimerButton.jsx文件:

 import React from 'react'; import PropTypes from 'prop-types'; const TimerButton = ({ buttonAction, buttonValue }) => ( <div className="button-container" /> ); TimerButton.propTypes = { buttonAction: PropTypes.func.isRequired, buttonValue: PropTypes.string.isRequired, }; export default TimerButton;

如果你在这个阶段运行npm test ,测试应该会渲染 TimerButton 组件的实例,但是会失败,因为我们还没有将 TimerButton 组件添加到 Timer 组件中。

让我们导入TimerButton组件,并在Timer.jsx的 render 方法中添加三个TimerButton组件:

 render = () => { return ( <div className="timer-container"> <div className="time-display"></div> <div className="timer-button-container"> <TimerButton buttonAction={this.startTimer} buttonValue={'Start'} /> <TimerButton buttonAction={this.stopTimer} buttonValue={'Stop'} /> <TimerButton buttonAction={this.resetTimer} buttonValue={'Reset'} /> </div> </div> ); };

定时器按钮 CSS

现在,是时候为 TimerButton 组件添加 CSS 变量了。 让我们将:root范围内的变量添加到index.css文件中:

 :root { ... --button-border: 3px solid #000000; --button-text-size: 2em; } @media screen and (max-width: 1024px) { :root { … --button-text-size: 4em; } }

另外,让我们在src/components目录下的TimerButton目录中创建一个名为TimerButton.css的文件:

 .button-container { flex: 1 1 auto; text-align: center; margin: 0px 20px; border: var(--button-border); font-size: var(--button-text-size); } .button-container:hover { cursor: pointer; }

让我们相应地更新TimerButton.jsx以导入TimerButton.css文件并显示按钮值:

 import React from 'react'; import PropTypes from 'prop-types'; import './TimerButton.css'; const TimerButton = ({ buttonAction, buttonValue }) => ( <div className="button-container"> <p className="button-value">{buttonValue}</p> </div> ); TimerButton.propTypes = { buttonAction: PropTypes.func.isRequired, buttonValue: PropTypes.string.isRequired, }; export default TimerButton;

此外,我们需要更新Timer.css以使三个按钮水平对齐,所以让我们也更新Timer.css文件:

 import React from 'react'; import PropTypes from 'prop-types'; import './TimerButton.css'; const TimerButton = ({ buttonAction, buttonValue }) => ( <div className="button-container"> <p className="button-value">{buttonValue}</p> </div> ); TimerButton.propTypes = { buttonAction: PropTypes.func.isRequired, buttonValue: PropTypes.string.isRequired, }; export default TimerButton;

如果您现在运行 React 应用程序,您将看到如下屏幕:

定时器

重构定时器

我们将重构 Timer,因为我们要实现startTimer、stopTimer、restartTimerresetTimer等功能。 我们先更新Timer.spec.js文件:

 describe('mounted Timer', () => { let container; beforeEach(() => (container = mount(<Timer />))); it('invokes startTimer when the start button is clicked', () => { const spy = jest.spyOn(container.instance(), 'startTimer'); container.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); container.find('.start-timer').first().simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); it('invokes stopTimer when the stop button is clicked', () => { const spy = jest.spyOn(container.instance(), 'stopTimer'); container.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); container.find('.stop-timer').first().simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); it('invokes resetTimer when the reset button is clicked', () => { const spy = jest.spyOn(container.instance(), 'resetTimer'); container.instance().forceUpdate(); expect(spy).toHaveBeenCalledTimes(0); container.find('.reset-timer').first().simulate('click'); expect(spy).toHaveBeenCalledTimes(1); }); });

如果您运行测试,您将看到添加的测试失败,因为我们还没有更新TimerButton组件。 让我们更新TimerButton组件以添加点击事件:

 const TimerButton = ({ buttonAction, buttonValue }) => ( <div className="button-container" onClick={() => buttonAction()}> <p className="button-value">{buttonValue}</p> </div> );

现在,测试应该通过了。

接下来,我们将添加更多测试来检查挂载的 Timer测试用例中调用每个函数时的状态:

 it('should change isOn state true when the start button is clicked', () => { container.instance().forceUpdate(); container.find('.start-timer').first().simulate('click'); expect(container.instance().state.isOn).toEqual(true); }); it('should change isOn state false when the stop button is clicked', () => { container.instance().forceUpdate(); container.find('.stop-timer').first().simulate('click'); expect(container.instance().state.isOn).toEqual(false); }); it('should change isOn state false when the reset button is clicked', () => { container.instance().forceUpdate(); container.find('.stop-timer').first().simulate('click'); expect(container.instance().state.isOn).toEqual(false); expect(container.instance().state.minutes).toEqual(25); expect(container.instance().state.seconds).toEqual(0); });

如果你运行测试,你会看到它们失败,因为我们还没有实现每个方法。 因此,让我们实现每个函数以通过测试:

 startTimer() { this.setState({ isOn: true }); } stopTimer() { this.setState({ isOn: false }); } resetTimer() { this.stopTimer(); this.setState({ minutes: 25, seconds: 0, }); }

如果您运行它们,您将看到测试通过。 现在,让我们在Timer.jsx中实现剩余的功能:

 import React, { Component } from 'react'; import './Timer.css'; import TimerButton from '../TimerButton/TimerButton'; class Timer extends Component { constructor(props) { super(props); this.state = { minutes: 25, seconds: 0, isOn: false, }; this.startTimer = this.startTimer.bind(this); this.stopTimer = this.stopTimer.bind(this); this.resetTimer = this.resetTimer.bind(this); } startTimer() { if (this.state.isOn === true) { return; } this.myInterval = setInterval(() => { const { seconds, minutes } = this.state; if (seconds > 0) { this.setState(({ seconds }) => ({ seconds: seconds - 1, })); } if (seconds === 0) { if (minutes === 0) { clearInterval(this.myInterval); } else { this.setState(({ minutes }) => ({ minutes: minutes - 1, seconds: 59, })); } } }, 1000); this.setState({ isOn: true }); } stopTimer() { clearInterval(this.myInterval); this.setState({ isOn: false }); } resetTimer() { this.stopTimer(); this.setState({ minutes: 25, seconds: 0, }); } render = () => { const { minutes, seconds } = this.state; return ( <div className="timer-container"> <div className="time-display"> {minutes}:{seconds < 10 ? `0${seconds}` : seconds} </div> <div className="timer-button-container"> <TimerButton className="start-timer" buttonAction={this.startTimer} buttonValue={'Start'} /> <TimerButton className="stop-timer" buttonAction={this.stopTimer} buttonValue={'Stop'} /> <TimerButton className="reset-timer" buttonAction={this.resetTimer} buttonValue={'Reset'} /> </div> </div> ); }; } export default Timer;

您将看到所有功能都基于我们之前准备的用户故事工作。

定时器

所以,这就是我们使用 TDD 开发基本 React 应用程序的方式。 如果用户故事和验收标准更详细,可以更精确地编写测试用例,从而贡献更多。

包起来

使用 TDD 开发应用程序时,不仅要将项目分解为史诗或用户故事,而且要为验收标准做好充分准备,这一点非常重要。 在本文中,我想向您展示如何分解项目并使用准备好的验收标准进行 React TDD 开发。

尽管有很多与 React TDD 相关的资源,但我希望本文能帮助您了解一些关于使用用户故事使用 React 进行 TDD 开发的知识。 如果您选择模拟这种方法,请参阅此处的完整源代码。