原文:www.toptal.com/react/tdd-r…
在本文中,我们将采用 测试驱动开发(TDD:test-driven development) 方法,从用户故事到产品开发一个 React 应用。同时,我们将在 TDD 中使用 Jest 和 Enzyme 。一旦完成本教程,你将能够:
- 基于需求创建 epic 和 user stories(用户故事)
- 基于用户故事创建测试
- 使用 TDD 开发一个 React 应用
- 使用 Enzyme 和 Jest 测试 React 应用
- 使用/复用 CSS variables 实现响应式设计
- 创建一个根据所提供的 props 实现不同渲染和功能的可复用 React 组件
- 使用 React PropTypes 实现组件 props 的类型检查
译注: epic(史诗)、user stories(用户故事)、acceptance criteria(验收准则)都是敏捷式开发中的相关概念
本文假设你已经具备了 React 和单元测试的基本知识,如果有必要请参阅如下资料:
- 《React 官方教程》 reactjs.org/tutorial/tu…
- 《Toptal 2019 React 教程 1》www.toptal.com/react/react…
- 《Toptal 2019 React 教程 1》www.toptal.com/react/react…
- 《对 React 组件进行单元测试》mp.weixin.qq.com/s/oE944uljX…
- 《更可靠的 React 组件:从"可测试的"到"测试通过的"》mp.weixin.qq.com/s/9wLF5bmj_…
- 《如何测试 React Hooks ?》mp.weixin.qq.com/s/Zfy23jHpy…
- 《Vue 测试指南中文版》mp.weixin.qq.com/s/cS3yT0Jpv…
应用概览
我们将创建一个由某些 UI 组件构成的番茄计时器基础应用。每一个组件都会在相关的一个测试文件中拥有独立的一组测试。首先,我们可以基于项目需求创建如下的史诗和用户故事:
史诗 | 用户故事 | 验收准则 |
作为一个用户,我需要使用计时器以管理时间 | 作为一个用户,我要能启动计时器以开始倒计时。 | 确保用户能够: *启动计时器 *看到计时器开始倒计时 即便用户多次点击启动按钮,倒计时也不应被中断 |
作为一个用户,我要能停止计时器,这样只有在我需要时才会倒计时。 | 确保用户能够: *停止计时器 *看到计时器被停止了 当用户多次点击停止按钮后,不应该再发生什么 |
|
作为一个用户,我要能重置计时器,这样我又能从头开始倒计时了。 | 确保用户能够: *重置计时器 *看到时间被重置为默认状态 |
线框图
项目设置
首先,我们使用 Create React App 创建如下这样的一个 React 项目:
js
$ npx create-react-app react-timer $ cd react-timer $ npm start
你将看到浏览器的一个新 tab 页被打开,其 URL 为 http://localhost:3000 。可以按下 Ctrl+C 结束这个 React 应用的运行。
现在,将 Jest 和 Enzyme 加入依赖:
js
$ npm i -D enzyme $ npm i -D react-test-renderer enzyme-adapter-react-16
同时,我们要添加或更新 src 目录中的 setupTests.js 文件:
js
import { configure } from ‘enzyme’; import Adapter from ‘enzyme-adapter-react-16’; configure({ adapter: new Adapter() });
因为 Create React App 会在每个测试之前运行 setupTests.js 文件,故这将正确地配置好 Enzyme。
配置 CSS
我们来编写基础的 CSS reset,因为想让 CSS variables 在应用中全局可用,也将在 :root
作用域中定义一些变量。定义变量的语法是使用自定义属性符,每个变量名都由 --
开头。
打开 index.css 文件并添加如下内容:
js
:root { --main-font: “Roboto”, sans-serif; } body, div, p { margin: 0; padding: 0; }
现在,需要将该 CSS 导入应用。将 index.js
更新为:
js
import React from ‘react’; import ReactDOM from ‘react-dom’; import ‘./index.css’; ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode> document.getElementById(“root”) )
浅渲染测试
正如你或许已经知道的,TDD 过程可能看起来像这样:
- 添加一个测试
- 运行所有测试,不出所料的失败
- 编写代码以通过测试
- 再次运行所有测试
- 重构代码
- 周而复始
因此,我们先添加一个浅渲染(shallow render)的测试,并编写代码使其通过。向 src/components/App 目录中添加一个名为 App.spec.js 的规格文件,如下:
js
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); }); });
然后运行测试:
js
$ npm test
你会看到测试失败。
添加组件
接下来创建 App 组件以通过测试。打开 src/components/App/App.jsx 并添加如下代码:
js
import React from ‘react’; const App = () => <div className=”app-container” />; export default App;
再次运行测试,首个测试将通过。
添加 App 的样式
接下来我们在 src/components/App 目录中创建一个 App.css 文件,增加一些 App 组件的样式:
css
.app-container { height: 100vh; width: 100vw; align-items: center; display: flex; justify-content: center; }
将其引入 App.jsx 文件:
js
import React from ‘react’; import ‘./App.css’; const App = () => <div className=”app-container” />; export default App;
下一步,更新 index.js 文件,增加引入 App 组件的逻辑:
js
import React from "react" import ReactDOM from "react-dom" import "./index.css" import App from "./components/App/App" ReactDOM.render( <React.StrictMode> <App /> </React.StrictMode>, document.getElementById("root") )
添加计时器组件
最后,应用得有个计时器组件,因此我们来更新 App.spec.js 文件用以检查其存在。同时,将变量 container 声明在首个测试用例之外,这样在每个测试用例之前都能用到浅渲染了。
js
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 组件的浅渲染测试:
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 组件
下一步,创建名为 Timer.jsx 的新文件,并基于用户故事定义相同的变量和方法:
js
import React, { Component } from 'react'; class Timer extends Component { constructor(props) { super(props); this.state = { minutes: 25, seconds: 0, isOn: false }; } startTimer() { console.log('启动定时器'); } stopTimer() { console.log('停止定时器'); } resetTimer() { console.log('重置定时器'); } render = () => { return <div className="timer-container" />; }; } export default Timer;
这将在 Timer.spec.js 中的测试用例中渲染一个 <div />
并使之通过,然而 App.spec.js 仍会失败,因为我们尚未把 Timer 组件加入 App 中。
更新 App.jsx 文件:
js
import React from 'react'; import './App.css'; import Timer from '../Timer/Timer'; const App = () => ( <div className="app-container"> <Timer /> </div> ); export default App;
现在所有测试都通过了。
为 Timer 增加样式
增加计时器相关的 CSS variables 以及适配小尺寸设备的媒体查询。
将 index.css 更新为:
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 :
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 文件。
js
import React, { Component } from "react" import "./Timer.css"
至此如果你运行这个 React 应用,将看到浏览器中出现一个带有边框的简单屏幕区域了。
编写 TimerButton 测试用例
我们需要三个按钮:Start、* Stop* 和 Reset,因此要创建一个 TimerButton 组件。
首先,更新 Timer.spec.js 文件以检查 Timer 组件中几个按钮的存在:
js
it("should render instances of the TimerButton component", () => { expect(container.find("TimerButton").length).toEqual(3) })
现在,在 src/components 目录下建立子目录 TimerButton 并添加 TimerButton.spec.js 文件,在其中编写如下测试:
js
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.jsx 文件:
js
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;
在 Timer.jsx 中引入并添加三个 TimerButton 组件:
js
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> ); };
TimerButton 的样式
现在轮到为 TimerButton 组件增加 CSS variables 了。把 index.css 文件更新为:
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 的文件:
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 中引入样式,并显示按钮 value :
js
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 以在底部横向排列三个按钮:
css
.time-display { height: 70%; font-size: 5em; display: flex; justify-content: center; margin-left: auto; flex-direction: column; align-items: center; } .timer-button-container { display: flex; flex-direction: row; justify-content: center; height: 30%; }
如果现在运行这个 React 应用,将看到如下的效果:
重构 Timer
为了实现 启动定时器、停止定时器、重置定时器 等功能,需要对 Timer 重构。先来更新 Timer.spec.js 测试:
js
describe('mounted Timer', () => { let container; beforeEach(() => (container = mount(<Timer />))); it('点击 Start 按钮时调用 startTimer 方法', () => { 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('点击 Stop 按钮时调用 stopTimer 方法', () => { 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('点击 Reset 按钮时调用 resetTimer 方法', () => { 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 组件中更新相关功能。让我们来添加点击的功能:
js
const TimerButton = ({ buttonAction, buttonValue }) => ( <div className="button-container" onClick={() => buttonAction()}> <p className="button-value">{buttonValue}</p> </div> );
测试现在会通过了。
下一步,添加更多的测试用例以检查每个方法被调用后组件的状态:
js
it('点击 Start 按钮后状态 isOn 应变为 true', () => { container.instance().forceUpdate(); container.find('.start-timer').first().simulate('click'); expect(container.instance().state.isOn).toEqual(true); }); it('点击 Stop 按钮后状态 isOn 应变为 false', () => { container.instance().forceUpdate(); container.find('.stop-timer').first().simulate('click'); expect(container.instance().state.isOn).toEqual(false); }); it('点击 Reset 按钮后状态 isOn 应变为 false 等', () => { container.instance().forceUpdate(); container.find('.reset-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); });
因为还未实现每个方法,所以测试将会失败。更新组件为:
js
startTimer() { this.setState({ isOn: true }); } stopTimer() { this.setState({ isOn: false }); } resetTimer() { this.stopTimer(); this.setState({ minutes: 25, seconds: 0, }); }
现在测试可以通过了。让我们实现 Timer.jsx 的剩余功能吧:
js
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 开发的帮助。
示例源代码可在这里找到:github.com/hyungmoklee… 。