如何使用TDD和React Testing Library构建健壮的React应用程序

如何使用TDD和React Testing Library构建健壮的React应用程序

当我开始学习React时,我努力的一件事就是以一种既有用又直观的方式来测试我的web应用程序。 每次我想测试它时,我都会用Jest的 Enzyme来渲染一个组件。

当然,我绝对滥用快照测试功能。

那么,至少我写了一个测试吧?

您可能听说过编写单元和集成测试会提高您编写的软件的质量。 另一方面,不好的测试会产生错误的信心。

最近,我通过与Kent C. Dodds的workshop.me参加了一个研讨会,他在那里教会我们如何为React应用程序编写更好的集成测试。

他还引诱我们使用他的新测试库 ,赞成强调测试应用程序,就像用户遇到它一样。

在本文中,我们将学习如何运用TDD,以便通过创建注释提要来构建可靠的React应用程序。 当然,这个过程几乎适用于所有软件开发,而不仅仅是React或JavaScript应用程序。

入门

我们将开始运行create-react-app并安装依赖关系。 我的假设是,如果您正在阅读关于测试应用程序的文章,那么您可能已经熟悉安装和启动JavaScript项目。 我会在这里使用yarn而不是npm 。

create-react-app comment-feed
cd comment-feed
yarn

现在,我们可以删除除index.js之外的src目录中的所有文件。 然后,在src文件夹内,创建一个名为components文件夹和另一个名为containers文件夹。

为了测试实用程序,我将使用Kent的React Testing Library构建此应用程序。 这是一个轻量级的测试实用程序,鼓励开发人员使用与测试应用程序相同的方式测试应用程序。

就像Enzyme一样,它会导出一个渲染函数,但这个渲染函数总是会完成一个完整的组件。 它会导出帮助程序方法,允许您通过标签或文本甚至测试标识来定位元素。 Enzyme和它的mount API一样,但它创建的抽象概念实际上提供了更多的选择,其中许多选项允许你脱离测试实现的细节。

我们不想再测试实施细节。 我们想渲染一个组件,并查看当我们点击或改变UI上的某些东西时是否发生了正确的事情。 而已! 不再直接检查道具或州或类名称。

让我们安装它们并开始工作。

yarn add react-testing-library

用TDD构建注释馈送

我们来做这个第一个组件TDD风格。 启动你的测试Runner。

yarn test --watch

在containers文件夹中,我们将添加一个名为CommentFeed.js的文件。
除此之外,添加一个名为CommentFeed.test.js的文件。 对于第一次测试,我们来验证用户是否可以创建评论。
太快了? 好的,由于我们还没有任何代码,我们将从较小的测试开始。 我们来检查一下,我们可以渲染Feed。

import React from 'react'
import { render } from 'react-testing-library'
import CommentFeed from './CommentFeed'

describe('CommentFeed', () => {
  it('renders the CommentFeed', () => {
    const { queryByText } = render(<CommentFeed />)
    const header = queryByText('Comment Feed')
    expect(header.innerHTML).toBe('Comment Feed')
  })
})

关于React测试库的一些注意事项

首先,我们在这里注意渲染功能。 它与react-dom在DOM上呈现组件的方式类似,但它返回一个对象,我们可以对其进行解构以获得一些整洁的测试助手。 在这种情况下,我们得到queryByText ,给定我们期望在DOM上看到的一些文本,将返回该HTML元素。

React Testing Library文档有一个层次结构,可帮助您决定使用哪种查询或获取方法。 一般来说,顺序是这样的:

getByLabelText (表单输入)
getByPlaceholderText (仅当您的输入没有标签时 - 不易访问!)
getByText (按钮和标题)
getByAltText (图片)
getByTestId (用于动态文本或其他奇怪元素的测试) 

其中每一个都有一个关联的queryByFoo ,它可以完成相同的工作,除非在没有找到元素的情况下不会失败。 如果您只是测试元素的存在 ,请使用这些。

如果这些都不会让你确切地知道你在找什么, render方法也会返回映射到container属性的DOM元素,所以你可以像container.querySelector('body #root')一样使用它。

第一个实施守则

现在,实现看起来相当简单。 我们只需确保“注释馈送(Comment Feed)”位于组件中。

import React, { Component } from 'react'

export default class CommentFeed extends Component {
  render() {
    const { header } = this.props
    return (
      <div>
        <h2>{header}/h2>
      </div>
    )
  }
}

这可能会更糟 - 我的意思是,我正在编写组件样式时编写整篇文章。 幸运的是,测试并不太在意样式,所以我们可以专注于我们的应用程序逻辑。

接下来的测试将验证我们可以提供评论。 但我们没有任何评论,所以我们也要添加该组件经过测试。

我还将创建一个道具对象来存储我们可能在这些测试中重用的数据。

import React from 'react'
import { render } from 'react-testing-library'
import CommentFeed from './CommentFeed'

describe('CommentFeed', () => {
  const props = { header: 'Comment Feed', comments: [] }

  it('renders the CommentFeed', () => {
    const { queryByText } = render(<CommentFeed {...props} />)
    const header = queryByText(props.header)
    expect(header.innerHTML).toBe(props.header)
  })

  it('renders the comment list', () => {
    const { container } = render(<CommentFeed {...props} />)
    const commentNodes = container.querySelectorAll('.Comment')
    expect(commentNodes.length).toBe(props.comments.length)
  })
})

在这种情况下,我正在检查评论数量是否等于传递给评论文章的数量。
这很简单,但是测试失败让我们有机会创建Comment.js文件。

 import React from 'react'

const Comment = props => {
  return (
    <div className="Comment">
      <h4>{props.author}</h4>
      <p>{props.text}</p>
    </div>
  )
}

export default Comment

这个绿色(译者注:这里是指测试通过之后时终端回馈提醒背景是绿色的)点亮了我们的测试套件,所以我们可以毫无畏惧地继续前进 所有人都热衷于TDD,这是我们这种救世主。 当然,当我们给它一个空数组时,它是有效的。 但是如果我们给它一些真实的东西呢?

describe('CommentFeed', () => {
  /* ... */

  it('renders the comment list with some entries', () => {
    let comments = [
      {
        author: 'Ian Wilson',
        text: 'A boats a boat but a mystery box could be anything.'
      },
      {
        author: 'Max Powers Jr',
        text: 'Krypton sucks.'
      }
    ]
    props = { header: 'Comment Feed', comments }
    const { container } = render(<CommentFeed {...props} />)
    const commentNodes = container.querySelectorAll('.Comment')
    expect(commentNodes.length).toBe(props.comments.length)
  })
})

我们必须更新我们的实现来实际渲染东西。 现在很简单,知道我们要去哪里,对吗?

import React, { Component } from 'react'
import Comment from '../components/Comment'

export default class CommentFeed extends Component {
  renderComments() {
    return this.props.comments.map((comment, i) => (
      <Comment key={i} {...comment} />
    ))
  }

  render() {
    const { header } = this.props
    return (
      <div className="CommentFeed">
        <h2>{header}</h2>
        <div className="comment-list">
          {this.renderComments()}
        </div>
      </div>
    )
  }
}

呃看看那个,我们的测试再次通过。 这是一个美丽的照片。

请注意,我从来没有说过,我们应该用bash yarn start我们的程序? 我们将保持这种方式一段时间。 关键是,你必须在心中感受代码。

以防万一您想要启动应用程序,请将index.js更新为以下内容:

import React from 'react'
import ReactDOM from 'react-dom'
import CommentFeed from './containers/CommentFeed'

const comments = [
  {
    author: 'Ian Wilson',
    text: 'A boats a boat but a mystery box could be anything.'
  },
  {
    author: 'Max Powers Jr',
    text: 'Krypton sucks.'
  },
  {
    author: 'Kent Beck',
    text: 'Red, Green, Refactor.'
  }
]

ReactDOM.render(
  <CommentFeed comments={comments} />,
  document.getElementById('root')
)

添加评论表

这是事情开始变得更有趣的地方。 这就是我们从困倦地检查DOM节点的存在到真正做这些事情并验证行为的地方 。 所有其他的东西都是热身。

我们先从这个表格描述我想要的内容。 这应该:

* 包含作者的文本输入
* 包含一个文本输入然后评论自己
* 有一个提交按钮
* 最终调用API或任何服务处理创建和存储评论。 

我们可以在单个集成测试中取消此列表。 对于之前的测试案例,我们采用它的速度相当缓慢,但现在我们要加快步伐,并尝试一举攻下它。

注意我们的测试套件是如何发展的? 我们从自己的测试案例中的硬编码道具走向为他们创建工厂。

Arrange, Act, Assert

以下集成测试可以分为三部分:Arrange, Act, Assert。

Arrange:为测试用例创建道具和其他固定装置
Act:模拟对文本输入或按钮点击等元素的更改
Asssert:断言期望的函数被调用了正确的次数,并且具有正确的参数 
import React from 'react'
import { render, Simulate } from 'react-testing-library'
import CommentFeed from './CommentFeed'

// props factory to help us arrange tests for this component
const createProps = props => ({
  header: 'Comment Feed',
  comments: [
    {
      author: 'Ian Wilson',
      text: 'A boats a boat but a mystery box could be anything.'
    },
    {
      author: 'Max Powers Jr',
      text: 'Krypton sucks.'
    }
  ],
  createComment: jest.fn(),
  ...props
})

describe('CommentFeed', () => {
  /* ... */

  it('allows the user to add a comment', () => {
    // Arrange - create props and locate elements
    const newComment = { author: 'Socrates', text: 'Why?' }
    let props = createProps()
    const { container, getByLabelText } = render(
      <CommentFeed {...props} />
    )

    const authorNode = getByLabelText('Author')
    const textNode = getByLabelText('Comment')
    const formNode = container.querySelector('form')

    // Act - simulate changes to elements
    authorNode.value = newComment.author
    textNode.value = newComment.text

    Simulate.change(authorNode)
    Simulate.change(textNode)

    Simulate.submit(formNode)

    // Assert - check whether the desired functions were called
    expect(props.createComment).toHaveBeenCalledTimes(1)
    expect(props.createComment).toHaveBeenCalledWith(newComment)
  })
})

关于代码有一些假设,比如我们的标签的命名或者我们将拥有createComment prop。

当找到输入时,我们想尝试通过他们的标签找到它们。 当我们构建我们的应用程序时,这优先考虑可访问性。 获取表单的最简单方法是使用container.querySelector

接下来,我们必须为输入分配新值并模拟更改以更新其状态。 这一步可能会感觉有点奇怪,因为通常我们一次只键入一个字符,更新每个新字符的组件状态。

这个测试的行为更像复制/粘贴行为,从空字符串到“苏格拉底”(译者注:这里用苏格拉底来指代复杂的输入)。 目前没有突破的问题,但我们可能想记下它,以防晚些时候出现。

提交表单后,我们可以对诸如哪些道具被引用以及使用什么参数等进行断言。 我们也可以使用这一时刻来验证表单输入已被清除。

它吓人吗? 不用担心,我的孩子,走这条路。 首先将表单添加到渲染函数中。

render() {
  const { header } = this.props
  return (
    <div className="CommentFeed">
      <h2>{header}</h2>

      <form
        className="comment-form"
        onSubmit={this.handleSubmit}
      >
        <label htmlFor="author">
          Author
          <input
            id="author"
            type="text"
            onChange={this.handleChange}
          />
          </label>
        <label htmlFor="text">
          Comment
          <input
            id="text"
            type="text"
            onChange={this.handleChange}
          />
        </label>

        <button type="submit">Submit Comment</button>
      </form>

      <div className="comment-list">
        {this.renderComments()}
      </div>
    </div>
  )
}

我可以将这种形式分解成它自己的单独组件,但我现在不会采用。 相反,我会将其添加到我的“重构愿望清单”中,并保存在我的桌子旁边。

这是TDD的方式。 当某些东西似乎可以被重构时,记下它并继续前进。 只有当抽象的存在对你有益时,才会重构,并且不觉得没有必要。

还记得我们通过创建createProps工厂来重构我们的测试套件吗? 就这样。 我们也可以重构测试。

现在,我们添加handleChangehandleSubmit类方法。 当我们更改输入或提交表单时,这些会被解雇(get fire,译者理解成移除)。 我也会初始化我们的状态。

export default class CommentFeed extends Component {
  state = {
    author: '',
    text: ''
  }

  handleSubmit = event => {
    event.preventDefault()
    const { author, text } = this.state
    this.props.createComment({ author, text })
  }

  handleChange = event => {
    this.setState({ [event.target.id]: event.target.value })
  }

  /* ... */
}

行了。 我们的测试正在通过,我们有一些类似于真实应用程序的东西。 我们的报道看起来如何?

不错。 如果我们忽略了index.js中的所有设置,我们就会对执行的行提供完全覆盖的Web应用程序。

当然,可能还有其他一些我们想测试的情况,以验证应用程序是否按照我们的意图工作。 这个覆盖范围数字就是你的老板在与其他人谈话时可以吹嘘的东西。

喜欢评论

我们如何检查我们是否可以评论? 这可能是在我们的应用程序中建立一些认证概念的好时机。 但是我们现在还不会跳得太远。 我们首先更新我们的道具工厂,为我们生成的评论添加auth字段以及ID。

const createProps = props => ({
  auth: {
    name: 'Ian Wilson'
  },
  comments: [
    {
      id: 'comment-0',
      author: 'Ian Wilson',
      text: 'A boats a boat but a mystery box could be anything.'
    },
    {
      id: 'comment-1',
      author: 'Max Powers Jr',
      text: 'Krypton sucks.'
    }
  ]
  /*...*/
})

“已认证”的用户将通过应用传递其auth属性。 任何与他们是否被认证相关的行为都会被记录下来。

在许多应用程序中,此属性可能包含某种访问令牌或cookie,它在向服务器发出请求时发送。

在客户端,该属性的存在让应用程序知道他们可以让用户查看他们的个人资料或其他受保护的路线。

然而,在这个测试例子中,我们不会因为身份验证而烦恼。 想象一下这样的场景:当你进入一个聊天室时,你给出你的屏幕名称(screen name,译者注:用于展示身份的名称,等同于昵称。下同)。 从那时起,您将负责使用此屏幕名称的每条评论,尽管还有其他人使用该名称登录。

尽管这不是一个好的解决方案,但即使在这个人为的例子中,我们也只关心测试CommentFeed组件的行为。 我们不关心用户如何登录。

换句话说,我们可能有一个完全不同的登录组件来处理特定用户的身份验证,从而通过火焰和愤怒来发送它们,以获得全能的auth属性,从而让他们在我们的应用程序中造成严重破坏。

让我们“喜欢”一条评论。 添加下一个测试用例,然后更新道具工厂以包含likeComment

const createProps = props => ({
  createComment: jest.fn(),
  likeComment: jest.fn(),
  ...props
})

describe('CommentFeed', () => {
  /* ... */

  it('allows the user to like a comment', () => {
    let props = createProps()
    let id = props.comments[1].id
    const { getByTestId } = render(<CommentFeed {...props} />)

    const likeNode = getByTestId(id)
    Simulate.click(likeNode)

    expect(props.likeComment).toHaveBeenCalledTimes(1)
    expect(props.likeComment).toHaveBeenCalledWith(id, props.auth.name)
  })
})

现在为了实现,我们将首先更新Comment组件来获得类似按钮以及data-testid属性,以便我们可以找到它。

const Comment = props => {
  return (
    <div className="Comment">
      <h4>{props.author}</h4>
      <p>{props.text}</p>
      <button
        data-testid={props.id}
        onClick={() => props.onLike(props.id, props.author)}
      >
        Like
      </button>
    </div>
  )
}

我将测试ID直接放在按钮上,以便我们可以立即模拟点击而无需嵌套查询选择器。 我还将一个onClick处理程序附加到按钮,以便它调用传递给它的onLike函数。

现在我们将这个类方法添加到我们的CommentFeed中:

handleLike = (id, author) => {
  this.props.likeComment(id, author)
}

您可能想知道为什么我们不直接将likeComment评论道具直接传递给Comment组件。 为什么我们把它变成一个类属性?

在这种情况下,因为它非常简单,所以我们不必构建这种抽象。 未来,我们可能会决定添加其他onClick处理程序,例如,处理分析事件或启动对该帖子未来评论的订阅。

能够在这个容器组件的handleLike方法中捆绑多个不同的函数调用有其优点。 如果我们这样选择,我们也可以使用这种方法更新组件状态。

不喜欢评论

在这一点上,我们已经进行了渲染,创建和喜欢评论的测试。 当然,我们还没有实现实际做到的逻辑 - 我们不更新存储或写入数据库。

您可能还会注意到,我们正在测试的逻辑非常脆弱,并且不适用于真实世界的评论馈送。 例如,如果我们想要评论一下我们已经喜欢的内容呢? 它会无限增加点数吗,还是会不同? 我可以喜欢我自己的评论吗?

我将把组件的功能扩展到您的想象中,但一个好的开始将是编写一个新的测试用例。 这是一个假设,我们想实施不喜欢我们已经喜欢的评论:

const createProps = props => ({
  header: 'Comment Feed',
  comments: [
    {
      id: 'comment-0',
      author: 'Ian Wilson',
      text: 'A boats a boat but a mystery box could be anything.',
      likes: ['user-0']
    },
    {
      id: 'comment-1',
      author: 'Max Powers Jr',
      text: 'Krypton sucks.',
      likes: []
    }
  ],
  auth: {
    id: 'user-0',
    name: 'Ian Wilson'
  },
  createComment: jest.fn(),
  likeComment: jest.fn(),
  unlikeComment: jest.fn(),
  ...props
})

describe('CommentFeed', () => {
  /* ... */

  it('allows the user to unlike a comment', () => {
    let props = createProps()
    let id = props.comments[0].id
    const { getByTestId } = render(<CommentFeed {...props} />)

    const likeNode = getByTestId(id)
    Simulate.click(likeNode)

    expect(props.unlikeComment).toHaveBeenCalledTimes(1)
    expect(props.unlikeComment).toHaveBeenCalledWith(id, props.auth)
  })
})

请注意,我们正在构建的评论Feed允许我喜欢我自己的评论。 谁做的?

我用一些逻辑更新了Comment组件,以确定当前用户是否喜欢评论。

const Comment = props => {
  const isLiked = props.likes.includes(props.currentUser.id)
  const onClick = isLiked
    ? () => props.onDislike(props.id)
    : () => props.onLike(props.id)
  return (
    <div className="Comment">
      <h4>{props.author}</h4>
      <p>{props.text}</p>

      <button data-testid={props.id} onClick={onClick}>
        {isLiked ? 'Unlike' : 'Like'}
      </button>
    </div>
  )
}

那么我欺骗了一点:在我们将author传递给onLike函数之前,我将其更改为currentUser ,这是传递给Comment组件的auth工具。

毕竟,当评论的作者在其他人喜欢他们的评论时显示出来是没有意义的。

我意识到这一点,因为我正在大力地编写测试。 如果我只是巧合编码,这可能会从我身边溜走,直到我的一个同事因为我的无知而殴打我。

但是这里没有无知,只是测试和后面的代码。 一定要更新CommentFeed,以便它期望传递auth属性。 对于onClick处理程序,我们可以忽略auth属性的传递,因为我们可以从父类handleLikhandleDislike方法中的auth属性中派生出来。

handleLike = id => {
  this.props.likeComment(id, this.props.auth)
}

handleDislike = id => {
  this.props.dislikeComment(id, this.props.auth)
}

renderComments() {
  return this.props.comments.map((comment, i) => (
    <Comment
      key={i}
      {...comment}
      currentUser={this.props.auth}
      onDislike={this.handleDislike}
      onLike={this.handleLike}
    />
  ))
}

打包

希望你的测试套件看起来像一棵未点燃的圣诞树。

我们可以采取这么多不同的路线,它会变得有点压倒性的。 每当你想到某件事情时,只需将它写下来,无论是在纸上还是在新的测试块中。

例如,假设你实际上想要实现handleLike并且处理handleDislike单个类的方法,但是你现在有其他的优先级。 你可以通过在测试案例中记录来做到这一点:

it('combines like and dislike methods', () => {})

这并不意味着你需要写一个全新的测试。 你也可以更新前两种情况。 但重要的是,您可以将您的测试跑步者作为您的应用程序的更紧迫的“待办事项”列表。

有用的网址

在那里有几个很大的内容来处理测试。 这里有一些特别的启发这篇文章以及我自己的做法。

我希望这会让你度过一段时间。

译者按:

原文博主运用了许多修辞的方式来说明,译者借助谷歌翻译结合自己的理解将其翻译成中文,可能还存在翻译不妥当的地方欢迎评论更正。

原文地址:How to build sturdy React apps with TDD and the React Testing Library

猜你喜欢

转载自blog.csdn.net/u011215669/article/details/80460244