应用于React组件测试的实例

85 阅读10分钟

我想向你展示一些东西。我将展示的是一个一般的测试原则,应用于React组件测试。因此,尽管这个例子是React的,但希望它能帮助正确传达这个概念。

注意:我的观点不是说嵌套本身不好,而是说它自然地鼓励使用测试钩子(如beforeEach )作为代码重用的机制,这确实导致了不可维护的测试。请继续阅读...

这是一个我想测试的React组件:

// login.js
import * as React from 'react'

function Login({onSubmit}) {
  const [error, setError] = React.useState('')

  function handleSubmit(event) {
    event.preventDefault()
    const {
      usernameInput: {value: username},
      passwordInput: {value: password},
    } = event.target.elements

    if (!username) {
      setError('username is required')
    } else if (!password) {
      setError('password is required')
    } else {
      setError('')
      onSubmit({username, password})
    }
  }

  return (
    <div>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="usernameInput">Username</label>
          <input id="usernameInput" />
        </div>
        <div>
          <label htmlFor="passwordInput">Password</label>
          <input id="passwordInput" type="password" />
        </div>
        <button type="submit">Submit</button>
      </form>
      {error ? <div role="alert">{error}</div> : null}
    </div>
  )
}

export default Login

这是我想测试的React组件:这是它的渲染结果(它实际上是有效的,试试吧)。

这是一个测试套件,类似于我多年来看到的那种测试。

// __tests__/login.js
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

describe('Login', () => {
  let utils,
    handleSubmit,
    user,
    changeUsernameInput,
    changePasswordInput,
    clickSubmit

  beforeEach(() => {
    handleSubmit = jest.fn()
    user = {username: 'michelle', password: 'smith'}
    utils = render(<Login onSubmit={handleSubmit} />)
    changeUsernameInput = value =>
      userEvent.type(utils.getByLabelText(/username/i), value)
    changePasswordInput = value =>
      userEvent.type(utils.getByLabelText(/password/i), value)
    clickSubmit = () => userEvent.click(utils.getByText(/submit/i))
  })

  describe('when username and password is provided', () => {
    beforeEach(() => {
      changeUsernameInput(user.username)
      changePasswordInput(user.password)
    })

    describe('when the submit button is clicked', () => {
      beforeEach(() => {
        clickSubmit()
      })

      it('should call onSubmit with the username and password', () => {
        expect(handleSubmit).toHaveBeenCalledTimes(1)
        expect(handleSubmit).toHaveBeenCalledWith(user)
      })
    })
  })

  describe('when the password is not provided', () => {
    beforeEach(() => {
      changeUsernameInput(user.username)
    })

    describe('when the submit button is clicked', () => {
      let errorMessage
      beforeEach(() => {
        clickSubmit()
        errorMessage = utils.getByRole('alert')
      })

      it('should show an error message', () => {
        expect(errorMessage).toHaveTextContent(/password is required/i)
      })
    })
  })

  describe('when the username is not provided', () => {
    beforeEach(() => {
      changePasswordInput(user.password)
    })

    describe('when the submit button is clicked', () => {
      let errorMessage
      beforeEach(() => {
        clickSubmit()
        errorMessage = utils.getByRole('alert')
      })

      it('should show an error message', () => {
        expect(errorMessage).toHaveTextContent(/username is required/i)
      })
    })
  })
})

这应该给我们100%的信心,这个组件可以工作,并将继续按设计工作。而它确实如此。但我不喜欢这个测试的地方。

过度抽象化

我觉得像changeUsernameInputclickSubmit 这样的实用程序是很好的,但是测试足够简单,重复这些代码反而可以简化我们的测试代码。只是,对于这一小部分测试来说,函数的抽象化并没有给我们带来真正的好处,而且我们还得让维护者在文件中寻找这些函数的定义,这就造成了成本。

嵌套

上面的测试是用Jest的API编写的,但你会在所有主要的JavaScript框架中发现类似的API。我特别谈到了describe ,它用于分组测试,beforeEach ,用于普通设置/操作,it ,用于实际断言。

我非常不喜欢这样的嵌套。我写过和维护过数以千计的这样的测试,我可以告诉你,对于这三个简单的测试来说,这是很痛苦的,如果你有几千行的测试,并且最终嵌套得更多,那就更糟了。

是什么让它变得如此复杂?以这一点为例。

it('should call onSubmit with the username and password', () => {
  expect(handleSubmit).toHaveBeenCalledTimes(1)
  expect(handleSubmit).toHaveBeenCalledWith(user)
})

handleSubmit 从哪里来,它的价值是什么?user 从哪里来?它的价值是什么?哦,当然,你可以去找它的定义所在。

describe('Login', () => {
  let utils,
    handleSubmit,
    user,
    changeUsernameInput,
    changePasswordInput,
    clickSubmit
  // ...
})

但是,你必须弄清楚它在哪里被分配。

beforeEach(() => {
  handleSubmit = jest.fn()
  user = {username: 'michelle', password: 'smith'}
  // ...
})

然后,你必须确保它没有被分配给更多嵌套的其他东西beforeEach 。追踪代码以跟踪变量和它们的值是我强烈建议反对嵌套测试的首要原因。你越是要为这样的琐碎事情在脑子里记着,就越没有空间去完成手头的重要任务。

你可以争辩说,变量重配是一种 "反模式",应该避免,我同意你的观点,但是在你那套可能已经很霸道的提示规则中增加更多的提示规则并不是一个很好的解决方案。如果有一种方法可以共享这种共同的设置,而不必担心变量的重新分配呢?

内联它!

对于这个简单的组件,我认为最好的解决方案是尽可能地去掉抽象的东西。看看这个吧。

// __tests__/login.js
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

test('calls onSubmit with the username and password when submit is clicked', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText, getByText} = render(<Login onSubmit={handleSubmit} />)
  const user = {username: 'michelle', password: 'smith'}

  userEvent.type(getByLabelText(/username/i), user.username)
  userEvent.type(getByLabelText(/password/i), user.password)
  userEvent.click(getByText(/submit/i))

  expect(handleSubmit).toHaveBeenCalledTimes(1)
  expect(handleSubmit).toHaveBeenCalledWith(user)
})

test('shows an error message when submit is clicked and no username is provided', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText, getByText, getByRole} = render(
    <Login onSubmit={handleSubmit} />,
  )

  userEvent.type(getByLabelText(/password/i), 'anything')
  userEvent.click(getByText(/submit/i))

  const errorMessage = getByRole('alert')
  expect(errorMessage).toHaveTextContent(/username is required/i)
  expect(handleSubmit).not.toHaveBeenCalled()
})

test('shows an error message when submit is clicked and no password is provided', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText, getByText, getByRole} = render(
    <Login onSubmit={handleSubmit} />,
  )

  userEvent.type(getByLabelText(/username/i), 'anything')
  userEvent.click(getByText(/submit/i))

  const errorMessage = getByRole('alert')
  expect(errorMessage).toHaveTextContent(/password is required/i)
  expect(handleSubmit).not.toHaveBeenCalled()
})

注意:testit 的别名,我只是在没有嵌套到describe 时更喜欢使用test

你会注意到那里有一点重复(我们会讨论这个问题),但看看这些测试是多么清晰。除了一些测试工具和Login组件本身,整个测试是自成一体的。这极大地提高了我们理解每个测试的能力,而不需要做任何滚动的操作。如果这个组件再有几十个测试,其好处就更大了。

还请注意,我们没有将所有的东西嵌套在一个describe 块中,因为真的没有必要。文件中的所有内容显然都是在测试login组件,包括哪怕一个层次的嵌套都是毫无意义的。

应用AHA(避免草率的抽象)。

AHA原则指出,你应该。

宁愿重复也不要错误的抽象,并首先对变化进行优化。

对于我们这里简单的登录组件,我可能会保持测试的原样,但让我们想象一下,它有点复杂,我们开始看到一些代码重复的问题,我们想减少它。我们是否应该为此向beforeEach ?我的意思是,这就是它的作用,对吗?

好吧,我们可以,但是我们又得开始担心易变的变量分配了,我们想避免这种情况。我们还可以怎样在我们的测试之间共享代码呢?啊哈!我们可以使用函数!

import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

// here we have a bunch of setup functions that compose together for our test cases
// I only recommend doing this when you have a lot of tests that do the same thing.
// I'm including it here only as an example. These tests don't necessitate this
// much abstraction. Read more: https://kcd.im/aha-testing
function setup() {
  const handleSubmit = jest.fn()
  const utils = render(<Login onSubmit={handleSubmit} />)
  const user = {username: 'michelle', password: 'smith'}
  const changeUsernameInput = value =>
    userEvent.type(utils.getByLabelText(/username/i), value)
  const changePasswordInput = value =>
    userEvent.type(utils.getByLabelText(/password/i), value)
  const clickSubmit = () => userEvent.click(utils.getByText(/submit/i))
  return {
    ...utils,
    handleSubmit,
    user,
    changeUsernameInput,
    changePasswordInput,
    clickSubmit,
  }
}

function setupSuccessCase() {
  const utils = setup()
  utils.changeUsernameInput(utils.user.username)
  utils.changePasswordInput(utils.user.password)
  utils.clickSubmit()
  return utils
}

function setupWithNoPassword() {
  const utils = setup()
  utils.changeUsernameInput(utils.user.username)
  utils.clickSubmit()
  const errorMessage = utils.getByRole('alert')
  return {...utils, errorMessage}
}

function setupWithNoUsername() {
  const utils = setup()
  utils.changePasswordInput(utils.user.password)
  utils.clickSubmit()
  const errorMessage = utils.getByRole('alert')
  return {...utils, errorMessage}
}

test('calls onSubmit with the username and password', () => {
  const {handleSubmit, user} = setupSuccessCase()
  expect(handleSubmit).toHaveBeenCalledTimes(1)
  expect(handleSubmit).toHaveBeenCalledWith(user)
})

test('shows an error message when submit is clicked and no username is provided', () => {
  const {handleSubmit, errorMessage} = setupWithNoUsername()
  expect(errorMessage).toHaveTextContent(/username is required/i)
  expect(handleSubmit).not.toHaveBeenCalled()
})

test('shows an error message when password is not provided', () => {
  const {handleSubmit, errorMessage} = setupWithNoPassword()
  expect(errorMessage).toHaveTextContent(/password is required/i)
  expect(handleSubmit).not.toHaveBeenCalled()
})

现在我们可以有几十个使用这些简单的setup 函数的测试,并且注意到它们可以组成在一起,给我们一个类似于我们之前的嵌套beforeEach 的行为,如果这有意义。但是我们避免了有易变的变量,我们必须担心在我们的头脑中保持跟踪。

你可以从AHA测试中了解更多关于AHA与测试的好处。

分组测试是怎么回事?

describe 函数的目的是将相关的测试分组,可以提供一个很好的方式来视觉上分离不同的测试,特别是当测试文件变大时。但我不喜欢测试文件变大的时候。因此,我不按describe 块来分组,而是按文件分组。因此,如果对同一 "单元 "的代码有不同测试的逻辑分组,我会把它们放在完全不同的文件中,把它们分开。如果有一些代码真的需要在它们之间共享,那么我将创建一个__tests__/helpers/login.js ,其中有共享的代码。

这样做的好处是将测试进行逻辑分组,将任何独特的设置完全分开,减少我在工作中对代码单元的特定部分的认知负担,如果你的测试框架可以并行运行测试,那么我的测试可能也会运行得更快。

清理工作怎么办?

这篇博文并不是对beforeEach/afterEach/等工具的攻击。它更像是对测试中可变变量的告诫,并注意你的抽象概念。

对于清理,有时你会遇到这样的情况:你正在测试的东西对全局环境做了一些改变,你需要在它之后进行清理。如果你试图把这段代码放在你的测试中,那么测试失败会导致你的清理工作不能运行,这可能会导致其他测试失败,最终导致大量的错误输出,更难以调试。

注意:这个例子是在@testing-library/react@9 之前写的,它使清理工作自动进行。但这个概念仍然适用,我不想重写这个例子😅。

例如,React测试库会将你的组件插入到文档中,如果你在每次测试后不进行清理,那么你的测试就会自己跑过去。

import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

test('example 1', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  userEvent.type(getByLabelText(/password/i), 'ilovetwix')
  // more test here
})

test('example 2', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  // 💣 this will blow up because the `getByLabelText` is actually querying the
  // entire document, and because we didn't cleanup after the previous test
  // we'll get an error indicating that RTL found more than one field with the
  // label "username"
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  // more test here
})

cleanup 解决这个问题很简单,你需要在每次测试后执行@testing-library/react 的方法。

import {cleanup, render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

test('example 1', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  userEvent.type(getByLabelText(/password/i), 'ilovetwix')
  // more test here
  cleanup()
})

test('example 2', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  // more test here
  cleanup()
})

然而,如果你使用afterEach 来做这件事,那么如果测试失败,你的清理工作就不会运行,像这样。

test('example 1', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  // 💣 the following typo will result in a error thrown:
  //   "no field with the label matching passssword"
  userEvent.type(getByLabelText(/passssword/i), 'ilovetwix')
  // more test here
  cleanup()
})

因为这样,"例子1 "中的cleanup 函数将不会运行,然后 "例子2 "也不会正常运行,所以你不会只看到一个测试失败,而是看到所有的测试都失败了,这将使调试变得更加困难。

因此,你应该使用afterEach ,这将确保即使你的测试失败了,你也可以进行清理。

import {cleanup, render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import Login from '../login'

afterEach(() => cleanup())

test('example 1', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  userEvent.type(getByLabelText(/password/i), 'ilovetwix')
  // more test here
})

test('example 2', () => {
  const handleSubmit = jest.fn()
  const {getByLabelText} = render(<Login onSubmit={handleSubmit} />)
  userEvent.type(getByLabelText(/username/i), 'kentcdodds')
  // more test here
})

更棒的是,使用React测试库。cleanup 默认情况下,每次测试后都会自动调用在文档中了解更多

此外,有时候,before* 肯定有很好的用例,但它们通常是与一个after* 中必须的清理相匹配的。比如启动和停止一个服务器。

let server
beforeAll(async () => {
  server = await startServer()
})
afterAll(() => server.close())

其实没有任何其他可靠的方法可以做到这一点。我可以想到的另一个使用这些钩子的情况是测试console.error

beforeAll(() => {
  jest.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
  console.error.mockClear()
})

afterAll(() => {
  console.error.mockRestore()
})

所以这些钩子肯定是有用处的。我只是不建议把它们作为代码重用的机制。我们有相应的功能。

总结

我希望这有助于澄清我在这条推文中的意思:

这种模式:test('whatever', () => { const foo = someThing() // use foo })比:let foo beforeEach(() => { foo = someThing() }) test('whatever', () => { // use foo }) 更简单的测试基础,避免可变变量。你的测试将更容易理解