前端应用程序的静态测试与单元测试与集成测试与E2E测试

193 阅读8分钟

TestingJavaScript.com上提供的 "与J.B. Rainsberger的测试实践 "的采访中,他给了我一个我非常喜欢的比喻。他说。

你可以把油漆扔到墙上,最终你可能会得到墙的大部分,但除非你拿着刷子走到墙边,否则你永远不会得到墙角。🖌️

我喜欢这个比喻,它适用于测试,因为它基本上是说,选择正确的测试策略与选择刷墙的刷子是同样的选择。你会用一把细点的刷子来刷整面墙吗?当然不会。那会花费太多时间,而且最终的结果可能看起来不是很均匀。你会用滚筒来刷所有的东西吗,包括你的曾曾祖母两百年前从海上带来的固定家具周围?不可能。不同的使用情况有不同的刷子,同样的事情也适用于测试。

这就是为什么我创建了测试奖杯。 此后,Maggie Appletonegghead.io的艺术/设计大师)为TestingJavaScript.com创建了这个奖杯。

The Testing
Trophy

在Testing Trophy中,有4种类型的测试。上面显示了这段文字,但为了那些使用辅助技术的人(以及防止图片无法加载给你),我将在这里从上到下写出它的内容:

  • 端到端:一个辅助机器人,表现得像一个用户,在应用程序周围点击,并验证其功能是否正确。有时称为 "功能测试 "或e2e。
  • 集成:验证几个单元是否和谐地一起工作。
  • 单元:验证单独的、孤立的部分按预期工作。
  • 静态:在你写代码的时候捕捉错别字和类型错误。

这些测试形式在战利品上的大小是相对于你在测试你的应用程序时(一般)应该给予它们的关注程度而言的。我想深入了解一下这些不同形式的测试,它的实际意义,以及我们可以做什么来优化我们的测试,以获得最大的收益。

测试类型

让我们看看这些测试的几个例子,从上到下。

端到端

一般来说,这些测试将运行整个应用程序(包括前端和后端),你的测试将与应用程序互动,就像一个典型的用户会。这些测试是用cypress编写的。

import {generate} from 'todo-test-utils'

describe('todo app', () => {
  it('should work for a typical user', () => {
    const user = generate.user()
    const todo = generate.todo()
    // here we're going through the registration process.
    // I'll typically only have one e2e test that does this.
    // the rest of the tests will hit the same endpoint
    // that the app does so we can skip navigating through that experience.
    cy.visitApp()

    cy.findByText(/register/i).click()

    cy.findByLabelText(/username/i).type(user.username)

    cy.findByLabelText(/password/i).type(user.password)

    cy.findByText(/login/i).click()

    cy.findByLabelText(/add todo/i)
      .type(todo.description)
      .type('{enter}')

    cy.findByTestId('todo-0').should('have.value', todo.description)

    cy.findByLabelText('complete').click()

    cy.findByTestId('todo-0').should('have.class', 'complete')
    // etc...
    // My E2E tests typically behave similar to how a user would.
    // They can sometimes be quite long.
  })
})

集成

下面的测试渲染了整个应用程序。这不是集成测试的要求,我的大多数集成测试都不渲染完整的应用程序。然而,他们将渲染我的应用程序中使用的所有提供者(这就是想象中的 "test/app-test-utils"模块中的render方法)。集成测试背后的想法是尽可能少地模拟。我几乎只模拟:

  1. 网络请求(使用MSW)
  2. 负责动画的组件(因为谁想在你的测试中等待这个呢?
import * as React from 'react'
import {render, screen, waitForElementToBeRemoved} from 'test/app-test-utils'
import userEvent from '@testing-library/user-event'
import {build, fake} from '@jackfranklin/test-data-bot'
import {rest} from 'msw'
import {setupServer} from 'msw/node'
import {handlers} from 'test/server-handlers'
import App from '../app'

const buildLoginForm = build({
  fields: {
    username: fake(f => f.internet.userName()),
    password: fake(f => f.internet.password()),
  },
})

// integration tests typically only mock HTTP requests via MSW
const server = setupServer(...handlers)

beforeAll(() => server.listen())
afterAll(() => server.close())
afterEach(() => server.resetHandlers())

test(`logging in displays the user's username`, async () => {
  // The custom render returns a promise that resolves when the app has
  //   finished loading (if you're server rendering, you may not need this).
  // The custom render also allows you to specify your initial route
  await render(<App />, {route: '/login'})
  const {username, password} = buildLoginForm()

  userEvent.type(screen.getByLabelText(/username/i), username)
  userEvent.type(screen.getByLabelText(/password/i), password)
  userEvent.click(screen.getByRole('button', {name: /submit/i}))

  await waitForElementToBeRemoved(() => screen.getByLabelText(/loading/i))

  // assert whatever you need to verify the user is logged in
  expect(screen.getByText(username)).toBeInTheDocument()
})

对于这些,我通常也会在全局范围内配置一些东西,比如在测试之间自动重设所有的mock

单元

import '@testing-library/jest-dom/extend-expect'
import * as React from 'react'
// if you have a test utils module like in the integration test example above
// then use that instead of @testing-library/react
import {render, screen} from '@testing-library/react'
import ItemList from '../item-list'

// Some people don't call these a unit test because we're rendering to the DOM with React.
// They'd tell you to use shallow rendering instead.
// When they tell you this, send them to https://kcd.im/shallow
test('renders "no items" when the item list is empty', () => {
  render(<ItemList items={[]} />)
  expect(screen.getByText(/no items/i)).toBeInTheDocument()
})

test('renders the items in a list', () => {
  render(<ItemList items={['apple', 'orange', 'pear']} />)
  // note: with something so simple I might consider using a snapshot instead, but only if:
  // 1. the snapshot is small
  // 2. we use toMatchInlineSnapshot()
  // Read more: https://kcd.im/snapshots
  expect(screen.getByText(/apple/i)).toBeInTheDocument()
  expect(screen.getByText(/orange/i)).toBeInTheDocument()
  expect(screen.getByText(/pear/i)).toBeInTheDocument()
  expect(screen.queryByText(/no items/i)).not.toBeInTheDocument()
})

每个人都把这称为单元测试,他们是对的:

// pure functions are the BEST for unit testing and I LOVE using jest-in-case for them!
import cases from 'jest-in-case'
import fizzbuzz from '../fizzbuzz'

cases(
  'fizzbuzz',
  ({input, output}) => expect(fizzbuzz(input)).toBe(output),
  [
    [1, '1'],
    [2, '2'],
    [3, 'Fizz'],
    [5, 'Buzz'],
    [9, 'Fizz'],
    [15, 'FizzBuzz'],
    [16, '16'],
  ].map(([input, output]) => ({title: `${input} => ${output}`, input, output})),
)

静态

// can you spot the bug?
// I'll bet ESLint's for-direction rule could
// catch it faster than you in a code review 😉
for (var i = 0; i < 10; i--) {
  console.log(i)
}

const two = '2'
// ok, this one's contrived a bit,
// but TypeScript will tell you this is bad:
const result = add(1, two)

为什么我们要再次测试?

我认为重要的是要记住为什么我们首先要写测试。为什么要写测试?是因为我告诉你要写吗?是因为你的PR不包含测试就会被拒绝吗?是不是因为测试可以增强你的工作流程?

我写测试的最大和最重要的原因是信心。我想确信我为未来编写的代码不会破坏我今天在生产中运行的应用程序。因此,无论我做什么,我都想确保我写的测试种类能给我带来最大的信心,我需要认识到我在测试时的权衡。

让我们来谈谈权衡的问题

在这张图片(从我的幻灯片上撕下来的)中,我想指出测试战利品的一些重要因素。

The Testing Trophy with arrows indicating the trade-offs

图片上的箭头标志着你在编写自动化测试时做出的三种权衡。

成本: ¢堆 ➡ 💰🤑💰

随着你在测试领域的发展,测试的成本会越来越高。这包括在持续集成环境中运行测试的实际费用,也包括工程师编写和维护每个测试的时间。

你的奖杯越高,故障点就越多,因此测试就越有可能发生故障,导致需要更多的时间来分析和修复测试。牢记这一点,因为这很重要#foreshadowing...

速度: 🏎💨 ➡ 🐢

当你向上移动测试战利品时,测试通常运行得比较慢。这是由于你在测试奖杯上的位置越高,你的测试运行的代码就越多。单元测试通常测试一些没有依赖性的小东西,或者会模拟这些依赖性(有效地将可能是数千行的代码换成只有几行)。请记住这一点,因为这很重要#foreshadowing...

信心。简单问题 👌 ➡ 大问题 😖

当人们谈论测试金字塔时,通常会提到成本和速度的权衡 🔺。如果这些是唯一的权衡,那么我会把100%的精力放在单元测试上,而完全忽略任何其他形式的测试。当然,我们不应该这样做,这是因为一个超级重要的原则,你可能已经听我说过了。

你的测试越像你的软件的使用方式,他们就越能给你信心。

这意味着什么呢?这意味着,没有比实际让你的玛丽阿姨使用你的税务软件报税更好的方法了。但是,我们不想等着玛丽阿姨为我们找到漏洞,对吗?这将花费太多时间,而且她可能会错过一些我们应该测试的功能。再加上我们定期发布软件更新的事实,任何数量的人都不可能跟上。

所以我们怎么做呢?我们做出权衡。我们如何做到这一点呢?我们写软件来测试我们的软件。当我们这样做的时候,我们总是在做权衡,现在我们的测试并不像我们有玛丽阿姨测试我们的软件时那样可靠地使用我们的软件。但我们这样做是因为我们用这种方法解决了我们的实际问题。这就是我们在测试奖杯的每个层次上所做的事情。

**当你在测试奖杯上移动时,你正在增加我称之为 "信心系数 "的东西。**这是每个测试能让你在该级别获得的相对信心。你可以想象,在奖杯上面的是手工测试。 这将使你从这些测试中获得真正的巨大信心,但测试将是非常昂贵和缓慢。

早些时候我告诉你要记住两件事:

  • 你的奖杯越高,失败点就越多,因此,测试就越有可能出现问题。
  • 单元测试通常测试一些没有依赖性的小东西,或者模拟那些依赖性(有效地将可能是数千行的代码换成只有几行)。

这些人说的是,你的战利品越低,你的测试所测试的代码就越少。如果你在低水平上操作,你需要更多的测试来覆盖你的应用程序中相同数量的代码,因为一个单一的测试可以在更高的奖杯上。事实上,当你在测试奖杯上越往下走,有一些东西是不可能被测试的。

特别是,静态分析工具没有能力让你对你的业务逻辑有信心。单元测试无法确保当你调用一个依赖关系时,你会适当地调用它(尽管你可以断言它是如何被调用的,但你无法确保它被单元测试正确地调用)。UI集成测试无法确保你向后端传递正确的数据,也无法确保你对错误做出正确的反应和解析。端到端测试是非常有能力的,但通常你会在非生产环境中运行这些测试(类似生产环境,但不是生产环境),以权衡这种信心和实用性。

现在让我们走另一条路。在测试战利品的顶端,如果你试图使用E2E测试来检查在某个字段中打字并点击提交按钮的表单和URL生成器之间的集成的边缘情况,你通过运行整个应用程序(包括后端)来做大量的设置工作。这可能更适合于集成测试。如果你试图用集成测试来处理优惠券代码计算器的边缘情况,你很可能在设置函数中做了相当多的工作,以确保你能渲染使用优惠券代码计算器的组件,你可以在单元测试中更好地覆盖这个边缘情况。如果你试图用单元测试来验证当你用字符串而不是数字来调用你的添加函数时会发生什么,那么你最好使用TypeScript这样的静态类型检查工具。

总结

每个级别都有自己的权衡。E2E测试有更多的故障点,通常更难追踪到是什么代码导致的故障,但这也意味着你的测试给你更多的信心。如果你没有那么多时间来写测试,这就特别有用。我宁愿有信心,但要面对追踪它失败的原因,而不是一开始就没有通过测试发现问题。

最后,**我并不真正关心这些区别。**如果你想把我的单元测试称为集成测试,甚至E2E测试(就像有些人说的🤷♂️),那就这样吧。我感兴趣的是,我是否有信心在发货时,我的代码能满足业务需求,我将使用不同的测试策略的混合来实现这一目标。

祝您好运!