教程:FullStackOpen2022/Part 5

本文中以 bloglist 应用为例。对应练习 5.11-5.21。

1. 使用 PropTypes

如果希望进一步定义组件 component 的属性值 props,可以通过 prop-types 包来实现。

npm install prop-types

使用 prop-types:

components/Togglable.js:

import PropTypes from 'prop-types'

const Togglable = React.forwardRef((props, ref) => {
  // ..
})

Togglable.propTypes = {
  buttonLabel: PropTypes.string.isRequired
}

Togglable.displayName = 'Togglable'

export default Togglable

components/LoginForm.js:

import PropTypes from 'prop-types'

const LoginForm = ({ login }) => {
    // ...
  }

LoginForm.propTypes = {
  login: PropTypes.func.isRequired,
}

export default LoginForm

定义属性为 required 但该属性是 undefined 时,或传递给 prop 的类型是错误的时,应用仍然可以工作,但控制台会展示错误信息。

2. React Component Unit Test

create-react-app 默认添加了 Jest。除了 Jest 之外,还需要另一个测试库帮助测试渲染组件,目前最好的选择是 react-testing-library。

测试组件是否渲染

测试使用 react-testing-library 提供的 render 方法渲染组件。render 返回一个具有多个 prop 属性的对象,其中一个属性称为 container,包含由组件渲染的所有 HTML。

react-testing-library 包提供了许多不同的方法来研究被测试组件的内容。

  • 使用 toHaveTextContent 从组件渲染的整个 HTML 代码中搜索匹配的文本。
  • 使用 render 方法返回对象的 getByText 方法获得包含给定文本的元素。如果不存在此类元素,则发生异常。
  • 使用 querySelector 方法,该方法接收 CSS 选择器作为其参数,返回第一个匹配的元素。
  • 使用 toHaveStyle 方法,通过检查 div 元素是否包含相应样式。此方法无法检测父元素样式。

tests/Blog.test.js:

import React from 'react'
import '@testing-library/jest-dom/extend-expect'
import { render, fireEvent } from '@testing-library/react'
import Blog from '../components/Blog'

describe('<Blog />', () => {
  const blog = {
    title: 'TDD harms architecture',
    author: 'Robert C. Martin',
    url: 'http://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html',
    likes: 4,
  }

  test('renders content', () => {
		const component = render(<Blog blog={blog}/>)

    expect(component.container).toHaveTextContent(blog.title)

		const element = component.getByText('Robert C. Martin')
    expect(element).toBeDefined()

    expect(component.container.querySelector('.details')).toHaveStyle(
      'display: none'
    )
  })
})

在测试中触发事件

测试中,事件处理程序是用 Jest 定义的 mock 函数。测试找到元素,然后使用 fireEvent 方法触发事件。

模拟对象和函数是测试中常用的根组件,用于替换被测试组件的依赖项。通过 mock 可以返回硬编码的响应,并验证调用 mock 函数的次数和参数。

tests/BlogForm.test.js:

import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import BlogForm from '../components/BlogForm'

test('<BlogForm /> updates parent state and calls onSubmit', () => {
  const blog = {
    //...
  }

  const createBlog = jest.fn()

  const component = render(
    <BlogForm createBlog={createBlog} />
  )

  const input_title = component.container.querySelector('input[name=title]')
  //...
  const form = component.container.querySelector('form')

  fireEvent.change(input_title, {
    target: { value: blog.title }
  })
  //...
  fireEvent.submit(form)

  expect(createBlog.mock.calls).toHaveLength(1)
  expect(createBlog.mock.calls[0][0].title).toBe(blog.title)
  //...
})

运行测试

create-react-app 默认情况下将测试配置为在 watch 模式下运行,这意味着 npm test 命令在测试结束后不会退出,而是等待对代码进行更改。一旦保存了对代码的新的更改,测试就会自动执行,等待新的更改。

想要避免持续测试,可以使用如下命令:

CI=true npm test

调试测试

render 方法返回的对象具有一个调试方法 debug ,该方法可用于将组件渲染的 HTML 打印到控制台。

还可以搜索组件的一小部分并打印其 HTML 代码。 为了做到这一点,需要 prettyDOM 方法,该方法可以从 @testing-library/dom 包中导入,通过 react-testing-library 自动安装。

import { prettyDOM } from '@testing-library/dom'

const blog = {
  //...
}

test('renders content', () => {
	const component = render(<Blog blog={blog}/>)

  component.debug()

	const li = component.container.querySelector('li')
  console.log(prettyDOM(li))
})

测试覆盖范围

通过运行内置命令,可以很容易地找到测试的覆盖范围 coverage。

CI=true npm test -- --coverage

coverage/lcov-report 目录将生成相当原始的 HTML report,该报告会告诉我们每个组件中未经测试的代码行。

3. Cypress E2E Integration Test

接下来,我们将使用一种端到端 End to End(E2E)测试,将系统作为一个整体测试。

可以使用浏览器和测试库对 web 应用进行 E2E 测试。可用的库,例如Selenium ,几乎可以用于任何浏览器;另一个选项是所谓的headless browsers,是一种没有用户界面的浏览器。

E2E 测试可能是最有用的一类测试,因为它们测试系统的界面与真实用户使用的界面相同。但 E2E 测试也可能是片状的。有些测试可能一次通过,另一次失败,即使代码根本没有改变。

安装并使用 Cypress

Cypress 非常容易使用,与 Selenium 相比需要少得多麻烦和头痛问题。它与大多数 E2E 测试库不同,Cypress 测试完全在浏览器中运行,而其他库在一个 node 进程中运行测试,进程再通过一个 API 连接到浏览器。

将 Cypress 安装到前端 ,作为开发依赖项。

npm install --save-dev cypress

Cypress 测试可以位于前端或后端仓库中,甚至可以位于它们自己的单独仓库中。这些测试要求测试系统正常运行。

在后端中添加一个 npm-script,该命令在测试模式下启动应用,使 NODE_ENV 设置为 test

{
  // ...
  {
    "scripts": {
	    // ...
	    "start:test": "cross-env NODE_ENV=test node index.js"
	  },
  // ...
}

在 Cypress 所在的库里添加一个 npm-script 来运行 Cypress:

{
  // ...
  {
    "scripts": {
	    // ...
	    "cypress:open": "cypress open", //图形化测试
		"test:e2e": "cypress run" //命令行运行,测试执行的视频将被保存到 cypress/videos 中
	  },
  // ...
}

当后端和前端都在运行时,可以使用该命令启动 Cypress(VS Code 中可以使用拆分terminal 来同时跑多个进程)。

第一次运行 Cypress 时,会创建一个Cypress 目录,其中包含一个 /integration 子目录用于放置测试。

控制测试数据库状态

与单元测试和集成测试一样,E2E 测试最好是在测试运行之前清空数据库并尽可能格式化数据库。

E2E 测试的挑战在于无法访问数据库。解决方案是为测试创建后端的 API 接口,使用这些接口清空数据库。

在后端为测试创建一个新的路由。

controllers/testing.js:

const testingRouter = require("express").Router();
const Blog = require("../models/blog");
const User = require("../models/user");

testingRouter.post("/reset", async (request, response) => {
  await Blog.deleteMany({});
  await User.deleteMany({});

  response.status(204).end();
});

module.exports = testingRouter;

app.js:

// ...

if (process.env.NODE_ENV === 'test') {
  const testingRouter = require('./controllers/testing')
  app.use('/api/testing', testingRouter)
}

app.use(middleware.unknownEndpoint)
app.use(middleware.errorHandler)

module.exports = app

更改之后,后端测试模式下,对 /api/testing/reset 接口的 HTTP POST 请求将清空数据库。

测试登录

就浏览器而言,每个测试都是从零开始的。在每次测试后,对浏览器状态的所有更改都会被重置。因此,在每个测试之前,因将共享部分分隔为 beforeEach 块运行。在对测试进行格式化时,使用cy.request 对后端进行 HTTP 请求。

cypress/integration/bloglist.spec.js:

const user = {
  name: 'Matti Luukkainen',
  username: 'mluukkai',
  password: 'salainen'
}

const blog = {
  title: 'A Blog Created by Cypress',
  author: 'Cypress',
  url: 'http://blog.cypress',
}

describe('Blog app', function () {
  beforeEach(function () {
    cy.request('POST', 'http://localhost:3003/api/testing/reset')
    cy.request('POST', 'http://localhost:3003/api/users/', user)
    cy.visit('http://localhost:3000')
  })

  it('login form is shown', function () {
    cy.contains('login').click()
    cy.get('input[name="username"]').should('be.visible')
    cy.get('input[name="password"]').should('be.visible')
    cy.get('#login-btn').should('be.visible')
  })

  describe('login',function() {
    it('succeeds with correct credentials', function() {
      cy.contains('login').click()
      cy.get('input[name="username"]').type('mluukkai')
      cy.get('input[name="password"]').type('salainen')
      cy.get('#login-btn').click()

      cy.contains('Matti Luukkainen logged in')
    })

    it('fails with wrong credentials', function() {
      cy.contains('login').click()
      cy.get('input[name="username"]').type('mluukkai')
      cy.get('input[name="password"]').type('wrong')
      cy.get('#login-btn').click()

      cy.get('.error')
        .should('contain', 'Wrong credentials')
        .and('have.css', 'color', 'rgb(255, 0, 0)')
        .and('have.css', 'border-style', 'solid')

      cy.get('html').should('not.contain', 'Matti Luukkainen logged in')
    })
  })
})

测试首先通过文本搜索登录按钮,然后用命令 cy.click 单击该按钮,展现登录表单。

登录字段包含两个 input 字段,测试将这两个字段写入其中。cy.get 命令通过 CSS 选择器搜索元素,并使用命令 cy.type 向它们写入内容。

登录失败时,使用 cy.get 来搜索带有 CSS 类为 .error 的组件,然后检查是否可以从这个组件中找到错误消息,并确保错误消息具有相应样式。(注意:一些 CSS 属性在 Firefox 中的行为有些不同。)

可以使用 should 语法来做与 contains 同样的事情。使用 should 比使 contains 稍微复杂一些,但它允许比 contains 更多样化的测试,contains 仅基于文本内容。should 应当总是与 get 或其他某个可链接命令链接。

因为所有测试都是针对使用 cy.get 访问到的同一个组件,所以可以使用 and 链接它们。

Cypress 自定义命令

使用 HTTP 请求登录要比填写表单快得多。测试其他用例时,可以选择绕过 UI,对后端执行 HTTP 请求以登录。

同时,当需要在多个地方使用相同代码时,应该设置成为一个自定义命令。

自定义命令在 cypress/support/commands.js 中声明。

登录和创建新 Blog 的代码如下:

Cypress.Commands.add('login', ({ username, password }) => {
  cy.request('POST', 'http://localhost:3003/api/login', {
    username, password
  }).then(({ body }) => {
    localStorage.setItem('loggedBloglistUser', JSON.stringify(body))
    cy.visit('http://localhost:3000')
  })
})

Cypress.Commands.add('createBlog', (blog) => {
  cy.request({
    url: 'http://localhost:3003/api/blogs',
    method: 'POST',
    body: blog,
    headers: {
      'Authorization': `bearer ${JSON.parse(localStorage.getItem('loggedBloglistUser')).token}`
    }
  })

  cy.visit('http://localhost:3000')
})

使用自定义命令使测试变得更简洁。

describe('when logged in', function() {
  beforeEach(function() {
    cy.login({ username: 'mluukkai', password: 'salainen' })
  })

  it('a new blog can be created', function() {
    // ...
  })

  // ...
})

测试 Blog 的 CRUD 功能

只有登录的用户才能创建新的 Blog,因此需将登录添加到应用的 beforeEach 块中。

cypress/integration/bloglist.spec.js:

const user = {
  name: 'Matti Luukkainen',
  username: 'mluukkai',
  password: 'salainen'
}

const blog = {
  title: 'A Blog Created by Cypress',
  author: 'Cypress',
  url: 'http://blog.cypress',
}

describe('Blog app', function () {
  beforeEach(function () {
    cy.request('POST', 'http://localhost:3003/api/testing/reset')
    cy.request('POST', 'http://localhost:3003/api/users/', user)
    cy.visit('http://localhost:3000')
  })

	//...

  describe('when logged in', function() {
    beforeEach(function() {
      cy.login({ username: 'mluukkai', password: 'salainen' })
    })

    it('a blog can be created', function() {
      cy.contains('new blog').click()
      cy.get('input[name="title"]').type(blog.title)
      cy.get('input[name="author"]').type(blog.author)
      cy.get('input[name="url"]').type(blog.url)
      cy.get('#create-blog-btn').click()
      cy.get('.blog').contains(blog.title)
    })

    describe('and several blogs exist', function () {
      beforeEach(function () {
        cy.createBlog(blog)
      })

      it('a blog\'s details can be viewed', function() {
        cy.contains(blog.title).contains('view').click()
        cy.contains(blog.title).get('.details').should('be.visible')
      })

      it('a blog can be added +1 like', function() {
        cy.contains(blog.title).contains('view').click()
        cy.contains(blog.title).get('.details').contains('+1').click()
        cy.contains(blog.title).get('.details').should('contain','likes: 1')
      })

      it.only('a blog can be deleted by its creator', function() {
        cy.contains(blog.title).contains('view').click()
        cy.contains(blog.title).get('.details').contains('remove').click()
        cy.get('html').should('not.contain', 'blog.title')
      })
    })

  })
})

当开发一个新的测试或者调试一个失败的测试时,可以用 it.only 而不是 it 来定义测试,这样 Cypress 就只会运行所定义的测试。

4. 配置测试相关 eslint

create-react-app 已经默认为项目安装好了 ESlint, 所以只需定义自己的 .eslintrc.js 文件即可。

为避免不想要和不相关的 lint 错误,安装 eslint-plugin-jest 和 eslint-plugin-cypress 库:

npm install eslint-plugin-jest --save-dev
npm install eslint-plugin-cypress --save-dev

为 .eslintrc.js 添加如下配置:

module.exports = {
  env: {
    browser: true,
    es6: true,
    "jest/globals": true,
    "cypress/globals": true,
  },
  extends: ["eslint:recommended", "plugin:react/recommended"],
  parserOptions: {
    ecmaFeatures: {
      jsx: true,
    },
    ecmaVersion: 2018,
    sourceType: "module",
  },
  plugins: ["react", "jest", "cypress"],
  rules: {
    indent: ["error", 2],
    "linebreak-style": ["error", "unix"],
    quotes: ["error", "single"],
    semi: ["error", "never"],
    eqeqeq: "error",
    "no-trailing-spaces": "error",
    "object-curly-spacing": ["error", "always"],
    "arrow-spacing": ["error", { before: true, after: true }],
    "no-console": 0,
    "react/prop-types": 0,
  },
  settings: {
    react: {
      version: "detect",
    },
  },
};

创建一个 .eslintignore 文件,使 eslint 跳过无需 lint 的文件。

node_modules
build
.eslintrc.js

为 eslint 创建一个 npm 脚本:

{
  // ...
  {
    "scripts": {
	    // ...
	    "eslint": "eslint --fix ."
	  },
  // ...
}