React 基础体系 · 第 49/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

React 单元测试:纯函数、Hook、时间、网络和稳定断言

React 单元测试的难点通常不在“如何调用断言”,而在于确定被测单元的边界:一个纯函数应当只根据输入计算结果;一个 Hook 还包含状态和生命周期;时间会让同一输入在不同瞬间产生不同结果;网络请求则把异步、失败、取消和外部系统引入测试;断言如果依赖实现细节,又会在无行为变化时频繁失效。

本文使用 React 19、现代 TypeScript、Vitest 和 React Testing Library 展开。测试示例面向客户端 React 代码;服务端函数、Server Component 和真实网络边界会单独说明。

先建立测试运行环境

一个最小的客户端测试环境通常包含:

  • Vitest:测试运行器、Mock、Fake Timer 和断言扩展。
  • React Testing Library:通过 DOM 和用户可观察行为测试组件。
  • @testing-library/jest-dom:提供 toBeInTheDocumenttoHaveTextContent 等 DOM 断言。
  • @testing-library/user-event:模拟更接近真实用户的点击、输入和键盘操作。

安装依赖:

npm install -D vitest jsdom \
  @testing-library/react \
  @testing-library/jest-dom \
  @testing-library/user-event \
  @types/node

vitest.config.ts

import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    restoreMocks: true,
    clearMocks: true,
  },
});

jsdom 提供浏览器 DOM 的模拟环境,但它不是完整浏览器。它不会真实执行布局、绘制、网络栈和所有浏览器 API。因此,依赖真实布局的逻辑,例如 getBoundingClientRect()、IntersectionObserver 或复杂的 CSS 行为,不能仅凭普通 DOM 单元测试证明正确。

src/test/setup.ts

import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';

afterEach(() => {
  cleanup();
});

测试命令可以写入 package.json

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest"
  }
}

测试文件通常使用 .test.ts.test.tsx 后缀。涉及 JSX 的测试必须使用 .tsx


一、纯函数:先证明输入到输出的确定性

1. 什么是适合单元测试的纯函数

函数

f:XYf: X \rightarrow Y

如果对任意输入 xXx \in X,函数都满足:

  1. 结果只由输入决定:同一个输入总是得到同一个输出;
  2. 没有可观察副作用:不修改外部变量、不写文件、不发请求、不改变全局时间或随机数状态;
  3. 不修改输入对象:调用后输入保持不变;

那么它可以视为纯函数。

形式化地说,如果同一执行上下文中:

f(x)=yf(x) = y

再次调用仍然满足:

f(x)=yf(x) = y

并且调用前后的外部状态 SS 不变:

Safter=SbeforeS_{\text{after}} = S_{\text{before}}

这带来一个直接推论:纯函数测试不需要 React、DOM、网络或 Fake Timer。测试只需准备输入、调用函数、检查输出和输入是否保持不变。

2. 完整示例:购物车金额计算

先定义业务类型和函数:

// src/cart/total.ts
export type CartItem = {
  id: string;
  name: string;
  unitPriceCents: number;
  quantity: number;
};

export type Pricing = {
  subtotalCents: number;
  discountCents: number;
  shippingCents: number;
  totalCents: number;
};

export function calculatePricing(
  items: readonly CartItem[],
  freeShippingThresholdCents = 10_000,
): Pricing {
  const subtotalCents = items.reduce(
    (sum, item) => sum + item.unitPriceCents * item.quantity,
    0,
  );

  const discountCents = subtotalCents >= 5_000
    ? Math.floor(subtotalCents * 0.1)
    : 0;

  const discountedSubtotalCents = subtotalCents - discountCents;

  const shippingCents =
    discountedSubtotalCents >= freeShippingThresholdCents ? 0 : 800;

  return {
    subtotalCents,
    discountCents,
    shippingCents,
    totalCents: discountedSubtotalCents + shippingCents,
  };
}

这里使用“分”为金额单位,而不是浮点数元。原因不是测试专属,而是货币运算需要避免:

0.1 + 0.2 !== 0.3

测试:

// src/cart/total.test.ts
import { describe, expect, it } from 'vitest';
import { calculatePricing, type CartItem } from './total';

describe('calculatePricing', () => {
  it('计算小计、折扣、运费和总价', () => {
    const items: CartItem[] = [
      {
        id: 'keyboard',
        name: 'Keyboard',
        unitPriceCents: 3_000,
        quantity: 2,
      },
    ];

    expect(calculatePricing(items)).toEqual({
      subtotalCents: 6_000,
      discountCents: 600,
      shippingCents: 800,
      totalCents: 6_200,
    });
  });

  it('达到免运费门槛后运费为零', () => {
    const items: CartItem[] = [
      {
        id: 'monitor',
        name: 'Monitor',
        unitPriceCents: 10_000,
        quantity: 1,
      },
    ];

    expect(calculatePricing(items)).toEqual({
      subtotalCents: 10_000,
      discountCents: 1_000,
      shippingCents: 0,
      totalCents: 9_000,
    });
  });

  it('不会修改输入数组和数组中的对象', () => {
    const items: CartItem[] = [
      {
        id: 'mouse',
        name: 'Mouse',
        unitPriceCents: 2_000,
        quantity: 1,
      },
    ];
    const before = structuredClone(items);

    calculatePricing(items);

    expect(items).toEqual(before);
  });
});

第一组断言验证完整业务结果,而不是只断言 totalCents。这样可以在折扣或运费计算错误时更快定位失败阶段。第二组验证边界条件。第三组验证“不修改输入”这一副作用约束。

3. 反例:看起来简单,但实际上不是纯函数

let discountRate = 0.1;

export function calculateTotal(price: number): number {
  return price * (1 - discountRate);
}

这个函数的输出还依赖模块级变量 discountRate。测试如果只覆盖初始值:

expect(calculateTotal(100)).toBe(90);

并没有证明函数对输入是确定的。另一个测试可能修改了 discountRate,导致测试顺序影响结果。

更明确的设计是把隐藏依赖变成参数:

export function calculateTotal(
  priceCents: number,
  discountRate: number,
): number {
  return Math.round(priceCents * (1 - discountRate));
}

现在,测试输入完整表达了计算条件:

expect(calculateTotal(1_000, 0.1)).toBe(900);

4. 纯函数测试中的边界和不变量

除了示例值,还应根据函数的业务约束测试不变量。例如购物车总价应满足:

total=subtotaldiscount+shipping\text{total} = \text{subtotal} - \text{discount} + \text{shipping}

并且:

0discountsubtotal0 \leq \text{discount} \leq \text{subtotal}

可以写成参数化测试:

import { describe, expect, it } from 'vitest';
import { calculatePricing } from './total';

describe('calculatePricing invariants', () => {
  it.each([
    { price: 0, quantity: 0 },
    { price: 1, quantity: 1 },
    { price: 4_999, quantity: 1 },
    { price: 5_000, quantity: 1 },
    { price: 20_000, quantity: 3 },
  ])('结果满足金额关系 %#', ({ price, quantity }) => {
    const result = calculatePricing([
      {
        id: 'item',
        name: 'Item',
        unitPriceCents: price,
        quantity,
      },
    ]);

    expect(result.totalCents).toBe(
      result.subtotalCents -
        result.discountCents +
        result.shippingCents,
    );
    expect(result.discountCents).toBeGreaterThanOrEqual(0);
    expect(result.discountCents).toBeLessThanOrEqual(
      result.subtotalCents,
    );
  });
});

参数化测试不是为了增加样例数量,而是把边界条件显式化。若规则复杂到需要大量随机输入,可以引入 property-based testing;但随机测试必须固定随机种子或在失败时输出可复现输入,否则诊断成本会抵消收益。


二、Hook:测试状态转换和生命周期,而不是内部变量

1. Hook 的测试对象是什么

Hook 不是普通纯函数。它通常包含:

  • 当前状态;
  • 状态更新函数;
  • useEffect 等副作用生命周期;
  • 依赖变化时的重新执行;
  • 卸载时的清理;
  • 异步回调与组件生命周期之间的竞态。

因此,Hook 测试关注的是状态转换:

S0调用返回的操作S1S_0 \xrightarrow{\text{调用返回的操作}} S_1

以及生命周期:

mounteffectdependency changecleanup\text{mount} \rightarrow \text{effect} \rightarrow \text{dependency change} \rightarrow \text{cleanup}

不能直接调用一个依赖 React Dispatcher 的 Hook:

// 错误思路
const result = useCounter();

React 只允许在组件或自定义 Hook 内部调用 Hook。脱离 React 渲染上下文调用会产生 “Invalid hook call” 错误。

2. 一个状态 Hook 的完整测试

Hook 实现:

// src/hooks/useCounter.ts
import { useCallback, useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => {
    setCount((current) => current + 1);
  }, []);

  const decrement = useCallback(() => {
    setCount((current) => current - 1);
  }, []);

  const reset = useCallback(() => {
    setCount(initialValue);
  }, [initialValue]);

  return {
    count,
    increment,
    decrement,
    reset,
  };
}

测试:

// src/hooks/useCounter.test.tsx
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useCounter } from './useCounter';

describe('useCounter', () => {
  it('支持增加、减少和重置', () => {
    const { result } = renderHook(() => useCounter(10));

    expect(result.current.count).toBe(10);

    act(() => {
      result.current.increment();
    });
    expect(result.current.count).toBe(11);

    act(() => {
      result.current.decrement();
    });
    expect(result.current.count).toBe(10);

    act(() => {
      result.current.reset();
    });
    expect(result.current.count).toBe(10);
  });
});

renderHook 把 Hook 放进一个由测试库管理的测试组件中。result.current 表示当前渲染返回的值。调用会触发 React 状态更新的操作应放在 act 中,使 React 有机会完成更新、执行相关副作用并刷新测试可见状态。

React Testing Library 的部分用户交互封装已经包含 act,但直接调用 Hook 返回的更新函数时,显式使用 act 更清楚,也能避免 React 关于未包装更新的警告。

3. 依赖变化和清理

下面的 Hook 订阅一个外部事件源:

// src/hooks/useOnlineStatus.ts
import { useEffect, useState } from 'react';

export function useOnlineStatus() {
  const [online, setOnline] = useState(() => navigator.onLine);

  useEffect(() => {
    const handleOnline = () => setOnline(true);
    const handleOffline = () => setOnline(false);

    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);

    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);

  return online;
}

测试既要证明状态变化,也要证明卸载后不会继续更新:

// src/hooks/useOnlineStatus.test.tsx
import { renderHook, act } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useOnlineStatus } from './useOnlineStatus';

describe('useOnlineStatus', () => {
  it('根据 online/offline 事件更新状态', () => {
    vi.stubGlobal('navigator', {
      ...navigator,
      onLine: true,
    });

    const { result } = renderHook(() => useOnlineStatus());

    expect(result.current).toBe(true);

    act(() => {
      window.dispatchEvent(new Event('offline'));
    });
    expect(result.current).toBe(false);

    act(() => {
      window.dispatchEvent(new Event('online'));
    });
    expect(result.current).toBe(true);
  });

  it('卸载后移除事件监听器', () => {
    const removeSpy = vi.spyOn(window, 'removeEventListener');
    const { unmount } = renderHook(() => useOnlineStatus());

    unmount();

    expect(removeSpy).toHaveBeenCalledWith(
      'online',
      expect.any(Function),
    );
    expect(removeSpy).toHaveBeenCalledWith(
      'offline',
      expect.any(Function),
    );
  });
});

这里对 removeEventListener 的断言是合理的,因为“卸载后解除订阅”属于 Hook 的外部行为契约。相反,断言 Hook 内部使用了几个 useState,则是实现细节,重构时不应导致测试失败。

4. React Strict Mode 下的效果执行

开发模式下,React Strict Mode 可能额外执行一轮 Effect 的 setup 和 cleanup,以帮助发现不安全副作用。测试环境是否启用了 Strict Mode 取决于测试包装器和工具配置。

因此,不应简单把“某个 Effect 只执行一次”当作普遍保证。更可靠的契约是:

  • 每次订阅都有对应的清理;
  • 清理后不会残留监听器;
  • 副作用具有幂等性,重复 setup 不会造成重复业务结果;
  • 网络请求或日志等不可逆副作用是否需要去重,应由业务设计决定。

如果 Hook 使用 renderHookwrapper 包裹 Provider,应测试 Provider 提供的外部契约,而不是测试上下文内部的实现:

import { renderHook } from '@testing-library/react';

function TestWrapper({ children }: { children: React.ReactNode }) {
  return <ThemeProvider value="dark">{children}</ThemeProvider>;
}

// renderHook(() => useTheme(), { wrapper: TestWrapper });

三、时间:把“现在”变成可控输入

1. 时间为什么会让测试不稳定

以下代码的结果依赖当前时刻:

export function isExpired(expiresAt: Date): boolean {
  return expiresAt.getTime() <= Date.now();
}

测试若直接使用真实时间:

expect(isExpired(new Date())).toBe(true);

存在边界竞态:构造 Date 和执行 Date.now() 之间可能跨越毫秒,结果虽然通常符合预期,但测试依赖了执行速度。

时间问题通常有三类:

  1. 当前时间Date.now()new Date()
  2. 定时器setTimeoutsetInterval
  3. 时间推进:需要证明过期、轮询或倒计时。

稳定测试必须控制时钟,而不是等待真实时间流逝。

2. 使用 Fake Timer 验证过期逻辑

实现一个轮询 Hook:

// src/hooks/usePolling.ts
import { useEffect, useRef, useState } from 'react';

export function usePolling(
  task: () => Promise<void>,
  intervalMs: number,
) {
  const [running, setRunning] = useState(false);
  const taskRef = useRef(task);
  taskRef.current = task;

  useEffect(() => {
    let cancelled = false;

    const run = async () => {
      if (cancelled) return;

      setRunning(true);
      try {
        await taskRef.current();
      } finally {
        if (!cancelled) {
          setRunning(false);
        }
      }
    };

    const timer = window.setInterval(run, intervalMs);

    return () => {
      cancelled = true;
      window.clearInterval(timer);
    };
  }, [intervalMs]);

  return { running };
}

测试时不等待真实的 60 秒:

// src/hooks/usePolling.test.tsx
import { renderHook, act } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { usePolling } from './usePolling';

afterEach(() => {
  vi.useRealTimers();
});

describe('usePolling', () => {
  it('每经过一个周期执行一次任务', async () => {
    vi.useFakeTimers();

    const task = vi.fn().mockResolvedValue(undefined);
    renderHook(() => usePolling(task, 60_000));

    expect(task).not.toHaveBeenCalled();

    await act(async () => {
      await vi.advanceTimersByTimeAsync(60_000);
    });

    expect(task).toHaveBeenCalledTimes(1);
  });

  it('卸载后停止轮询', async () => {
    vi.useFakeTimers();

    const task = vi.fn().mockResolvedValue(undefined);
    const { unmount } = renderHook(() => usePolling(task, 60_000));

    unmount();

    await act(async () => {
      await vi.advanceTimersByTimeAsync(120_000);
    });

    expect(task).not.toHaveBeenCalled();
  });
});

这里有三个关键点:

  • vi.useFakeTimers() 把定时器替换为可控实现;
  • vi.advanceTimersByTimeAsync() 推进时间,并等待由定时器触发的异步任务;
  • 测试结束后调用 vi.useRealTimers(),防止 Fake Timer 泄漏到其他测试。

如果只使用同步的 advanceTimersByTime,定时器回调中的 Promise 可能尚未完成,立即断言会产生假失败。异步任务和时间推进同时存在时,优先使用异步版本,并把状态更新放入 act

3. 使用 setSystemTime 固定当前时刻

// src/time/isExpired.ts
export function isExpired(expiresAt: Date): boolean {
  return expiresAt.getTime() <= Date.now();
}

测试:

import { afterEach, describe, expect, it, vi } from 'vitest';
import { isExpired } from './isExpired';

afterEach(() => {
  vi.useRealTimers();
});

describe('isExpired', () => {
  it('在到期时刻之前未过期', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));

    expect(isExpired(new Date('2025-01-01T00:00:01.000Z'))).toBe(false);
  });

  it('在到期时刻及之后过期', () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2025-01-01T00:00:00.000Z'));

    expect(isExpired(new Date('2024-12-31T23:59:59.999Z'))).toBe(true);
    expect(isExpired(new Date('2025-01-01T00:00:00.000Z'))).toBe(true);
  });
});

Date.now() 的比较使用毫秒时间戳,所以边界必须明确:本例把 expiresAt <= now 定义为已过期。如果产品规则是“到整秒结束才过期”,则实现和测试都应表达该规则,而不能靠测试运行时机碰巧通过。

4. 更容易测试的设计:显式注入时钟

Fake Timer 适合测试现有代码,但业务核心也可以直接接收时间:

export function isExpiredAt(
  expiresAt: Date,
  now: Date,
): boolean {
  return expiresAt.getTime() <= now.getTime();
}

此时测试不需要全局替换:

expect(
  isExpiredAt(
    new Date('2025-01-01T00:00:00.000Z'),
    new Date('2025-01-01T00:00:00.000Z'),
  ),
).toBe(true);

显式注入的优点是依赖关系可见,缺点是需要在更多调用层传递 now。通常可以让边界层读取真实时间,再把时间传给纯业务函数:

export function createExpirationChecker(clock = () => new Date()) {
  return (expiresAt: Date) => isExpiredAt(expiresAt, clock());
}

这样测试核心逻辑不依赖全局时钟,集成测试仍可验证真实时钟接入。


四、网络:控制边界,验证加载、成功、失败和取消

1. 网络请求不是纯函数

网络请求的结果不仅由参数决定,还取决于:

  • 服务器响应;
  • 网络延迟;
  • HTTP 状态;
  • JSON 格式;
  • 用户是否离开页面;
  • 请求是否被取消;
  • 多个请求返回顺序。

因此,测试网络代码时不应访问真实后端。真实后端会让单元测试依赖环境、数据、认证和网络可用性。单元测试应在请求边界替换 fetch 或使用请求拦截工具,主动定义响应。

2. 一个带状态和 AbortController 的 Hook

// src/hooks/useUser.ts
import { useEffect, useState } from 'react';

export type User = {
  id: string;
  name: string;
};

type State =
  | { status: 'loading'; data: null; error: null }
  | { status: 'success'; data: User; error: null }
  | { status: 'error'; data: null; error: Error };

export function useUser(userId: string): State {
  const [state, setState] = useState<State>({
    status: 'loading',
    data: null,
    error: null,
  });

  useEffect(() => {
    const controller = new AbortController();

    setState({
      status: 'loading',
      data: null,
      error: null,
    });

    fetch(`/api/users/${encodeURIComponent(userId)}`, {
      signal: controller.signal,
    })
      .then(async (response) => {
        if (!response.ok) {
          throw new Error(`HTTP ${response.status}`);
        }

        return (await response.json()) as User;
      })
      .then((data) => {
        setState({
          status: 'success',
          data,
          error: null,
        });
      })
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === 'AbortError') {
          return;
        }

        setState({
          status: 'error',
          data: null,
          error: error instanceof Error
            ? error
            : new Error('Unknown error'),
        });
      });

    return () => controller.abort();
  }, [userId]);

  return state;
}

状态转移可以表示为:

stateDiagram-v2
  [*] --> loading: mount 或 userId 变化
  loading --> success: HTTP 2xx 且 JSON 合法
  loading --> error: HTTP 非 2xx 或解析失败
  loading --> [*]: unmount / abort
  success --> loading: userId 变化
  error --> loading: userId 变化

AbortController 的作用不是让请求一定停止于服务器,而是向 fetch 和相关客户端代码发出取消信号,并防止组件卸载后继续提交无意义的成功或失败状态。服务器是否已经收到请求、是否能撤销计算,取决于服务器协议和实现。

3. Mock fetch,但不要 Mock 被测状态机

测试请求成功:

// src/hooks/useUser.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { useUser } from './useUser';

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('useUser', () => {
  it('先进入 loading,再进入 success', async () => {
    const fetchMock = vi.fn().mockResolvedValue(
      new Response(
        JSON.stringify({ id: 'u1', name: 'Ada' }),
        {
          status: 200,
          headers: { 'Content-Type': 'application/json' },
        },
      ),
    );
    vi.stubGlobal('fetch', fetchMock);

    const { result } = renderHook(() => useUser('u1'));

    expect(result.current).toEqual({
      status: 'loading',
      data: null,
      error: null,
    });

    await waitFor(() => {
      expect(result.current).toEqual({
        status: 'success',
        data: { id: 'u1', name: 'Ada' },
        error: null,
      });
    });

    expect(fetchMock).toHaveBeenCalledWith(
      '/api/users/u1',
      expect.objectContaining({
        signal: expect.any(AbortSignal),
      }),
    );
  });
});

waitFor 会重复执行回调,直到回调不再抛出异常或超时。它适合等待异步状态转换;不应在回调外立即断言异步结果,因为 Promise 微任务尚未完成。

测试 HTTP 错误:

it('HTTP 非成功状态进入 error', async () => {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockResolvedValue(
      new Response('Not Found', { status: 404 }),
    ),
  );

  const { result } = renderHook(() => useUser('missing'));

  await waitFor(() => {
    expect(result.current.status).toBe('error');
  });

  expect(result.current).toMatchObject({
    status: 'error',
    data: null,
    error: new Error('HTTP 404'),
  });
});

这里需要注意 Error 对象的比较。toEqual(new Error(...)) 在不同断言库和配置下可读性不如显式检查消息,因此也可以写为:

expect(result.current.error?.message).toBe('HTTP 404');

测试网络拒绝:

it('网络拒绝进入 error', async () => {
  vi.stubGlobal(
    'fetch',
    vi.fn().mockRejectedValue(new Error('network down')),
  );

  const { result } = renderHook(() => useUser('u1'));

  await waitFor(() => {
    expect(result.current.status).toBe('error');
  });

  expect(result.current.error).toEqual(new Error('network down'));
});

测试卸载时取消:

it('卸载时调用 abort', () => {
  const abort = vi.fn();

  class TestAbortController {
    signal = new AbortSignal();
    abort = abort;
  }

  vi.stubGlobal('AbortController', TestAbortController);
  vi.stubGlobal(
    'fetch',
    vi.fn(() => new Promise<Response>(() => {})),
  );

  const { unmount } = renderHook(() => useUser('u1'));

  unmount();

  expect(abort).toHaveBeenCalledTimes(1);
});

这个测试用一个永不完成的 Promise 模拟挂起请求,验证卸载路径是否触发取消。它没有等待请求完成,因为请求被刻意设计为不会完成。

在真实项目中,若多个测试、多个组件都需要模拟 API,使用 MSW 这类请求拦截工具通常比在每个测试里直接替换 global.fetch 更接近真实请求边界,也能统一维护成功、错误和延迟场景。但无论使用哪种工具,测试仍应断言应用状态和用户可观察结果,而不是只断言 Mock 被调用。

4. HTTP 错误与网络错误必须分开理解

fetch 的规范行为是:收到 HTTP 404、500 等响应时,Promise 通常仍然 resolve;只有网络层失败、请求被取消等情况才会 reject。因此,必须显式检查:

if (!response.ok) {
  throw new Error(`HTTP ${response.status}`);
}

只写下面的代码是不完整的:

const response = await fetch(url);
const data = await response.json();

因为 404 响应可能继续进入 json(),最终把 HTTP 错误误报成 JSON 解析错误,或者在错误响应不是 JSON 时产生完全不同的失败信息。

5. 客户端和服务端边界

fetch 在现代浏览器和现代服务端 JavaScript 运行时中都可能存在,但测试环境的默认能力不同:

  • 客户端组件测试通常运行在 jsdom
  • 服务端函数测试可以使用 Node 环境;
  • React Server Component 不是普通客户端组件,不能简单通过 render 当作浏览器组件测试;
  • 框架中的 Server Action、路由处理器、数据库访问应作为服务端模块单独测试;
  • 客户端组件测试应把服务端能力视为外部边界,通过 props、context、请求拦截或框架测试工具提供结果。

例如,数据库函数应直接测试其输入、返回值和错误映射,而不是在 jsdom 中伪造整个服务器运行时。客户端只需测试“服务端返回成功时显示什么、失败时显示什么、加载中如何表现”。


五、组件测试:通过用户可观察结果连接纯函数和 Hook

单元测试组件时,最稳定的边界通常是 DOM:

  • 用户能看到的文本;
  • 可访问角色和名称;
  • 按钮是否可用;
  • 表单提交后的结果;
  • 错误信息是否出现。

示例组件:

// src/components/UserPanel.tsx
import { useUser } from '../hooks/useUser';

export function UserPanel({ userId }: { userId: string }) {
  const state = useUser(userId);

  if (state.status === 'loading') {
    return <p role="status">Loading...</p>;
  }

  if (state.status === 'error') {
    return (
      <p role="alert">
        Failed: {state.error.message}
      </p>
    );
  }

  return <h1>{state.data.name}</h1>;
}

测试:

// src/components/UserPanel.test.tsx
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi, afterEach } from 'vitest';
import { UserPanel } from './UserPanel';

afterEach(() => {
  vi.unstubAllGlobals();
});

describe('UserPanel', () => {
  it('显示加载状态和用户名称', async () => {
    let resolveRequest!: (response: Response) => void;

    vi.stubGlobal(
      'fetch',
      vi.fn(
        () =>
          new Promise<Response>((resolve) => {
            resolveRequest = resolve;
          }),
      ),
    );

    render(<UserPanel userId="u1" />);

    expect(screen.getByRole('status')).toHaveTextContent('Loading');

    resolveRequest(
      new Response(JSON.stringify({ id: 'u1', name: 'Ada' }), {
        status: 200,
      }),
    );

    expect(await screen.findByRole('heading', { name: 'Ada' }))
      .toBeInTheDocument();
  });

  it('显示请求错误', async () => {
    vi.stubGlobal(
      'fetch',
      vi.fn().mockRejectedValue(new Error('network down')),
    );

    render(<UserPanel userId="u1" />);

    expect(
      await screen.findByRole('alert'),
    ).toHaveTextContent('network down');
  });
});

getByRole 是同步查询,元素不存在时立即失败;findByRole 是异步查询,适合等待元素出现;queryByRole 在元素不存在是正常情况时返回 null,适合断言“不存在”。

这里没有断言:

expect(screen.getByTestId('user-panel-internal-state')).toBe(...);

因为内部状态不是组件对用户的契约。只要组件仍然显示加载、成功和错误状态,内部是 useState、Reducer 还是其他实现,都不应影响测试。


六、稳定断言:验证契约,而不是冻结实现

1. 稳定断言的含义

稳定断言是指:当外部行为没有变化、只有内部实现重构时,测试仍然通过;而当外部行为真的违反契约时,测试能够失败。

可以把测试依赖分为三层:

  1. 业务输出:例如金额、状态、错误消息;
  2. 用户可观察行为:例如可访问名称、按钮禁用、页面文本;
  3. 实现细节:例如 state 变量名、组件层级、私有函数调用顺序。

通常应优先断言前两层。第三层只有在它本身就是公共契约,例如某个适配器必须调用特定 SDK,才值得验证。

2. 几类稳定断言

断言完整对象时控制范围

expect(result).toMatchObject({
  status: 'success',
  data: { id: 'u1' },
});

toMatchObject 只要求指定字段符合预期,适合响应中包含不关心的服务端字段时使用。但不能滥用,否则关键字段缺失也可能漏测。核心业务对象应使用 toEqual 或逐字段断言。

对集合断言业务内容

不稳定:

expect(screen.getAllByRole('listitem')).toHaveLength(3);

如果数量不是业务契约,这个断言会因为增加装饰项而失败。

更稳定:

expect(screen.getByRole('listitem', { name: 'Keyboard' }))
  .toBeInTheDocument();

如果顺序是业务要求,应明确断言顺序;否则不要因为 DOM 排列变化而绑定测试。

异步元素使用语义查询

expect(await screen.findByRole('alert')).toHaveTextContent(
  'Failed',
);

不要用固定延时:

await new Promise((resolve) => setTimeout(resolve, 1000));

固定等待不能证明状态已经完成,只会让测试变慢;在繁忙机器上,一秒也可能不够。findBy*waitFor 根据 DOM 或断言条件等待,失败时还会提供更接近原因的上下文。

事件处理器调用次数只在契约需要时断言

expect(onSubmit).toHaveBeenCalledTimes(1);

这只有在“提交一次只能通知一次”属于契约时才有意义。如果组件内部改成调用另一层函数,但用户看到的结果正确,测试不应因为调用次数变化而失败。

3. 快照测试的边界

快照会记录一大段序列化输出:

expect(container).toMatchSnapshot();

它适合以下情况:

  • 输出结构本身是稳定的;
  • 变更需要人工审阅;
  • 快照内容不会包含随机 ID、时间、环境路径和无关属性。

它不适合代替行为断言。大组件快照一旦变得很长,开发者容易在更新快照时忽略真实回归。通常应先写:

expect(screen.getByRole('button', { name: 'Save' }))
  .toBeEnabled();

再在确有结构审查价值的局部使用快照。


七、Mock、Stub、Spy 的边界和清理

Vitest 中常见的三种替换方式含义不同:

  • Mock:提供一个替代实现,例如 vi.fn().mockResolvedValue(...)
  • Stub:替换全局或模块暴露的对象,例如 vi.stubGlobal('fetch', ...)
  • Spy:保留原实现,同时记录调用,例如 vi.spyOn(window, 'removeEventListener')

如果测试替换了全局对象,必须恢复:

afterEach(() => {
  vi.unstubAllGlobals();
  vi.useRealTimers();
});

clearMocks: true 只清除调用记录,不一定恢复实现;restoreMocks: true 用于恢复 Spy 等被替换的原实现,但全局 stub 仍应根据 Vitest 配置和使用方式明确清理。不要依赖测试执行顺序来恢复环境。

模块 Mock 也有边界。假设直接 Mock 了整个 useUser

vi.mock('../hooks/useUser');

那么 UserPanel 测试只验证了“组件能渲染一个预设 Hook 结果”,没有验证请求、加载和错误状态机。这样做并非错误,但它是组件单元测试,而不是 Hook 或网络集成测试。测试名称和职责应保持一致。


八、常见失败表现与诊断路径

1. act 警告

典型现象:

An update to ... inside a test was not wrapped in act(...)

原因通常是:

  • 直接调用了状态更新函数;
  • Promise 完成后触发了状态更新,但测试没有等待;
  • Timer 推进后没有等待异步回调;
  • 组件在测试结束后仍有异步更新。

诊断顺序:

  1. 找到产生状态更新的 Promise、Timer 或事件;
  2. 同步更新用 act(() => ...)
  3. 异步更新用 await act(async () => ...)findBy* / waitFor
  4. 检查卸载时是否清理定时器、事件和请求。

2. 测试偶尔失败

常见根因不是 Vitest 随机,而是未控制的外部状态:

  • 使用真实当前时间;
  • 等待真实网络;
  • 测试之间共享模块变量;
  • Fake Timer 未恢复;
  • Promise 未等待完成;
  • 使用随机 ID 但没有固定随机源;
  • 断言完整 DOM 结构而非业务行为。

应先把外部依赖逐个变成可控输入,而不是增加重试次数。重试可以掩盖竞态,不能修复竞态。

3. “找不到元素”但页面最终会显示

错误:

render(<UserPanel userId="u1" />);

expect(screen.getByRole('heading', { name: 'Ada' }))
  .toBeInTheDocument();

组件初次渲染仍处于 loading,所以同步查询必然失败。应改为:

expect(
  await screen.findByRole('heading', { name: 'Ada' }),
).toBeInTheDocument();

如果元素可能出现也可能不出现,则使用 waitForqueryBy*,但必须明确“不出现”是否是预期行为。

4. 更新状态后读到旧值

React 状态更新不是直接修改当前渲染中的普通变量:

act(() => {
  result.current.increment();
});

expect(result.current.count).toBe(1);

应从下一次渲染后的 result.current 读取值。不要保存旧对象后期待它被原地修改:

const oldResult = result.current;

act(() => {
  oldResult.increment();
});

// oldResult.count 不是可靠的“最新渲染结果”

Hook 返回对象可能在每次渲染中重新创建,测试应读取测试库暴露的当前结果。


九、一个可执行的测试组织方式

可以按被测边界组织文件:

src/
  cart/
    total.ts
    total.test.ts
  hooks/
    useCounter.ts
    useCounter.test.tsx
    usePolling.ts
    usePolling.test.tsx
    useUser.ts
    useUser.test.tsx
  components/
    UserPanel.tsx
    UserPanel.test.tsx
  test/
    setup.ts

这种组织方式不是强制规范,但它使测试职责清晰:

  • total.test.ts 不需要 DOM;
  • useCounter.test.tsx 验证状态转换;
  • usePolling.test.tsx 验证 Timer、异步和清理;
  • useUser.test.tsx 验证请求状态机;
  • UserPanel.test.tsx 验证用户可观察界面。

测试层次之间可以共享业务类型和测试数据,但不应把一个层次的 Mock 规则隐式泄漏到另一个层次。


十、从单元测试到真实边界验证

单元测试能快速验证局部因果关系,但不能证明所有系统行为。例如:

  • jsdom 不能证明真实浏览器布局;
  • Mock fetch 不能证明后端 API 契约;
  • Hook 测试不能证明路由、Provider 和框架数据加载器已经正确连接;
  • Server Component 单独测试不能证明客户端水合和浏览器交互;
  • 纯函数测试不能证明数据库事务或权限配置。

因此可以按边界增加更高层测试:

  1. 纯函数单元测试:输入和输出确定;
  2. Hook 单元测试:状态、Effect、清理和异步转换;
  3. 组件测试:DOM、可访问角色和用户行为;
  4. 请求拦截测试:客户端与 API 形状的连接;
  5. 端到端测试:真实浏览器、路由、服务端和关键业务路径。

层次越高,环境越真实,执行成本和故障来源也越多。稳定断言的原则在每一层都成立:测试当前层负责的契约,不越界替代其他层的证明。

当纯函数、Hook、时间和网络被分别建模后,测试就不再是“渲染组件然后等待某个文本”的技巧集合,而是对状态、时间和外部边界的可重复实验。稳定断言则把实验结果绑定到业务行为,而不是绑定到某次实现方式。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。