[译] React 测试驱动开发:从用户故事到产品
原文:https://www.toptal.com/react/tdd-react-user-stories-to-development
在本文中,我们将采用 测试驱动开发(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 官方教程》 https://reactjs.org/tutorial/tutorial.html
《Toptal 2019 React 教程 1》https://www.toptal.com/react/react-tutorial-pt1
《Toptal 2019 React 教程 1》https://www.toptal.com/react/react-tutorial-pt2
应用概览
我们将创建一个由某些 UI 组件构成的番茄计时器基础应用。每一个组件都会在相关的一个测试文件中拥有独立的一组测试。首先,我们可以基于项目需求创建如下的史诗和用户故事:
史诗 | 用户故事 | 验收准则 |
---|---|---|
作为一个用户,我需要使用计时器以管理时间 | 作为一个用户,我要能启动计时器以开始倒计时。 | 确保用户能够: *启动计时器 *看到计时器开始倒计时 即便用户多次点击启动按钮,倒计时也不应被中断 |
作为一个用户,我要能停止计时器,这样只有在我需要时才会倒计时。 | 确保用户能够: *停止计时器 *看到计时器被停止了 当用户多次点击停止按钮后,不应该再发生什么 |
|
作为一个用户,我要能重置计时器,这样我又能从头开始倒计时了。 | 确保用户能够: *重置计时器 *看到时间被重置为默认状态 |
线框图
项目设置
首先,我们使用 Create React App 创建如下这样的一个 React 项目:
$ npx create-react-app react-timer
$ cd react-timer
$ npm start
你将看到浏览器的一个新 tab 页被打开,其 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 reset,因为想让 CSS variables 在应用中全局可用,也将在 :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 过程可能看起来像这样:
添加一个测试
运行所有测试,不出所料的失败
编写代码以通过测试
再次运行所有测试
重构代码
周而复始
因此,我们先添加一个浅渲染(shallow render)的测试,并编写代码使其通过。向 src/components/App 目录中添加一个名为 App.spec.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);
});
});
然后运行测试:
$ npm test
你会看到测试失败。
添加组件
接下来创建 App 组件以通过测试。打开 src/components/App/App.jsx 并添加如下代码:
import React from ‘react’;
const App = () => <div className=”app-container” />;
export default App;
再次运行测试,首个测试将通过。
添加 App 的样式
接下来我们在 src/components/App 目录中创建一个 App.css 文件,增加一些 App 组件的样式:
.app-container {
height: 100vh;
width: 100vw;
align-items: center;
display: flex;
justify-content: center;
}
将其引入 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"
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById("root")
)
添加计时器组件
最后,应用得有个计时器组件,因此我们来更新 App.spec.js 文件用以检查其存在。同时,将变量 container 声明在首个测试用例之外,这样在每个测试用例之前都能用到浅渲染了。
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 组件的浅渲染测试:
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 的新文件,并基于用户故事定义相同的变量和方法:
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 文件:
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 更新为:
: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 测试用例
我们需要三个按钮:Start、* Stop* 和 Reset,因此要创建一个 TimerButton 组件。
首先,更新 Timer.spec.js 文件以检查 Timer 组件中几个按钮的存在:
it("should render instances of the TimerButton component", () => {
expect(container.find("TimerButton").length).toEqual(3)
})
现在,在 src/components 目录下建立子目录 TimerButton 并添加 TimerButton.spec.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 文件:
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 组件:
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 文件更新为:
: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 中引入样式,并显示按钮 value :
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 以在底部横向排列三个按钮:
...
.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 测试:
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 组件中更新相关功能。让我们来添加点击的功能:
const TimerButton = ({ buttonAction, buttonValue }) => (
<div className="button-container" onClick={() => buttonAction()}>
<p className="button-value">{buttonValue}</p>
</div>
);
测试现在会通过了。
下一步,添加更多的测试用例以检查每个方法被调用后组件的状态:
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);
});
因为还未实现每个方法,所以测试将会失败。更新组件为:
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 开发的帮助。
示例源代码可在这里找到:https://github.com/hyungmoklee/react-timer 。
--End--
查看更多前端好文
请搜索 fewelife 关注公众号
转载请注明出处