React测试驱动的开发:从用户故事到生产

129 阅读4分钟

React测试驱动的开发:从用户故事到生产

在这篇文章中,我们将使用测试驱动开发(TDD)开发一个React应用,从用户故事到开发。同时,我们将使用Jest和Enzyme来进行TDD。完成本指南后,你将能够:

  • 在需求的基础上创建史诗和用户故事。
  • 基于用户故事创建测试。
  • 使用TDD开发一个React应用程序。
  • 使用Enzyme和Jest来测试React应用程序。
  • 使用/重复使用CSS变量进行响应式设计。
  • 创建一个可重用的React组件,根据所提供的props进行不同的渲染和功能。
  • 使用React PropTypes类型检查组件的道具。

我们的测试驱动的React应用程序的概述

我们将建立一个由一些UI组件组成的基本绒球定时器应用。每个组件在相应的测试文件中都会有一组单独的测试。首先,我们可以根据我们的项目要求,创建如下的史诗和用户故事。

EPIC用户故事接受标准
作为一个用户,我需要使用计时器,以便我可以管理我的时间。作为一个用户,我需要启动计时器,以便我可以倒计时。确保用户能够:

*启动定时器
*看到定时器开始倒计时

即使用户多次点击启动按钮,倒计时也不应被打断。
作为一个用户,我需要停止定时器,这样我才能在需要的时候倒数我的时间。确保用户能够:

*停止定时器
*看到定时器停止

即使用户点击停止按钮超过一次,也不应发生任何事情。
作为一个用户,我需要重新设置定时器,这样我就可以从头开始倒计时。确保用户能够:

*重置定时器
*看到定时器重置为默认值

线框图

项目设置

首先,我们要用Create React App创建一个React项目,如下所示。

$ npx create-react-app react-timer
$ cd react-timer
$ npm start

你会看到一个新的浏览器标签打开,网址是http://localhost:3000。你可以使用Ctrl+C停止正在运行的React应用程序。

现在,我们要添加JestEnzyme以及一些依赖项,如下所示。

$ 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重置,因为我们希望CSS变量在应用程序中全局可用。我们将在: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. 重复。

因此,我们要为浅层渲染测试添加第一个测试,然后编写代码来通过测试。在src/components/App目录下添加一个名为App.spec.js的新 spec 文件,如下所示。

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;

现在,再次运行测试。

$ npm test

现在第一个测试应该通过了。

添加App的CSS

我们将在src/components/App目录下创建一个App.css文件,为App组件添加一些样式,如下所示。

.app-container {
	height: 100vh;
	width: 100vw;
	align-items: center;
	display: flex;
	justify-content: center;
}

现在,我们准备将CSS导入到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"
import * as serviceWorker from "./serviceWorker"

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
)

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister()

添加定时器组件

最后,应用程序将包含定时器组件,因此我们要更新App.spec.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.spec.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.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('Starting timer.');
  }

  stopTimer() {
    console.log('Stopping timer.');
  }

 resetTimer() {
    console.log('Resetting timer.');
  }

  render = () => {
    return <div className="timer-container" />;
  };
}

export default Timer;

这应该通过测试,并在Timer.spec.js文件中渲染一个<div /> ,但测试不应该渲染Timer组件,因为我们还没有在应用组件中添加Timer组件。

我们要像这样在App.jsx文件中添加Timer组件。

import React from 'react';
import './App.css';
import Timer from '../Timer/Timer';

const App = () => (
  <div className="app-container">
    <Timer />
  </div>
);

export default App;

现在所有的测试都应该通过。

添加定时器的CSS

我们将添加与定时器相关的CSS变量,并为小型设备添加媒体查询。

更新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%;
}
}

另外,我们要在component/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的浅层渲染测试

我们需要三个按钮。开始、停止重置,因此我们要创建TimerButton组件

首先,我们需要更新Timer.spec.js文件,检查TimerButton组件在Timer组件中是否存在。

it("should render instances of the TimerButton component", () => {
    expect(container.find("TimerButton").length).toEqual(3)
  })

现在,让我们把TimerButton.spec.js文件添加到src/components目录下一个名为TimerButton的新目录中,然后让我们像这样把测试添加到该文件中。

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组件创建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;

如果你在这个阶段运行npm test ,测试应该呈现TimerButton组件的实例,但会失败,因为我们还没有把TimerButton组件添加到Timer组件中。

让我们导入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 CSS

现在,是时候为TimerButton组件添加CSS变量了。让我们在index.css文件的 :root 范围内添加变量。

: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来导入TimerButton.css文件并显示按钮值。

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,使三个按钮水平对齐,所以我们也来更新Timer.css文件。

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;

如果你现在运行React应用程序,你会看到如下的屏幕。

Timer

重构定时器

我们要重构Timer,因为我们要实现startTimer、stopTimer、restartTimerresetTimer等函数。让我们首先更新Timer.spec.js文件。

describe('mounted Timer', () => {
  let container;

  beforeEach(() => (container = mount(<Timer />)));

  it('invokes startTimer when the start button is clicked', () => {
    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('invokes stopTimer when the stop button is clicked', () => {
    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('invokes resetTimer when the reset button is clicked', () => {
    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组件。让我们更新TimerButton组件,添加点击事件。

const TimerButton = ({ buttonAction, buttonValue }) => (
  <div className="button-container" onClick={() => buttonAction()}>
    <p className="button-value">{buttonValue}</p>
  </div>
);

现在,测试应该通过了。

接下来,我们将添加更多的测试,以检查在加载的定时器测试案例中每个函数被调用时的状态。

it('should change isOn state true when the start button is clicked', () => {
    container.instance().forceUpdate();
    container.find('.start-timer').first().simulate('click');
    expect(container.instance().state.isOn).toEqual(true);
  });

  it('should change isOn state false when the stop button is clicked', () => {
    container.instance().forceUpdate();
    container.find('.stop-timer').first().simulate('click');
    expect(container.instance().state.isOn).toEqual(false);
  });

  it('should change isOn state false when the reset button is clicked', () => {
    container.instance().forceUpdate();
    container.find('.stop-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开发中分解项目并使用准备好的验收标准。

尽管有很多与React TDD相关的资源,我希望这篇文章能帮助你了解一些关于使用用户故事的React TDD开发。如果你选择模仿这种方法,请参考这里的完整源代码。

了解基础知识

你如何进行测试驱动开发?

对于测试驱动开发,你必须首先创建一个测试,并确认该测试是否失败。一旦你确认测试失败,就编写代码以通过测试,必要时重构代码。然后为接下来的测试重复所有这些步骤。

在React中我应该测试什么?

编写测试背后的主要原因是确保应用程序能够正常工作。在React中,我们应该测试应用程序的渲染是否正常,有没有错误。此外,我们应该测试输出和状态,以及事件。最后,我们应该像其他技术栈一样测试边缘案例。

测试驱动的开发好吗?

测试驱动开发是有益的,因为它可以减少生产中的错误,提高代码质量,使代码维护更容易。它还为回归测试提供了自动化测试。然而,TDD对于GUI测试来说成本很高,而且过多的TDD会使代码更加复杂。