React 的 act 函数详解

354 阅读10分钟

React 的 act 函数详解

在 React 测试中,act 函数是一个关键工具,用于确保所有的更新和副作用都已正确执行和渲染。

什么是 act

act 是 React 的一个测试工具函数,旨在帮助开发者在测试中确保组件的所有状态更新和副作用都已应用。它由 React 测试实用工具包(如 React Testing LibraryEnzyme)提供,用于包裹任何可能引起状态更新的操作,如事件处理、API 调用等。

为什么需要 act

在 React 中,组件的状态更新可能是同步或异步的,并且可能涉及多个阶段的渲染和副作用。为了确保测试的可靠性和准确性,必须等待所有这些更新完成后再进行断言。这就是 act 发挥作用的地方。

如果不使用 act,测试可能会在组件的某些状态更新或副作用尚未完成时执行断言,从而导致测试不稳定或产生错误的结果。

act 的作用

act 的主要作用是:

  1. 确保所有副作用都已执行:在 act 块内的所有状态更新和副作用都会被完成和应用。
  2. 提高测试的可靠性:通过等待 act 块内的操作完成,确保测试在正确的时间点进行断言。
  3. 简化测试逻辑:减少因状态更新未完成而导致的测试错误,使编写同步和异步测试更为简单。

act 的使用示例

基本用法示例

下面是一个简单的示例,展示如何在测试中使用 act 来包裹一个状态更新。

import React, { useState } from 'react';

function Counter() {
    const [count, setCount] = useState(0);

    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);

    return (
        <div>
            <p>当前计数:{count}</p>
            <button onClick={increment}>+1</button>
            <button onClick={decrement}>-1</button>
        </div>
    );
}

export default Counter;
import React from 'react';
import { render, fireEvent, screen, act } from '@testing-library/react';
import Counter from '../Counter';

describe('Counter 组件', () => {
    it('应该渲染初始计数为 0', () => {
        render(<Counter />);
        expect(screen.getByText('当前计数:0')).toBeInTheDocument();
    });

    it('点击 +1 按钮后计数应该增加', () => {
        render(<Counter />);
        const incrementButton = screen.getByText('+1');

        act(() => {
            fireEvent.click(incrementButton);
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
    });

    it('点击 -1 按钮后计数应该减少', () => {
        render(<Counter />);
        const decrementButton = screen.getByText('-1');

        act(() => {
            fireEvent.click(decrementButton);
        });

        expect(screen.getByText('当前计数:-1')).toBeInTheDocument();
    });
});
详细解释
  1. 组件定义Counter 组件使用 useState 管理 count 状态,并提供两个按钮用于增加和减少计数。
  2. 测试用例
    • 初始渲染测试:验证组件渲染时计数显示为 0
    • 增加计数测试:使用 act 包裹 fireEvent.click 操作,确保计数更新后再进行断言。
    • 减少计数测试:同理,包裹 fireEvent.click 操作,验证计数减少。

与异步操作结合

在实际应用中,组件的状态更新可能源自异步操作,如 API 调用。下面展示如何在测试中处理异步操作并使用 act

import React, { useState } from 'react';

function AsyncCounter() {
    const [count, setCount] = useState(0);

    const incrementAsync = () => {
        setTimeout(() => {
            setCount(prev => prev + 1);
        }, 1000);
    };

    return (
        <div>
            <p>当前计数:{count}</p>
            <button onClick={incrementAsync}>异步 +1</button>
        </div>
    );
}

export default AsyncCounter;
import React from 'react';
import { render, fireEvent, screen, act } from '@testing-library/react';
import AsyncCounter from '../AsyncCounter';

jest.useFakeTimers();

describe('AsyncCounter 组件', () => {
    it('点击 异步 +1 按钮后计数应该增加', () => {
        render(<AsyncCounter />);
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
            jest.runAllTimers();
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
    });
});
详细解释
  1. 组件定义AsyncCounter 组件提供一个异步增加计数的按钮,点击后通过 setTimeout 延迟 1 秒更新状态。
  2. 测试用例
    • 使用假定时器:通过 jest.useFakeTimers() 控制定时器,使测试更加高效。
    • 包裹异步操作:在 act 内部点击按钮并运行所有定时器,确保异步状态更新完成后再进行断言。
    • 断言:验证计数是否正确增加。

与 React Testing Library 结合

React Testing Library 本身已经内置了一些 act 的功能,但在某些情况下,手动使用 act 仍然是必要的。下面展示如何结合使用 act 和 React Testing Library 进行复杂测试。

import React, { useState } from 'react';

function UserFetcher() {
    const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(false);

    const fetchUser = async () => {
        setLoading(true);
        const response = await fetch('https://jsonplaceholder.typicode.com/users/1');
        const data = await response.json();
        setUser(data);
        setLoading(false);
    };

    return (
        <div>
            <button onClick={fetchUser}>获取用户</button>
            {loading && <p>加载中...</p>}
            {user && (
                <div>
                    <p>姓名:{user.name}</p>
                    <p>邮箱:{user.email}</p>
                </div>
            )}
        </div>
    );
}

export default UserFetcher;
import React from 'react';
import { render, fireEvent, screen, act } from '@testing-library/react';
import UserFetcher from '../UserFetcher';

global.fetch = jest.fn(() =>
    Promise.resolve({
        json: () => Promise.resolve({ name: 'John Doe', email: 'john@example.com' }),
    })
) as jest.Mock;

describe('UserFetcher 组件', () => {
    it('点击获取用户按钮后显示用户信息', async () => {
        render(<UserFetcher />);
        const fetchButton = screen.getByText('获取用户');

        await act(async () => {
            fireEvent.click(fetchButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        // 等待所有异步操作完成
        await act(async () => {
            // 等待 fetch 完成
            await Promise.resolve();
        });

        expect(screen.getByText('姓名:John Doe')).toBeInTheDocument();
        expect(screen.getByText('邮箱:john@example.com')).toBeInTheDocument();
    });
});
详细解释
  1. 组件定义UserFetcher 组件提供一个按钮,用于异步获取用户信息并展示。
  2. 测试用例
    • Mock fetch:使用 jest.fn() 模拟 fetch 方法,返回预期的用户数据。
    • 包裹异步点击操作:在 act 内部点击按钮,确保所有状态更新在断言前完成。
    • 等待异步操作:使用 await act(async () => { ... }) 确保所有异步操作完成后再进行断言。
    • 断言:验证加载文本和用户信息是否正确显示。

act 的优点

  1. 确保测试准确性:通过包裹所有可能引起状态更新的操作,确保组件的所有更新和副作用都已正确执行。
  2. 提高测试可靠性:减少因状态更新未完成而导致的假阳性或假阴性测试结果。
  3. 简化异步测试:在处理异步操作时,act 提供了一种统一的方法来等待所有更新完成。
  4. 与 React 兼容act 是 React 官方提供的测试工具,确保与 React 版本的兼容性和最佳实践。

act 的缺点

  1. 增加测试复杂性:在某些情况下,尤其是涉及多个异步操作时,使用 act 可能会使测试代码变得更复杂。
  2. 可能导致冗余代码:对于简单的同步操作,过度使用 act 可能会导致不必要的代码包裹。
  3. 需要理解异步行为:开发者需要深入理解组件的异步行为和状态更新机制,以正确使用 act

最佳实践

  1. 仅在必要时使用 act:对于 React Testing Library,某些操作已经隐式地包裹在 act 中。仅在需要确保特定更新完成时才手动使用 act
  2. 保持测试简洁:尽量保持测试代码简洁,避免过度嵌套 act 块。
  3. 正确处理异步操作:在处理异步操作时,确保 act 块内的所有异步行为都已完成,以避免测试不稳定。
  4. 合理组织测试文件:将与 act 相关的测试逻辑组织在一起,使代码更具可读性和可维护性。
  5. 使用辅助函数:对于重复使用的 act 逻辑,可以封装成辅助函数,提高测试代码的复用性。

完整代码示例

以下是一个完整的代码示例,包括组件定义和详尽的测试用例,展示了如何在不同场景下使用 act

Counter 组件

import React, { useState } from 'react';

function Counter() {
    const [count, setCount] = useState(0);

    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);
    const reset = () => setCount(0);

    return (
        <div>
            <p>当前计数:{count}</p>
            <button onClick={increment}>+1</button>
            <button onClick={decrement}>-1</button>
            <button onClick={reset}>重置</button>
        </div>
    );
}

export default Counter;

Counter 组件测试

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

describe('Counter 组件', () => {
    beforeEach(() => {
        render(<Counter />);
    });

    it('应该渲染初始计数为 0', () => {
        expect(screen.getByText('当前计数:0')).toBeInTheDocument();
    });

    it('点击 +1 按钮后计数应该增加', () => {
        const incrementButton = screen.getByText('+1');

        act(() => {
            fireEvent.click(incrementButton);
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
    });

    it('点击 -1 按钮后计数应该减少', () => {
        const decrementButton = screen.getByText('-1');

        act(() => {
            fireEvent.click(decrementButton);
        });

        expect(screen.getByText('当前计数:-1')).toBeInTheDocument();
    });

    it('点击重置按钮后计数应该重置为 0', () => {
        const incrementButton = screen.getByText('+1');
        const resetButton = screen.getByText('重置');

        act(() => {
            fireEvent.click(incrementButton);
            fireEvent.click(resetButton);
        });

        expect(screen.getByText('当前计数:0')).toBeInTheDocument();
    });

    it('连续点击按钮后计数应正确更新', () => {
        const incrementButton = screen.getByText('+1');
        const decrementButton = screen.getByText('-1');

        act(() => {
            fireEvent.click(incrementButton);
            fireEvent.click(incrementButton);
            fireEvent.click(decrementButton);
            fireEvent.click(incrementButton);
        });

        expect(screen.getByText('当前计数:2')).toBeInTheDocument();
    });
});

高级测试示例:异步操作与 act

假设我们有一个异步更新计数的组件,点击按钮后等待 1 秒钟再更新计数。

import React, { useState } from 'react';

function AsyncCounter() {
    const [count, setCount] = useState(0);
    const [loading, setLoading] = useState(false);

    const incrementAsync = () => {
        setLoading(true);
        setTimeout(() => {
            setCount(prev => prev + 1);
            setLoading(false);
        }, 1000);
    };

    return (
        <div>
            <p>当前计数:{count}</p>
            {loading && <p>加载中...</p>}
            <button onClick={incrementAsync}>异步 +1</button>
        </div>
    );
}

export default AsyncCounter;
import React from 'react';
import { render, fireEvent, screen, act } from '@testing-library/react';
import AsyncCounter from '../AsyncCounter';

jest.useFakeTimers();

describe('AsyncCounter 组件', () => {
    beforeEach(() => {
        render(<AsyncCounter />);
    });

    it('点击 异步 +1 按钮后计数应该增加', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        act(() => {
            jest.advanceTimersByTime(1000);
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
        expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
    });

    it('连续多次点击 异步 +1 按钮后计数应正确更新', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
            fireEvent.click(asyncIncrementButton);
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        act(() => {
            jest.advanceTimersByTime(3000);
        });

        expect(screen.getByText('当前计数:3')).toBeInTheDocument();
        expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
    });

    it('在异步更新期间点击重置按钮', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        // 假设有一个重置按钮
        // 暂无重置按钮,若有则可以类似以上示例添加
    });
});
详细解释
  1. 组件定义AsyncCounter 提供一个异步增加计数的按钮,点击后通过 setTimeout 延迟 1 秒更新状态,同时显示加载状态。
  2. 测试用例
    • 使用假定时器:通过 jest.useFakeTimers() 控制定时器,使测试更加高效。
    • 包裹异步操作:在 act 内部点击按钮,并推进定时器,确保异步状态更新完成后再进行断言。
    • 断言
      • 验证加载文本是否正确显示。
      • 验证异步更新后计数是否正确增加。
      • 验证加载文本在更新完成后是否消失。

总结

React 的 act 函数在测试中起到了至关重要的作用,确保组件的所有状态更新和副作用都已被正确执行和渲染。通过适当使用 act,可以提高测试的可靠性和准确性,特别是在处理异步操作时。

然而,开发者在使用 act 时需注意避免过度包裹简单操作,以及理解其在不同场景下的应用方式。结合最佳实践和详细的测试用例,可以充分利用 act 的优势,编写出高质量、可维护的测试代码。

附录:完整代码文件结构

以下是上述示例代码的完整文件结构和内容概要:

src/
├── components/
│   ├── AsyncCounter.tsx
│   ├── Counter.tsx
│   └── __tests__/
│       ├── AsyncCounter.test.tsx
│       └── Counter.test.tsx
└── index.tsx

src/components/Counter.tsx

import React, { useState } from 'react';

function Counter() {
    const [count, setCount] = useState(0);

    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);
    const reset = () => setCount(0);

    return (
        <div>
            <p>当前计数:{count}</p>
            <button onClick={increment}>+1</button>
            <button onClick={decrement}>-1</button>
            <button onClick={reset}>重置</button>
        </div>
    );
}

export default Counter;

src/components/__tests__/Counter.test.tsx

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

describe('Counter 组件', () => {
    beforeEach(() => {
        render(<Counter />);
    });

    it('应该渲染初始计数为 0', () => {
        expect(screen.getByText('当前计数:0')).toBeInTheDocument();
    });

    it('点击 +1 按钮后计数应该增加', () => {
        const incrementButton = screen.getByText('+1');

        act(() => {
            fireEvent.click(incrementButton);
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
    });

    it('点击 -1 按钮后计数应该减少', () => {
        const decrementButton = screen.getByText('-1');

        act(() => {
            fireEvent.click(decrementButton);
        });

        expect(screen.getByText('当前计数:-1')).toBeInTheDocument();
    });

    it('点击重置按钮后计数应该重置为 0', () => {
        const incrementButton = screen.getByText('+1');
        const resetButton = screen.getByText('重置');

        act(() => {
            fireEvent.click(incrementButton);
            fireEvent.click(resetButton);
        });

        expect(screen.getByText('当前计数:0')).toBeInTheDocument();
    });

    it('连续点击按钮后计数应正确更新', () => {
        const incrementButton = screen.getByText('+1');
        const decrementButton = screen.getByText('-1');

        act(() => {
            fireEvent.click(incrementButton);
            fireEvent.click(incrementButton);
            fireEvent.click(decrementButton);
            fireEvent.click(incrementButton);
        });

        expect(screen.getByText('当前计数:2')).toBeInTheDocument();
    });
});

src/components/AsyncCounter.tsx

import React, { useState } from 'react';

function AsyncCounter() {
    const [count, setCount] = useState(0);
    const [loading, setLoading] = useState(false);

    const incrementAsync = () => {
        setLoading(true);
        setTimeout(() => {
            setCount(prev => prev + 1);
            setLoading(false);
        }, 1000);
    };

    return (
        <div>
            <p>当前计数:{count}</p>
            {loading && <p>加载中...</p>}
            <button onClick={incrementAsync}>异步 +1</button>
        </div>
    );
}

export default AsyncCounter;

src/components/__tests__/AsyncCounter.test.tsx

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

jest.useFakeTimers();

describe('AsyncCounter 组件', () => {
    beforeEach(() => {
        render(<AsyncCounter />);
    });

    it('点击 异步 +1 按钮后计数应该增加', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        act(() => {
            jest.advanceTimersByTime(1000);
        });

        expect(screen.getByText('当前计数:1')).toBeInTheDocument();
        expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
    });

    it('连续多次点击 异步 +1 按钮后计数应正确更新', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
            fireEvent.click(asyncIncrementButton);
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        act(() => {
            jest.advanceTimersByTime(3000);
        });

        expect(screen.getByText('当前计数:3')).toBeInTheDocument();
        expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
    });

    it('在异步更新期间点击重置按钮', () => {
        const asyncIncrementButton = screen.getByText('异步 +1');

        act(() => {
            fireEvent.click(asyncIncrementButton);
        });

        expect(screen.getByText('加载中...')).toBeInTheDocument();

        // 假设有一个重置按钮
        // 若添加重置按钮,可在此处点击并验证结果
    });
});

参考资料