欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

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

程序员文章站 2022-07-05 18:17:48
...

原文:https://www.toptal.com/react/tdd-react-user-stories-to-development

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

在本文中,我们将采用 测试驱动开发(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 组件构成的番茄计时器基础应用。每一个组件都会在相关的一个测试文件中拥有独立的一组测试。首先,我们可以基于项目需求创建如下的史诗和用户故事:

史诗 用户故事 验收准则
作为一个用户,我需要使用计时器以管理时间 作为一个用户,我要能启动计时器以开始倒计时。 确保用户能够:
               
*启动计时器
*看到计时器开始倒计时
               
               即便用户多次点击启动按钮,倒计时也不应被中断
作为一个用户,我要能停止计时器,这样只有在我需要时才会倒计时。 确保用户能够:
               
*停止计时器
*看到计时器被停止了
               
               当用户多次点击停止按钮后,不应该再发生什么
作为一个用户,我要能重置计时器,这样我又能从头开始倒计时了。 确保用户能够:
               
*重置计时器
*看到时间被重置为默认状态

线框图

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

线框图

项目设置

首先,我们使用 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 应用,将看到如下的效果:

[译] 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;

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

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

计时器

所以,这就是我们如何使用 TDD 开发一个基础 React 应用的过程。用户故事及验收准则越细致,测试用例也将越精确,那将是大有裨益的。

总结

当使用 TDD 开发应用时,不仅将项目分解为史诗和用户故事,同时也要准备好验收准则,这是非常重要的。在本文中,展示了上述方法对 React TDD 开发的帮助。

示例源代码可在这里找到:https://github.com/hyungmoklee/react-timer 。



--End--

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

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

转载请注明出处