专栏首页云前端[译] React 测试驱动开发:从用户故事到产品

[译] 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
  • 《对 React 组件进行单元测试》
  • 《更可靠的 React 组件:从"可测试的"到"测试通过的"》
  • 《如何测试 React Hooks ?》
  • 《Vue 测试指南中文版》

应用概览

我们将创建一个由某些 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 过程可能看起来像这样:

  1. 添加一个测试
  2. 运行所有测试,不出所料的失败
  3. 编写代码以通过测试
  4. 再次运行所有测试
  5. 重构代码
  6. 周而复始

因此,我们先添加一个浅渲染(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 。

本文分享自微信公众号 - 云前端(fewelife),作者:云前端

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2020-08-06

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 我的第一本英文技术书

    在这本书的写作过程中,我个人最大的收获应该是:当你制定了一个目标,不论这个目标开始开起来有多么的不切实际,一旦你开始细化这个目标并逐步实施,你就已经离这个目标不...

    ThoughtWorks
  • 解密 Uber 数据部门的数据可视化最佳实践

    概述 在2015年初,我们在Uber规划了一个官方的数据科学团队。这个主意的缘起是:通过可视化数据探索工具从Uber的数据中发现洞见。每天,Uber 管理上亿级...

    小小科
  • 干货 | 行为驱动开发在携程机票前端研发流程中的实践

    任跃华,携程机票前台软件工程师,参与了国际机票 RN Clean Architecture 落地和 MEC 中文测试框架研发工作。

    携程技术
  • 如何运用 DDD 解决团队协作与沟通问题?

    领域驱动设计的核心是“领域”,因此要运用领域驱动设计,从一开始就要让团队走到正确的点上。当我们组建好了团队之后,应该从哪里开始?

    范蠡
  • 为什么我们选择使用 React 而不是 Angular 构建新 UI

    我们在2013年推出了Cloud Elements集成平台的v1版本,这个产品在过去几年里一直为客户和公司发展提供了良好的服务。但是2017年,Web技术迅速...

    顶级程序员
  • 为什么我们选择使用 React 而不是 Angular 构建新 UI

    我们在2013年推出了Cloud Elements集成平台的v1版本,这个产品在过去几年里一直为客户和公司发展提供了良好的服务。但是2017年,Web技术迅速演...

    疯狂的技术宅
  • 如何运用 DDD 解决团队协作与沟通问题?

    领域驱动设计的核心是“领域”,因此要运用领域驱动设计,从一开始就要让团队走到正确的点上。当我们组建好了团队之后,应该从哪里开始?

    CSDN技术头条
  • 如何运用 DDD 解决团队协作与沟通问题?

    领域驱动设计的核心是“领域”,因此要运用领域驱动设计,从一开始就要让团队走到正确的点上。当我们组建好了团队之后,应该从哪里开始?

    纯洁的微笑
  • Flutter vs React Native vs Native:深度性能比较

    玄武门之变是唐高祖武德九年六月初四(公元626年7月2日)由当时唐高祖李渊次子秦王李世民在唐王朝的首都长安城大内皇宫的北宫门——玄武门附近发动的一次流血政变。

    老孟Flutter
  • [译] 为新的Facebook.com重建我们的技术栈

    当我们考虑如何构建一个新的网络应用—一个为现代浏览器设计的、具有用户对Facebook(我们已知的)所有期望的功能,我们现有的技术栈无法支持我们所需要的类似于桌...

    一只图雀
  • Uber大数据可视分析:让数据为用户讲故事

    <数据猿导读> Uber数据可视化团队的理念是将Uber后台的大量数据,通过数据可视分析工具实现情报分析。Uber系统每天需要管理近十亿GPS数据。Uber的数...

    数据猿
  • 可视化 | Uber 工程智能大数据可视分析案例

    UBER这款让人又爱又恨的打车软件已经潜入我们的生活,他们从来不说他们是出租车公司,他们说自己是大数据企业。那么他们是如何做大数据的呢?往下看看吧。 前言 20...

    CDA数据分析师
  • GitHub 上100个优质前端项目整理,非常全面!

    作 者:小明小明长大了 来 源:https://www.jianshu.com/p/72ca8192f7b8

    开发者技术前线
  • 前端每周清单第 44 期: 2017 JS 调查报告、REST 接口实时化、ESM 的过去与未来

    前端每周清单专注前端领域内容,以对外文资料的搜集为主,帮助开发者了解一周前端热点;分为新闻热点、开发教程、工程实践、深度阅读、开源项目、巅峰人生等栏目。欢迎关注...

    王下邀月熊
  • React Native 在 Airbnb 的起起落落

    感谢支持ayqy个人订阅号,每周义务推送1篇(only unique one)原创精品博文,话题包括但不限于前端、Node、Android、数学...

    ayqy贾杰
  • 收藏夹吃灰了:GitHub 上值得收藏的100个精选前端项目

    codrops 一系列具有相当具有创意且有趣的前端效果的集合,是非常棒的学习资料,可以欣赏和下载使用。并且有些项目,也托管到了github仓库中

    王小婷
  • 前端自动化测试探索和实践

    众所周知的原因,前端作为一种特殊的 GUI 软件,做自动化测试困难重重。在快速迭代,UI 变动大的业务中,自动化测试想要落地更是男上加男 ?。

    ConardLi
  • 阿里&百度&腾讯&facebook&Microsoft&Google开源项目汇总

    BAT && YMFT Tencent GitHub地址:https://github.com/Tencent/tinker Tinker是Android的...

    shaonbean
  • 真实测评揭秘:开发小程序用原生还是选框架?

    自 2017-1-9微信小程序诞生以来,历经几年的迭代升级,已有数百万小程序上线,成为继Web、iOS、Android之后,第四大主流开发技术。

    极乐君

扫码关注云+社区

领取腾讯云代金券