译:React 中的脆弱测试:检测、预防和工具

原文:https://semaphoreci.com/blog/flaky-react
作者:Victor Ayomipo
译者:ChatGPT 4 Turbo

编者注:感觉看前半篇就好了,了解哪些原因会导致 Flaky Test,然后避免他们。

在 React 的背景下,测试是保持代码质量和流畅用户体验的不可谈判的过程。

然而,当在 React 中运行测试时,通常会面临一个令人沮丧的坏消息。那就是脆弱的测试

简单来说,脆弱的测试是那些大多数时间看上去能通过但有时会失败的测试,而且这种失败并非因为代码或测试的变化 —— 没有任何原因。

在这份指南中,我们将专注于 React 中的脆弱测试,它们的各种原因,如何检测它们,如何修复它们,以及使用的有效工具。

理解 React 中的脆弱测试

脆弱测试,特别是在 UI 测试中,是开发者的一个常见痛点。即使是 ==Google 也报告说,大约14% 的他们的测试是脆弱的。==

这里有一个简短的场景,以便进一步理解 React 中的脆弱测试:

所以你为一个 React 组件编写了一个测试,这个组件显示一个按钮,当点击时,会发送一个通知给用户。然后你运行测试,它通过了 —— 全绿。第二天,你再次运行测试,它通过了 —— 全绿。然后,为了最终确定,你最后一次运行测试 —— 红色,它失败了。

现在,它不应该失败,因为它是同一个测试和同一个组件;什么都没变,但它突然失败了。所以,正如预期的,你再次运行测试 —— 它失败了,再次运行 —— 通过了;再次运行 —— 失败了。

那么,是什么确切地导致了 React 中的脆弱测试?

React 中脆弱测试的常见原因

在我们继续之前,让我们先澄清一点。React 中脆弱测试的确切原因可能是任何事物;它变化多端。这主要是由于 React 组件的动态特性及其相互作用方式所致。

然而,有一些常见的原因你可以留意,这些可能是问题的罪魁祸首。让我们更详细地看看它们。

外部依赖

几乎每个 React 应用都会与 API、数据库或第三方服务交互,预料之中,测试也依赖它们。

现在,比如,你有一个测试,它检查在从 API 获取数据后是否显示了产品列表。然而,如果 API 响应缓慢或宕机,即使代码正常工作,测试也可能失败。这种不稳定发生是因为测试依赖于一个大多数情况下你无法控制的外部因素。

例如,这里有一个组件,它从一个 API 获取产品列表并显示它们:

// 引入 ...
export function ProductsList() {
  const [products, setProducts] = useState([]);
  useEffect(() => {
    const fetchProducts = async () => {
      try {
        const response = await fetch("https://api.example.com/products");
        const data = await response.json();
        setProducts(data);
      } catch (error) {
        console.log(error);
      }
    };
    fetchProducts();
  }, []);

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

现在测试可能看起来像这样:

import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";

describe("ProductsList", () => {
  test("应当渲染一个产品列表", async () => {
    render(<ProductsList />);
    await waitFor(() => {
      expect(screen.getByText("Product 1")).toBeInTheDocument();
      expect(screen.getByText("Product 2")).toBeInTheDocument();
    });
  });
});

这个测试可能运行良好;然而,它是不稳定的,因为它依赖于一个外部 API,就像通常那样,它带来了许多不确定性,如网络延迟或服务器问题。这可以通过使用模拟来解决(稍后会详细说明)。

时序问题

谁知道一个组件会不会花费比预期更长的时间呢?一个测试可以依赖于一个需要不可预测时间完成的过程;这一类的核心过程包括动画和过渡。

如果有一个测试在动画运行前后检查特定的 UI 元素,出现一个不稳定的测试结果也不会让人惊讶。

异步操作

在 React 中,很多任务都不是立即发生的,比如等待用户输入、UI 更新,或者从服务器获取数据。如果你的测试没有在进行断言之前等待这些操作完成,它们将会失败。

我们知道在 React 中,当一个组件的状态发生变化时,虚拟 DOM 会更新,然后实际的 UI 会异步更新。所以,如果测试在状态更新之后立刻断言 UI 状态,可能会出现不稳定的情况,因为 UI 还没有更新。这里有一个例子:

// 这里是引入的模块 ...
function Counter() {
  const [count, setCount] = useState(0);
  const handleClick = () => setCount((count) => count + 1);
  return (
    <>
      <p>{count}</p>
      <button onClick={handleClick}>Increment</button>
    </>
  );
}

describe("Counter", () => {
  it("点击后应该更新计数", () => {
    render(<Counter />);
    fireEvent.click(screen.getByText("Increment"));
    expect(screen.getByText("1")).toBeInTheDocument();
  });
});

这个测试看起来很简单,但是当测试在 UI 反映变化之前就运行断言的时候,不稳定性就会出现,因此建议使用 waitFor 工具:

describe("Counter", () => {
  it("点击后应该更新计数", async () => {
    render(<Counter />);
    fireEvent.click(screen.getByText("Increment"));
    // 等待 UI 更新
    await waitFor(() => {
      expect(screen.getByText("1")).toBeInTheDocument();
    });
  });
});

状态泄露

这种情况发生在测试修改了全局状态或者有一些没有考虑到的副作用。因此,这些改变可能会干扰下一个运行的测试,导致意外的失败。

这主要常见于多个 React 组件依赖于状态来渲染。你有一个测试 A,它设置了一个状态变量,如果用户登录了。现在,如果测试 B 依赖于这个状态并且在运行前没有重置它,它可能会失败,因为它期望用户已经登出了。

缺陷测试

可能是由于截止日期的缘故,仓促进行测试并不少见。我们大多数人都有过这样的经历,我们希望快速看到绿色的测试结果然后继续其他事情。但通常情况下,匆忙编写的测试往往基于假设,这导致了测试的不稳定。

考虑以下组件:

export function TestA() {
  useEffect(() => {
    localStorage.setItem("user", "minato");
  }, []);
  return <p>Test A 设置 localStorage 中的 user</p>;
}

export function TestB() {
  return <p>Test B: 从 localStorage 中读取 user</p>;
}

这是 TestA 测试文件:

test("TestA 设置 localStorage 中的 user", () => {
  render(<TestA />);
  expect(localStorage.getItem("user")).toBe("minato");
});

这是 TestB 测试文件:

test("TestB 从 localStorage 中读取 user", () => {
  render(<TestB />);
  expect(localStorage.getItem("user")).toBeNull();
});

TestA 使用 useEffect 中的副作用设置了 localStorage 中的值。没有清理副作用,可能会干扰后续测试。

TestB 期望 localStorage.getItem('user')null,但可能会因存储泄露失败。你可以使用 beforeEachafterEach 清理所有副作用。

测试不稳定对开发工作流程和产品质量的影响

假设你刚刚在你的 React 应用中完成了一个新的搜索功能。你通过你的 CI/CD 流水线 运行它,所有测试通过。你再运行一次,所有测试都通过了,然后你将代码合并到主分支,构建失败了!然后你回顾代码,似乎没有什么问题,你再次运行测试,然而……它们失败了。

在这个场景中发生的事情说明了测试不稳定可能会造成代码安全的虚假感觉。另一个已知的影响是对测试信任的降低。当测试开始随机失败时,这是令人沮丧的,随着时间的推移,开发者往往会开始忽视不稳定的测试,并将它们标记为“预期失败”。这样做最终导致了错误的用户界面。

避免这些影响很重要,尽早做到这一点带来了一些好处:

  • 它节省时间和金钱。
  • 它确保了用户体验的稳定性。
  • 它允许顺畅可靠的 CI/CD 流程。

在 React 中检测易变的测试

开发者用来检测 React 中易变测试的方法有很多,有些是手动的,而有些是自动的。让我们来看看。

检查测试代码

始终检查测试中的代码,尤其是那些涉及异步进程的,它们是 React 测试易变性的主要原因。还要检查测试是否在新测试开始之前或旧测试结束后进行了清理。

Jest 提供了两个适用于此的钩子 beforeEachafterEach

// 函数和逻辑在这里 ...

describe("items in correct group", () => {
  beforeEach(() => getAnimalData());
  afterEach(() => clearAnimalData());

  test("animals in right category", () => {
    expect(isAnimalInCategory("cat", "mammals")).toBeTruthy();
  });
});

另外,在审查测试代码时,测试是否可以独立运行并完美无缺,而不依赖外部状态或全局变量?因为如果测试依赖于无法控制的外部数据,随机性和不可预测性就会介入,从而引入易变性。

分析错误处理

在 React 中,易变性的一个常见来源是组件内部不充分的错误处理。这简单地因为未捕获的错误可以改变执行流程,这可能导致测试失败,这些测试可能甚至与当前运行的测试无关。

例如,React 测试库是否被深思熟虑地实现了?潜在的错误是否被考虑并有效处理了?所有这些都很重要,因为如果一个错误没有被考虑到,它可能会导致测试随机失败。

稳定的测试环境

在 React 中运行测试时,环境的一致性至关重要。比方说,你在本地环境中运行一些测试,所有测试都通过了,但当你在 CI/CD 管道上运行它们时,一些测试失败了。那么环境变化可能是原因。

依赖、工具和配置的一致性

基本上,每次测试运行时,所有依赖、工具和配置都应保持一致。工具中的轻微硬件配置变更可能导致测试变得不稳定。

日志记录为了洞察

在软件开发中——包括 React,为了找出任何失败的原因,日志记录是首选的方法之一。

一个简单的 console.log() 能够在定位特定不稳定测试的原因上做到奇迹。只需在你的测试套件周围放置日志语句,就可以详细追踪测试执行的流程,通过此,更容易识别导致测试失败的模式。

为了使事情更简单,React 测试库提供了一个方法 screen.debug,帮助记录元素或整个呈现文档。

function Card({ title }) {
  return <div>{title}</div>;
}

describe("Card 渲染", () => {
  test("渲染标题", () => {
    render(<Card title='飞往火星的航班' />);
    screen.debug(); // 渲染文档
    expect(screen.getByText("飞往火星的航班")).toBeInTheDocument();
    // 仅渲染 card 组件
    screen.debug(screen.getByText("飞往火星的航班")); 
  });
});

元素 的顺序很重要

让我们用一个例子来解释这个,一个组件渲染商品列表,你写了一个测试,期望最后一个项目是饮料。测试可能现在通过了,但你能确定最后一项总会是饮料吗?产品数据结构可能会变更(例如排序算法更新或代码重构)。因此,不要假设数据总会按照你预期的来。相反,在测试时应使用唯一 ID 来定位 UI 内的特定元素。

使用 CI/CD 流水线

手动运行测试效率低下;当然,如果是小型测试,可能没什么大不了。但是对于大型 React 代码库或频繁的测试运行来说,比如说 93 次——这非常多。

现在,自动化就派上用场了,CI/CD 是最擅长的。你可以轻松地 集成 React 测试 到 CI/CD 流水线中来自动化过程。许多 CI/CD 平台,如 Semaphore,都有 内置功能,用于轻松检测和报告不稳定的测试

他们会在代码变更被推送到仓库时自动运行你的测试,并在自动化测试过程中出现波动性测试时通知开发者。这些平台可以多次重跑失败的测试以确认其不稳定性,在将测试标记为失败之前(不过,这将增加你的账单费用)。

防止 React 中的波动性测试

要明白,波动性测试更多时候被忽视后,往往会变成潜在的 bug。因此,尽快预防或修复这些测试至关重要,至少对于 UI 稳定性来说是如此。

尽早使用 CI/CD

这种自动化技术是预防甚至早期发现波动性测试的最佳选择之一。你可以像设置 SemaphoreCI 这样的 CI/CD 流水线,并配置它来触发自动化测试执行。这将为你提供关于测试失败的详细报告、堆栈跟踪,甚至是关于波动性测试发生的 how、when、why 的日志。

良好地构建你的测试

这很简单,结构良好的测试更容易维护,因为随着代码库的增长,测试的数量也会增加,波动性测试出现的机会也就越高。

  • 测试应该是独立的,以防止连锁反应,一个测试随机失败,其后的其他测试也可能表现出相同的行为。
  • 测试应该有有意义的名称。
  • 测试应该在运行时设置它自己的组件实例。
  • 考虑设计测试来检查依赖关系或数据是否可用或在运行核心测试之前能否正常工作。

最小化固定等待时间

应该尽可能最小化地使用固定等待时间(例如,setTimeout),因为它们不可预测,尤其是在 UI 更改或动画期间。相反,使用事件、async/await 或 promise 来处理这些情况;这样更为高效。

假设一个测试点击一个按钮来打开一个运行 500ms 的模态框。与其在测试中使用固定的 500ms 等待,不如使用 waitFor,它会在计时器结束后执行测试断言。

以下示例说明了这一点:

function Modal() {
  const [isOpen, setIsOpen] = useState(false);
  const handleOpen = () => {
    setTimeout(() => setIsOpen(true), 500);
  };

  return (
    <>
      {isOpen && <div data-testid='modal'>这是模态框</div>}
      <button onClick={handleOpen}>打开模态框</button>
    </>
  );
}

让我们创建一个由于固定等待时间而可能出现问题的测试:

test("模态框打开", async () => {
  render(<Modal />);
  fireEvent.click(screen.getByText("打开模态框"));

  setTimeout(() => {
    expect(screen.getByTestId("modal")).toBeInTheDocument();
  }, 500);
});

这将显示为测试通过,然而,断言在测试结束之前并没有运行。我们可以通过使用 async/awaitwaitFor 来解决它:

test("模态框打开", async () => {
  render(<Modal />);
  fireEvent.click(screen.getByText("打开模态框"));

  await waitFor(() => expect(screen.getByTestId("modal")).toBeInTheDocument());
});

现在,测试将等待模态框打开,然后通过断言检查其存在情况。

注意动态数据

注意在多次测试运行期间,可能无法控制地发生变化的随机或不可预测数据。一个例子是 UUID,它们非常适合于反应优化,然而,在测试中,由于 UUID 是随机生成的,它们在多次测试运行中会有所不同,导致结果不稳定。相反,可以使用可预测的模式,比如递增计数器(仅用于测试目的)。

其他动态数据包括用户输入和日期。

使用 Mock

Mock 最适合用来替换外部依赖,提供一个更受控的仿制版本,组件在测试期间可以使用。这使得测试行为更可预测,而且不必处理外部依赖的不一致细微差别。

让我们重新审视一下我们在 React 中的测试不稳定因素之外部依赖 中使用的 ProductsList 代码示例。以下是 Mock 能够帮助的方式:

const mockProducts = [
  { id: 1, name: "产品 1" },
  { id: 2, name: "产品 2" },
];

describe("ProductsList", () => {
  test("应当渲染一个产品列表", async () => {
    global.fetch = jest.fn().mockResolvedValue({
      json: jest.fn().mockResolvedValue(mockProducts),
    });
    render(<App />);
    await waitFor(() => {
      expect(global.fetch).toHaveBeenCalledWith(
        "https://api.example.com/products"
      );
    });
    await waitFor(() => {
      expect(screen.getByText("产品 1")).toBeInTheDocument();
    });
    await waitFor(() => {
      expect(screen.getByText("产品 1")).toBeInTheDocument();
    });
  });
});

在这个测试中,mockProducts 数组包含了将被用来代替原始获取到的 API 数据的虚拟数据。然后使用 jest.fn() 来模拟全局的 fetch 函数,以便我们可以在测试中控制其行为。

之后,模拟配置成一个成功的获取响应(mockResolvedValue),该响应返回 mockProducts 数组。接着,我们进行常规断言。

有了这个设置,我们可以确定测试主要关注其代码逻辑,将其与外部因素隔离。

尽快修复不稳定的测试

这是因为如果一个测试突然显示为不稳定,并且你打上了标签——稍后修复。然而,当那个时刻来临并运行测试时,它可能每次都继续通过,不稳定性可能不会再出现。

这并不意味着它被修复了;这可能意味着你最初得到一个不稳定测试的特定原因是由于一天中的时间。现在你不得不等待不稳定的测试再次出现,或者你冒着将代码推送到生产的风险并希望一切正常(不推荐)。

编写稳定的测试

以下是在 React 中编写稳定测试的一些好习惯:

  • 每个测试应专注于特定 React 组件的行为,且是独立的
  • 从较小的测试开始,这使得测试更容易理解。
  • 使用 beforeEachafterEach 方法确保每个测试都从一个干净的状态开始。
  • waitForact 是处理异步操作的好选择。
  • 除非功能明确涉及异步操作,否则编写同步测试。
  • 如果你的测试工具支持快照测试,请使用它。这会让事情变得更简单。
  • 不要盲目地进行易出错的测试,相反,你可以给它加上一个标记,稍后再修复。不过,你越快修复它,就越好。
  • 记录你的测试内容,解释它们测试了什么以及为什么。

结论

React 作为一个 UI 库,在进行测试时有自己的一系列挑战。然而,我们都是从过去的错误中学习的,所以如果你现在还没有掌握解决易出错测试的方法。只需知道这是正常的,随着时间的推移,你遇到并修复的易出错测试越多,你的测试就会变得越来越稳定,并总体上变得更好。