如何用React测试库有效地测试你的React应用程序

97 阅读9分钟

前端测试很难,但React测试库让它变得更容易。

如果你做过你的React前端测试,你肯定遇到过集成测试用户界面的挫折。你有几个选择。

  1. **快照测试。**你可以在不同的状态下对你的代码进行 "快照",保存这些快照,如果这些快照在未来发生变化,就认为测试 "失败 "了。当快照测试失败时,你要审查新旧快照之间的差异,并决定是否适合将你的快照更新为新的快照。

  2. **功能性测试。**你可以装载你的React组件(通常使用像jsdom 这样的工具),使用DOM选择器与该组件交互,并对其行为进行一些断言。

快照测试的问题

快照测试有一些好处。尽管我在应用程序的其他部分做了改动,但某些组件的渲染效果完全一样,这让我很欣慰。

话虽如此,快照还是非常脆弱的。例如,改变一个并不真正影响代码渲染的类名会显示为一个失败的测试。更糟糕的是,如果你用一个额外的div 来包装你的应用程序的一部分,由于新的div 和代码缩进的改变,你往往会得到一个绝对巨大的差异。

我认为任何没有真正表明问题的失败测试都是 "假阳性"。在快照测试中,你会得到很多 "假阳性",这可能会导致忽略快照差异。在你知道之前,你最终会忽略真正重要的差异(即,那些指示性的突破性变化),然后你开始意识到你的快照测试正在失去其价值。

功能性测试的问题

我一般喜欢功能测试而不是快照测试。你可以阐述应用程序的一个部分的流程,并断言如果你做了x,那么y会显示,z会发生。但这种测试的问题是,它可能是脆性的。

例如:如果你通过使用一个CSS选择器来触发x,并触发一个点击事件,如果你改变这个元素的类别,你的测试就会中断。然而,你的应用程序的功能根本没有改变。同样,这就是我所说的 "假阳性"。

这种测试的另一个问题是我们应用程序的异步性质。等待元素在DOM上出现或消失是很棘手的。你可以让自己陷入一种情况,你的测试是不确定的。当一个测试今天通过,明天又毫无理由地失败时,就会引起很大的挫折感。

React测试库有什么不同?

明确地说,React测试库使你能够同时进行快照和功能测试。主要的区别是,它使以下任务容易完成,而这些任务通常在其他测试库中是令人沮丧的。

  • 以非脆性的方式选择DOM元素
  • 引发事件
  • 等待DOM元素加载或消失
  • 很好地完成所有这些事情(也就是说,代码听起来和它所做的完全一样)。

现在我已经花了一些时间来解释为什么我对React测试库如此着迷,让我们看看一个应用程序的例子,以及我们如何测试它!

React测试库的实践入门

为了开始使用React测试库,我们将克隆一个我做的现有应用程序。它只是一个简单的价格计算器,用于一些虚构的服务。

在该应用程序中,用户可以选择他们的产品是商业还是非营利性的努力。如果是商业性的,用户可以使用一个范围滑块输入来选择其产品的预期用户数。最后,用户点击一个 "计算 "按钮,计算出服务的估计成本。如果用户选择的是非营利性,该应用程序会显示一条信息,告诉用户该服务是免费的。

在当地设置该应用程序

这里有三个快速步骤,让应用程序在本地设置。在我们为其编写测试之前,我们需要这样做。

第1步:克隆版本库

现在我们已经看到了应用程序是如何工作的,让我们来克隆回购,这样我们就可以为它编写测试。

如果你使用的是带HTTPS的github,你可以用下面的命令克隆 repo。

git clone https://github.com/nas5w/rtl-testing-demo.git

如果你使用的是带SSH的github,下面的命令应该可以。

git clone git@github.com:nas5w/rtl-testing-demo.git

如果你没有使用git,你仍然可以github上直接下载一个代码的压缩文件。

第2步:安装依赖项

切换到新的目录,使用yarnnpm install 命令安装依赖项。

如果使用yarn。

cd rtl-testing-demo
yarn

如果使用npm。

cd rtl-testing-demo
npm install

应该花点时间来安装所有的项目依赖。

第3步:启动应用程序,并对其进行一些修补

要启动应用程序,如果使用yarn,运行yarn start ;如果使用npm,运行npm run start 。你的应用程序现在将在3000端口运行,这意味着你应该能够在浏览器中导航到它,网址是http://localhost:3000。玩一玩,感受一下它是如何工作的!

回顾代码

这不是一个React教程,所以我不打算详细介绍代码如何工作。也就是说,所有重要的代码都在PricePlanner.tsx 文件中。

如果你不知道Typescript,这应该是相当简单的。

import { useState } from 'react';

type AppType = 'commercial' | 'open' | undefined;

export const PricePlanner = () => {
  const [appType, setAppType] = useState<AppType>();
  const [users, setUsers] = useState(1);
  const [price, setPrice] = useState<number>();

  return (
    <div className="planner">
      <h1>Price Planner</h1>
      <p>Find out how much you'll pay for our service.</p>
      <AppTypeSelect appType={appType} setAppType={setAppType} />
      {appType === 'commercial' && (
        <>
          <UserSelect users={users} setUsers={setUsers} />
          <button
            className="calculate"
            onClick={() => {
              fakeAsyncPricer(users).then((p) => {
                setPrice(p);
              });
            }}
          >
            Calculate
          </button>
          {price && <h2>Your estimated price is ${price}/mo.</h2>}
        </>
      )}
      {appType === 'open' && <h2>Congrats, your access is free!</h2>}
    </div>
  );
};

type AppTypeSelectProps = {
  appType: AppType;
  setAppType: (appType: AppType) => void;
};

const AppTypeSelect = ({ appType, setAppType }: AppTypeSelectProps) => {
  return (
    <div>
      <legend className="question">What type of app are you developing?</legend>
      <br />
      <div>
        <label>
          <input
            id="commercial"
            type="radio"
            checked={appType === 'commercial'}
            onChange={() => {
              setAppType('commercial');
            }}
          />
          Commercial
        </label>
        <br />
        <label>
          <input
            id="open"
            type="radio"
            checked={appType === 'open'}
            onChange={() => {
              setAppType('open');
            }}
          />
          Nonprofit/open source
        </label>
      </div>
    </div>
  );
};

type UserSelectProps = {
  users: number;
  setUsers: (users: number) => void;
};

const UserSelect = ({ users, setUsers }: UserSelectProps) => {
  return (
    <>
      <label className="question" htmlFor="users">
        How many users will your app have?
      </label>
      <br />
      <input
        type="range"
        min="1"
        max="1000"
        value={users}
        id="users"
        onChange={(e) => {
          setUsers(parseInt(e.target.value));
        }}
      />
      <br />
      <span>
        {users === 1000 ? '1,000+' : users} {users === 1 ? 'user' : 'users'}
      </span>
    </>
  );
};

const fakeAsyncPricer = (users: number) => {
  const randomDelay = Math.floor(Math.random() * 300);
  return new Promise<number>((res) => {
    setTimeout(() => {
      let price: number;
      if (users < 100) {
        price = users * 5;
      } else if (users < 300) {
        price = users * 4;
      } else if (users < 700) {
        price = users * 3;
      } else {
        price = users * 2;
      }
      res(price);
    }, randomDelay);
  });
};

我们看到,我们有一些条件性的渲染,使我们的应用程序具有反应性。我们还看到,我们有一个fakeAsyncPricer 函数,它伪造了某种异步操作以确定服务价格。添加这个函数是为了让我们能够很好地了解如何在异步事情发生时对DOM进行测试。

编写我们的测试

好了,我们终于准备好用React测试库写一些测试了!让我们创建一个新的测试文件。让我们为我们的组件创建一个新的测试文件。我们将把它放在我们的src 文件夹中。

touch src/PricePlanner.test.tsx

在这个测试文件中,让我们设置几个我们想要完成的不同测试。

  1. PricePlanner显示非营利组织的免费价格
  2. PricePlanner计算商业用户的价格

在我们的文件中,这将看起来如下。我添加了一些注释来思考用户如何与该组件进行实际的交互。同样,React测试库的声明性是我非常喜欢它的部分原因。

describe('PricePlanner', () => {
  it('shows free price for nonprofits', () => {
    // Render the PricePlanner component
    // Select the nonprofit radio option
    // Assert that the screen tells the user it's free
  });
  it('calculates price for commercial users', () => {
    // Render the PricePlanner component
    // Select the commercial radio option
    // Change the slider option to 300 users
    // Click the calculate button
    // Assert that the app tells the user it will cost $900/mo
  });
});

所以,让我们开始填空吧。

渲染,屏幕,和FireEvent

从React测试库导出的三个最重要的函数是renderscreenfireEvent

  • render 将提供给它的 JSX 渲染成基于节点的 DOM(它使用 )。jsdom
  • screen 将允许你对DOM中发生的事情进行各种声明性的选择和断言。
  • fireEvent 它的作用和它听起来一样:它可以让你在DOM中触发事件。例如,你可以用它来点击一个按钮,改变一个单选,并添加一些输入文本。

第一个测试

第一个测试是相当直接的,因为没有任何异步的事情发生。我们做一个单选,并检查一些文本是否显示。下面是使用React测试库的情况。

import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { PricePlanner } from './PricePlanner';

describe('PricePlanner', () => {
  it('shows free price for nonprofits', () => {
    // Render the PricePlanner component
    render(<PricePlanner />);
    // Select the nonprofit radio option
    const nonprofitRadio = screen.getByLabelText('Nonprofit', { exact: false });
    fireEvent.click(nonprofitRadio);
    // Assert that the screen tells the user it's free
    expect(screen.getByText('free', { exact: false })).toBeInTheDocument();
  });
});

我们可以通过在我们的根目录下运行yarn test (或npm run test )来测试这个。

它是有效的!我认为它的阅读效果很好。这里最突出的是,我们使用screen.getByLabelText('Nonprofit', { exact: false }) 找到了非营利性的单选。exact 选项告诉选择器,我们想要一个包含 "非营利 "的标签,但它也可以有一些其他字符。基本上,可以使用相当少的信息量来获得正确的单选题,并且相当确信我们不会破坏选择器。即使我们这样做了,也是很容易解决的。

这样做的好处是,它有助于鼓励良好的可访问性实践:如果我没有正确地将label 与单选题联系起来,这个测试就会失败。

触发事件的简单性也是非常好的。我们抓取了单选按钮,然后简单地将其传递给fireEvent.click

我的断言有点懒,但它完成了工作:基本上,它断言DOM上有一个包含测试 "free "的节点。在这种情况下,我们可以相当肯定,这只符合我们的应用程序成功地告诉用户该服务是免费的情况。如果有必要的话,我们可以用这个选择器做得更具体。

第二个测试

由于我们的异步价格计算器,第二个测试更为复杂。让我们暂时天真一点,"忘记 "这个问题。我们写这个测试就像写第一个测试一样。

it('calculates price for commercial users', () => {
  // Render the PricePlanner component
  render(<PricePlanner />);
  // Select the commercial radio option
  const commercialRadio = screen.getByLabelText('Commercial');
  fireEvent.click(commercialRadio);
  // Change the slider option to 300 users
  const slider = screen.getByLabelText('How many users', { exact: false });
  fireEvent.change(slider, { target: { value: 300 } });
  // Click the calculate button
  const calculateButton = screen.getByRole('button');
  fireEvent.click(calculateButton);
  // Assert that the app tells the user it will cost $900/mo
  expect(screen.getByText('$900', { exact: false })).toBeInTheDocument();
});

我们可以看到这里有几个额外的方便的工具:fireEvent.change ,接收一个事件对象{target: { value: "Some value" }} ,并将所提供的输入改为该值。我们还注意到screen.getByRole ,它允许我们轻松地找到页面上作为button 的任何东西。

让我们用yarn test (或npm run test )运行这个测试。

**哦,不,它失败了!**让我们看看我们的控制台怎么说。

TestingLibraryElementError。无法找到一个文本为:900美元的元素。这可能是因为文本被多个元素分解了。在这种情况下,你可以为你的文本匹配器提供一个函数,使你的匹配器更加灵活。

那条错误信息部分是对的:该元素不在DOM上。但是--这并不是因为我们使用的匹配器是坏的。而是因为我们的价格计算器需要一些时间(最多 300ms)来计算价格,然后将其渲染到 DOM 上。

我们的测试试图在DOM准备好之前做出断言。

异步选择器

幸运的是,React测试库已经抽象出了等待东西出现在DOM上的复杂性。我们的测试案例只需要做两个小改动。

  1. 我们应该把测试用例改成一个async 函数。这样我们就可以在函数中使用await
  2. 我们可以使用await screen.findByTest ,而不是getByText

顺便说一下,React测试库有一个很好的约定,所有的getBy* 选择器都是同步的,并返回匹配的元素,而findBy* 选择器是异步的,并返回承诺。

测试二,第二步

利用我们对异步选择器的新知识,让我们修正我们的测试。

it('calculates price for commercial users', async () => {
  // Render the PricePlanner component
  render(<PricePlanner />);
  // Select the commercial radio option
  const commercialRadio = screen.getByLabelText('Commercial');
  fireEvent.click(commercialRadio);
  // Change the slider option to 300 users
  const slider = screen.getByLabelText('How many users', { exact: false });
  fireEvent.change(slider, { target: { value: 300 } });
  // Click the calculate button
  const calculateButton = screen.getByRole('button');
  fireEvent.click(calculateButton);
  // Assert that the app tells the user it will cost $900/mo
  expect(await screen.findByText('$900', { exact: false })).toBeInTheDocument();
});

现在,如果我们运行这个测试,它就会起作用了。

仅仅触及表面

我希望你喜欢这个关于React测试库的快速入门课程。我们只看到了皮毛,但希望你已经看到了它比其他的测试库更具有声明性和稳健性。