为什么我从不使用浅层渲染

122 阅读10分钟

我记得几年前,当我开始使用React时,我决定我需要弄清楚如何测试React组件。我尝试了shallow从enzyme,并立即决定,我永远不会用它来测试我的React组件。我在很多场合表达过这种感觉,也经常有人问我为什么对shallow 渲染有这样的感觉,为什么React测试库永远不支持shallow 渲染。

所以,我终于出来了,并解释了为什么我从不使用浅层渲染,以及为什么我认为其他人也不应该使用。以下是我的主要论断。

使用浅层渲染,我可以重构我的组件的实现,而我的测试却会中断。使用浅层渲染,我可以破坏我的应用程序,而我的测试说一切都在工作。

这让我非常担心,因为它不仅使测试变得令人沮丧,而且还使你陷入一种错误的安全感。我写测试的原因是对我的应用程序的工作有信心,而且有比浅层渲染更好的方法来做到这一点。

什么是浅层渲染?

为了本文的目的,让我们用这个例子作为我们的测试对象:

import * as React from 'react'
import {CSSTransition} from 'react-transition-group'

function Fade({children, ...props}) {
  return (
    <CSSTransition {...props} timeout={1000} className="fade">
      {children}
    </CSSTransition>
  )
}

class HiddenMessage extends React.Component {
  static defaultProps = {initialShow: false}
  state = {show: this.props.initialShow}
  toggle = () => {
    this.setState(({show}) => ({show: !show}))
  }
  render() {
    return (
      <div>
        <button onClick={this.toggle}>Toggle</button>
        <Fade in={this.state.show}>
          <div>Hello world</div>
        </Fade>
      </div>
    )
  }
}

export {HiddenMessage}

下面是一个使用浅层渲染和酶的测试例子:

import * as React from 'react'
import Enzyme, {shallow} from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import {HiddenMessage} from '../hidden-message'

Enzyme.configure({adapter: new Adapter()})

test('shallow', () => {
  const wrapper = shallow(<HiddenMessage initialShow={true} />)
  expect(wrapper.find('Fade').props()).toEqual({
    in: true,
    children: <div>Hello world</div>,
  })
  wrapper.find('button').simulate('click')
  expect(wrapper.find('Fade').props()).toEqual({
    in: false,
    children: <div>Hello world</div>,
  })
})

为了理解浅层渲染,让我们添加一个console.log(wrapper.debug()),它将记录出酶为我们所渲染的结构:

<div>
  <button onClick={[Function]}>Toggle</button>
  <Fade in={true}>
    <div>Hello world</div>
  </Fade>
</div>

你会注意到,它实际上并没有显示CSSTransition ,而这正是Fade 所渲染的。这是因为浅层并没有实际渲染组件并调用到Fade 组件,而只是查看了将应用于你浅层渲染的组件所创建的React元素的道具。事实上,如果我把HiddenMessage组件的render 方法和console.log 它所返回的东西,我得到的东西看起来有点像这样:

{
  "type": "div",
  "props": {
    "children": [
      {
        "type": "button",
        "props": {
          "onClick": [Function],
          "children": "Toggle"
        }
      },
      {
        "type": [Function: Fade],
        "props": {
          "in": true,
          "children": {
            "type": "div",
            "props": {
              "children": "Hello world"
            }
          }
        }
      }
    ]
  }
}

看起来很熟悉吗?因此,浅层渲染所做的是采取给定组件的render 方法的结果(这将是一个React元素(阅读什么是JSX? )),并给我们一个wrapper 对象,以及一些用于遍历这个JavaScript对象的工具。这意味着它不会运行生命周期方法(因为我们只有React元素要处理),它不允许你实际与DOM元素互动(因为没有什么实际渲染),而且它实际上并不试图获得由你的自定义组件(如我们的Fade 组件)返回的反应元素。

为什么人们使用浅层渲染

当我很早就决定不使用浅层渲染时,是因为我知道有更好的方法来获得浅层渲染所带来的便利,而不需要像浅层渲染那样迫使你做出取舍。我最近问了一些人,告诉我他们为什么使用浅层渲染。下面是一些浅层渲染使之更容易的事情:

  1. ...在React组件中调用方法
  2. ......为每个测试组件的所有子节点渲染数百/数千次,似乎是一种浪费。
  3. 对于实际的单元测试。测试组成的组件会引入新的依赖关系,可能会引发错误,而单元本身可能仍然按计划工作。

还有更多的回答,但这些总结了使用浅层渲染的主要论点。让我们来逐一解决这些问题。

调用反应组件中的方法

你见过或写过像这样的测试吗?

test('toggle toggles the state of show', () => {
  const wrapper = shallow(<HiddenMessage initialShow={true} />)
  expect(wrapper.state().show).toBe(true) // initialized properly
  wrapper.instance().toggle()
  wrapper.update()
  expect(wrapper.state().show).toBe(false) // toggled
})

这是使用浅层渲染的一个很好的理由,但这是一个非常糟糕的测试实践。有两件非常重要的事情是我在测试时努力考虑的。

  1. 当有一个错误会破坏生产中的组件时,这个测试会不会中断?
  2. 当组件有一个完全向后兼容的重构时,这个测试会继续工作吗?

这种测试在这两方面的考虑上都失败了。

  1. 我可以错误地将buttononClick 设置为this.tgogle 而不是this.toggle 。我的测试继续工作,但我的组件却坏了。
  2. 我可以将toggle 改名为handleButtonClick (并更新相应的onClick 参考)。尽管这是一个重构,但我的测试还是失败了。

这种测试之所以不能通过这些考虑,是因为它在测试不相关的实现细节。用户一点都不关心事情的名称。事实上,该测试甚至没有验证当show 状态为false 时,消息是否被正确隐藏,或者当show 状态为true 时,消息是否被显示。因此,该测试不仅没有很好地保护我们免受破坏,而且还很虚弱,实际上没有测试该组件首先存在的原因。

总之,如果你的测试使用instance()state() ,要知道你在测试用户不可能知道或甚至不关心的东西,这将使你的测试进一步让你相信,当你的用户使用它们时,东西会工作。

...这似乎是一种浪费...

浅层渲染比任何其他形式的反应组件测试都要快,这是无法回避的事实。它当然比安装一个反应组件快得多。但我们在这里谈论的是少数几个毫秒的时间。是的,它会增加,但我很乐意等待额外的几秒钟或几分钟来完成我的测试,以换取我的测试真正给我信心,当我把它运送给用户时,我的应用程序会工作。

除此之外,在开发测试时,你可能应该使用Jest的功能,只运行与你的变化相关的测试,这样在本地运行测试套件时,就不会感觉到差异。

对于实际的单元测试

这是一个非常普遍的误解。"要对反应组件进行单元测试,你必须使用浅层渲染,这样其他组件就不会被渲染。"浅层渲染确实不会渲染其他组件(如上图所示),这样做的问题在于,它太重了,因为它不会渲染任何其他组件。你没有选择。

浅层渲染不仅不渲染第三方组件,它甚至不渲染文件内的组件。例如,我们上面的<Fade /> 组件是<HiddenMessage /> 组件的一个实现细节,但是因为我们的浅层渲染<Fade /> 没有被渲染,所以对该组件的修改可能会破坏我们的应用程序,但不会破坏我们的测试。这在我看来是个大问题,对我来说是我们在测试实现细节的证据。

此外,你绝对可以在没有浅层渲染的情况下对反应组件进行单元测试。请看接近结尾的部分,有一个这样的测试例子(使用React测试库,但你也可以用enzyme来做),它使用Jest mocking来模拟出<CSSTransition /> 组件。

我需要补充的是,我通常反对100%地嘲弄第三方组件。我经常听到的关于模拟第三方组件的论点是:测试组成的组件引入了新的依赖关系,可能会引发错误,而单元本身可能仍然按计划工作。 但测试的重点不就是要确信应用程序能够工作吗?如果应用程序坏了,谁会在乎你的单元是否工作?我肯定想知道我所使用的第三方组件是否破坏了我的用例。我的意思是,我不打算重写他们的整个测试基础,但如果我可以通过模拟他们的组件来轻松地测试我的用例,那么为什么不这样做,以获得额外的信心?

我还应该补充一点,我赞成更多地依赖集成测试。 当你这样做的时候,你需要单元测试的简单组件较少,最终只需要单元测试组件的边缘案例(它们可以随意模拟)。但即使在这些情况下,我仍然认为,当你明确哪些组件被模拟,哪些组件被渲染时,通过做完全的安装和明确的模拟,会带来更多的信心和更可维护的测试基地。

没有浅层渲染

我非常相信React测试库的指导原则。

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

这就是为什么我一开始就写了这个库。作为这篇浅显的渲染文章的侧记,我想提一下,你有更少的方法来做用户不可能做的事情。下面是React测试库不能做的事情列表(开箱即用)。

  1. 浅层渲染
  2. 静态渲染(如enzyme的render功能)。
  3. 酶的大部分方法都是用来查询元素(如find),其中包括通过组件类甚至它的displayName(再次,用户不关心你的组件被称为什么,你的测试也不应该关心)。注意:React测试库支持查询元素的方式,以鼓励你的组件的可访问性和更可维护的测试。
  4. 获取一个组件实例(如酶的instance)
  5. 获取和设置一个组件的props (props())
  6. 获取和设置组件的状态 (state())

所有这些都是你的组件的用户不能做的事情,所以你的测试也不应该做这些。下面是对<HiddenMessage />组件的测试,它与用户使用你的组件的方式更接近。此外,它还可以验证你是否正确地使用了<CSSTransition />(这是浅层渲染的例子所不能做到的)。

import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import {CSSTransition} from 'react-transition-group'

import {HiddenMessage} from '../hidden-message'

// NOTE: you do NOT have to do this in every test.
// Learn more about Jest's __mocks__ directory:
// https://jestjs.io/docs/en/manual-mocks
jest.mock('react-transition-group', () => {
  return {
    CSSTransition: jest.fn(({children, in: show}) => (show ? children : null)),
  }
})

test('you can mock things with jest.mock', () => {
  render(<HiddenMessage initialShow={true} />)
  const toggleButton = screen.getByText(/toggle/i)

  const context = expect.any(Object)
  const children = expect.any(Object)
  const defaultProps = {children, timeout: 1000, className: 'fade'}

  expect(CSSTransition).toHaveBeenCalledWith(
    {in: true, ...defaultProps},
    context,
  )
  expect(screen.getByText(/hello world/i)).not.toBeNull()

  CSSTransition.mockClear()

  userEvent.click(toggleButton)

  expect(screen.queryByText(/hello world/i)).toBeNull()
  expect(CSSTransition).toHaveBeenCalledWith(
    {in: false, ...defaultProps},
    context,
  )
})

总结

几周前,在我的DevTipsWithKent(我在YouTube上的每周直播)中,我直播了"从浅层渲染的反应组件迁移到显式组件嘲讽"。 在那里,我演示了我上面描述的浅层渲染的一些陷阱,以及如何使用jest嘲讽。

我希望这是有帮助的我们都在尽力为用户提供良好的体验。我祝愿你在这一努力中好运

P.S.

有人提出了这个问题。

浅层包装器对于测试小的独立组件是很好的。有了适当的序列化器,就可以获得清晰易懂的快照。

我很少在react中使用快照测试,我当然也不会在shallow中使用它。那是一个实现细节的秘诀。整个快照除了实现细节之外什么都没有(它充满了组件和道具名称,这些名称在重构时一直在变化)。只要你一碰这个组件,它就会失败,而且快照的git diff看起来和你对这个组件的修改几乎一样。

这将使人们不在乎对快照的修改,因为它们一直在变化。所以它基本上是没有价值的(几乎比没有测试更糟糕,因为它让你认为你已经被覆盖了,而你并没有,而且你也不会因为他们已经到位而写出适当的测试)。

我确实认为快照是有用的。关于我的更多信息,请查看另一篇博文。

有效的快照测试

我希望这对你有帮助!