[译] React 测试驱动开发:从用户故事到产品

1,190 阅读8分钟

原文: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 和单元测试的基本知识,如果有必要请参阅如下资料:

应用概览

我们将创建一个由某些 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 开发的帮助。

示例源代码可在这里找到:github.com/hyungmoklee…



--End--

查看更多前端好文
请搜索 fewelife 关注公众号

转载请注明出处