Langflow 前端异步测试模式:Jest 与 React Testing Library 的实战指南
2026/9/7 17:39:14 网站建设 项目流程

Langflow 前端异步测试模式:Jest 与 React Testing Library 的实战指南

【免费下载链接】langflowLangflow is a powerful tool for building and deploying AI-powered agents and workflows.项目地址: https://gitcode.com/GitHub_Trending/la/langflow

本文基于 Langflow 前端测试技能文档references/async-testing.md,系统讲解在 Langflow 前端(Jest 30 + React Testing Library 16 + jsdom 技术栈)中测试异步行为的完整模式:waitFor的轮询机制与选项、waitForElementToBeRemovedfindBy*查询、Fake Timers 及其与异步操作的组合、Promise 拒绝、防抖函数、事件流/模拟 WebSocket、React Query mutations 以及受控加载态。读完后你将能够独立编写确定性、无 flaky 的异步组件测试,并理解 Langflow 仓库中这些模式的实际落地方式。

测试基础设施:这些模式运行在什么环境上

理解异步测试模式之前,先确认 Langflow 前端测试环境的实际构成,因为waitFor的默认行为、Fake Timers 的可用性以及全局 mock 都依赖这一套配置:

技术版本(以 package.json 为准)用途
Jest^30.0.3测试运行器与断言框架
ts-jest^29.4.0TypeScript 转换
@testing-library/react^16.3.1组件渲染与 DOM 查询
@testing-library/user-event^14.5.2真实用户交互模拟
@testing-library/jest-dom^6.9.1扩展 DOM 匹配器(toBeInTheDocument等)
jest-environment-jsdom^30.0.2浏览器环境模拟
@tanstack/react-query^5.49.2服务端状态管理

关键配置位于 jest.config.js:

  • preset: "ts-jest"testEnvironment: "jsdom",覆盖率由coverageProvider: "v8"提供;
  • testMatch匹配src/**/__tests__/**/*.{test,spec}.{ts,tsx}src/**/*.{test,spec}.{ts,tsx},即测试文件放在__tests__目录或源码同目录;
  • setupFiles: ["<rootDir>/jest.setup.js"]负责全局 mock(react-i18nextlocalStoragesessionStoragecryptoimport.meta.env等);setupFilesAfterEach阶段的 setupTests.ts 则引入@testing-library/jest-dom匹配器,并 mock 了ResizeObserverIntersectionObserverwindow.matchMedia等 jsdom 缺失的浏览器 API——很多异步组件(懒加载、滚动监听)在 jsdom 下不 mock 这些就会直接崩溃;
  • moduleNameMapper@/别名映射到<rootDir>/src/,样式文件统一映射到空 mock。

常用命令(在src/frontend目录下执行):

npm test # 运行全部测试 npm test -- path/to/file.test.tsx # 运行单个测试文件 npm test -- --testPathPattern="alertStore" # 按模式匹配运行 npm run test:watch # watch 模式 npm run test:coverage # 带覆盖率运行

需要牢记的约束:Langflow 测试栈只用 Jest API(jest.fn()jest.mock()jest.spyOn()jest.mocked()),禁止使用 Vitest 的vi.*等价物;用户交互一律用@testing-library/user-event而非fireEvent;涉及 React 状态更新时必须用act()包裹。

waitFor:等待异步操作完成的核心工具

waitFor用于在断言前等待异步操作完成——它会对回调进行轮询,直到断言通过或超时。这是所有异步断言的基石:

import { render, screen, waitFor } from "@testing-library/react"; it("should load and display data", async () => { jest.mocked(api.get).mockResolvedValueOnce({ data: { items: [{ id: "1", name: "Test Item" }] }, }); render(<ItemList />); // Wait for the async data to appear await waitFor(() => { expect(screen.getByText("Test Item")).toBeInTheDocument(); }); });

waitFor 选项

waitFor的第二个参数可以控制轮询行为:

await waitFor( () => { expect(screen.getByText("loaded")).toBeInTheDocument(); }, { timeout: 3000, // Max time to wait (default: 1000ms) interval: 50, // Polling interval (default: 50ms) }, );
  • timeout:最大等待时间,默认 1000ms。对于本仓库这类走 axios 网络层或 React Query 缓存的组件,若 mock 的 resolve 较慢或组件有防抖逻辑,可按需调大;
  • interval:轮询间隔,默认 50ms。配合 Fake Timers 时该值决定了需要推进多少虚拟时间才能命中一次轮询。

waitFor 最佳实践

  • waitFor内只放一个断言。多个断言组合会导致误导性的失败:若第二条断言失败,无法判断第一条是否曾经为真。
  • waitFor用于“元素出现”,而非“元素消失”。等待消失应使用waitForElementToBeRemoved
  • 同步场景不要用waitFor——它只会引入不必要的轮询延迟。元素在render后已存在时,直接用getBy*断言即可。

正反例对比:

// GOOD: Single assertion in waitFor await waitFor(() => { expect(screen.getByText("Data loaded")).toBeInTheDocument(); }); // BAD: Multiple assertions -- if second fails, first may have been true await waitFor(() => { expect(screen.getByText("Data loaded")).toBeInTheDocument(); expect(screen.getByText("5 items")).toBeInTheDocument(); // unreliable }); // GOOD: Chain waitFor calls await waitFor(() => { expect(screen.getByText("Data loaded")).toBeInTheDocument(); }); expect(screen.getByText("5 items")).toBeInTheDocument();

waitForElementToBeRemoved:等待元素消失

加载指示器、临时 toast、下拉弹层等“先出现后消失”的 UI,用waitForElementToBeRemoved断言其消失时刻:

it("should hide loading spinner after data loads", async () => { jest.mocked(api.get).mockResolvedValueOnce({ data: [] }); render(<ItemList />); // Spinner appears immediately expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); // Wait for it to disappear await waitForElementToBeRemoved(() => screen.queryByTestId("loading-spinner"), ); // Now assert the loaded state expect(screen.getByText("No items found")).toBeInTheDocument(); });

注意这里查询回调使用的是queryByTestId而非getByTestId:元素消失后getBy*会抛错,而waitForElementToBeRemoved依赖查询返回null来判断“已移除”。

findBy 查询:waitFor + getBy 的语法糖

findBy*系列查询返回一个 Promise,在元素出现时 resolve,等价于waitFor(() => getBy*(...))

it("should display async content", async () => { jest.mocked(api.get).mockResolvedValueOnce({ data: { name: "Test" } }); render(<AsyncComponent />); // findByText = waitFor(() => getByText(...)) const element = await screen.findByText("Test"); expect(element).toBeInTheDocument(); });

需要自定义超时时,把选项作为第三个参数传入:

const element = await screen.findByText("Slow content", {}, { timeout: 5000 });

Langflow 仓库中findBy*是实际在用的写法,例如 ModelInputComponent.test.tsx、ChunksMetadataFilter.test.tsx 等测试均依赖它断言异步加载的模型列表与过滤结果,这与文档中的模式完全一致。

Fake Timers:可控时间的定时器测试

当组件使用setTimeoutsetIntervalDate.now时,用 Fake Timers 让时间完全受测试控制。文档给出的标准结构如下:

describe("TimerComponent", () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); }); it("should update after interval", () => { render(<TimerComponent interval={1000} />); expect(screen.getByText("0 seconds")).toBeInTheDocument(); act(() => { jest.advanceTimersByTime(3000); }); expect(screen.getByText("3 seconds")).toBeInTheDocument(); }); it("should clean up interval on unmount", () => { const clearIntervalSpy = jest.spyOn(global, "clearInterval"); const { unmount } = render(<TimerComponent interval={1000} />); unmount(); expect(clearIntervalSpy).toHaveBeenCalled(); clearIntervalSpy.mockRestore(); }); });

这个“beforeEach开启 fake timers /afterEach先执行jest.runOnlyPendingTimers()jest.useRealTimers()”的模板在 Langflow 的真实测试中反复出现。一个几乎逐字对应的实例是 use-debounce.test.ts,它测试防抖 hook 时使用了同一套清理结构,并通过多次调用 +jest.advanceTimersByTime(500)验证“连续触发只回调一次且携带最后一次参数”的防抖语义:

beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); }); afterEach(() => { jest.runOnlyPendingTimers(); jest.useRealTimers(); });

组合 Fake Timers 与异步操作

当组件同时使用定时器和异步操作(如轮询 API)时,需要交替使用“推进虚拟时间”与“等待 Promise 落定”两个手段:

it("should poll for updates", async () => { jest.useFakeTimers(); const mockGet = jest.mocked(api.get); mockGet .mockResolvedValueOnce({ data: { status: "pending" } }) .mockResolvedValueOnce({ data: { status: "complete" } }); render(<PollingComponent />); // First fetch happens immediately await waitFor(() => { expect(screen.getByText("pending")).toBeInTheDocument(); }); // Advance past the polling interval act(() => { jest.advanceTimersByTime(5000); }); // Second fetch resolves await waitFor(() => { expect(screen.getByText("complete")).toBeInTheDocument(); }); jest.useRealTimers(); });

这个“定时器触发 +waitFor断言结果”的交替模式正是 Langflow 知识库轮询 hook 测试的核心结构。useKnowledgeBasePolling.test.ts 测试了知识底座在“ingesting”状态下的 6 秒轮询行为:beforeEachjest.useFakeTimers(),用例内通过await act(async () => { jest.advanceTimersByTime(6000); })推进一个轮询周期,随后断言api.get是否被调用(未处于轮询状态时expect(mockApiGet).not.toHaveBeenCalled())。该文件同时展示了jest.mock("@/controllers/API/api", ...)模块 mock 与renderHook+QueryClientProviderwrapper 的组合,是上述模式的完整落地范例。

Mock Date.now

对于用Date.now()计算经过时间的组件,手动替换Date.now再配合 fake timers 推进间隔回调:

it("should track elapsed time", async () => { const realDateNow = Date.now; let mockTime = 1000000; Date.now = jest.fn(() => mockTime); jest.useFakeTimers(); render(<ElapsedTimer />); // Advance mock time by 2 seconds mockTime += 2000; act(() => { jest.advanceTimersByTime(100); // Trigger interval callback }); await waitFor(() => { expect(screen.getByText("2.0s")).toBeInTheDocument(); }); Date.now = realDateNow; jest.useRealTimers(); });

要点:修改mockTime只是让组件下一次读取Date.now时得到新值;真正让组件“感知”时间变化、触发重渲染的,是act内推进的定时器。用完必须恢复Date.now并切回 real timers,否则会污染同文件后续用例。

测试 Promise 拒绝:API 失败路径

mockRejectedValueOnce让 mock 精确地拒绝一次请求,配合waitFor断言错误 UI:

it("should display error message on API failure", async () => { jest.mocked(api.get).mockRejectedValueOnce(new Error("Server error")); render(<DataComponent />); await waitFor(() => { expect(screen.getByText(/server error/i)).toBeInTheDocument(); }); });

这也是文档中“对抗性测试”原则(不只测 happy path)在异步场景的直接体现:网络 5xx、401/403/404、超时等错误分支都应按此模式覆盖。

测试防抖函数

搜索输入、自动保存等防抖场景,必须组合 fake timers 与userEvent,且要在userEvent.setup时传入advanceTimers

it("should debounce search input", async () => { jest.useFakeTimers(); const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); render(<SearchInput onSearch={mockSearch} debounceMs={300} />); const input = screen.getByRole("textbox"); await user.type(input, "hello"); // Should not have called search yet expect(mockSearch).not.toHaveBeenCalled(); // Advance past debounce delay act(() => { jest.advanceTimersByTime(300); }); expect(mockSearch).toHaveBeenCalledWith("hello"); jest.useRealTimers(); });

重要:在 fake timers 下使用userEvent时,必须传入advanceTimers: jest.advanceTimersByTime,这样 userEvent 才能在其内部延迟(按键间隔、click 的 pointer 阶段)中推进虚拟时间,否则await user.type(...)会永远等待真实时间而卡死或超时。

仓库中 use-debounce.test.ts 展示了 hook 层的防抖测试写法:先 mocklodash.debounce(保留真实的setTimeout语义以便 fake timers 接管),再用“快速多次调用 → 未到延迟断言未触发 →advanceTimersByTime后断言仅触发一次”验证防抖边界,包括“rapid calls”场景下回调只携带最后一个参数(toHaveBeenCalledWith(9))。

测试事件流与 WebSocket

Langflow 前端的对话、流式输出等场景依赖事件源。测试组件消费 SSE/WebSocket 事件时,用jest.spyOn替换全局EventSource为受控 mock,再手动触发监听器:

it("should handle streaming messages", async () => { const mockEventSource = { addEventListener: jest.fn(), removeEventListener: jest.fn(), close: jest.fn(), }; jest.spyOn(global, "EventSource" as any).mockImplementation( () => mockEventSource, ); render(<StreamingChat />); // Simulate incoming message const onMessage = mockEventSource.addEventListener.mock.calls.find( ([event]: [string]) => event === "message", )?.[1]; act(() => { onMessage?.({ data: JSON.stringify({ text: "Hello from stream" }) }); }); expect(screen.getByText("Hello from stream")).toBeInTheDocument(); });

核心手法:从addEventListener.mock.calls中按事件名找回message监听器,然后在act中调用它模拟一条入站消息——act包裹是必要的,因为监听器内部会触发 React 状态更新。

测试 React Query Mutations

Langflow 大量使用@tanstack/react-query管理服务端状态,mutation 测试模式为:包上QueryClientProvider→ 用userEvent触发表单提交 →waitFor等待成功 UI → 断言 API 调用参数:

it("should save and show success", async () => { const user = userEvent.setup(); jest.mocked(api.post).mockResolvedValueOnce({ data: { id: "new-1" } }); render( <QueryClientProvider client={createTestQueryClient()}> <CreateForm /> </QueryClientProvider>, ); await user.type(screen.getByLabelText("Name"), "New Item"); await user.click(screen.getByRole("button", { name: /save/i })); await waitFor(() => { expect(screen.getByText(/saved successfully/i)).toBeInTheDocument(); }); expect(api.post).toHaveBeenCalledWith("/api/v1/items", { name: "New Item", }); });

其中createTestQueryClient()的推荐做法是构造一个关闭重试的客户端,避免失败用例触发多次请求干扰断言——仓库内的 useKnowledgeBasePolling.test.ts 就采用new QueryClient({ defaultOptions: { queries: { retry: false } } })这一标准配置。最后一步的toHaveBeenCalledWith断言把“UI 显示成功”与“发出了正确请求”绑定在一起,这正是黑盒测试原则(按用户可见行为断言,同时验证对外副作用)的体现。

测试加载态:手动控制的 Promise

要精确断言“加载中 → 加载完成”的过渡(例如确认 spinner 与内容不重叠),可以用一个手动 resolve 的 Promise 掌控异步边界:

it("should show loading then content", async () => { let resolvePromise: (value: any) => void; const promise = new Promise((resolve) => { resolvePromise = resolve; }); jest.mocked(api.get).mockReturnValueOnce(promise as any); render(<DataDisplay />); // Loading state expect(screen.getByTestId("loading-spinner")).toBeInTheDocument(); expect(screen.queryByText("Data content")).not.toBeInTheDocument(); // Resolve the promise await act(async () => { resolvePromise!({ data: { content: "Data content" } }); }); // Loaded state expect(screen.queryByTestId("loading-spinner")).not.toBeInTheDocument(); expect(screen.getByText("Data content")).toBeInTheDocument(); });

关键在于resolvePromise由测试握有控制权:先断言加载态(spinner 存在、内容不存在),再在await act(async () => {...})中 resolve,让 React 完整处理状态更新与重渲染,最后断言加载完成态。

常见陷阱(Common Pitfalls)

文档归纳的五类高频错误,逐条对应前面各节的要点:

  1. 忘记act()包裹定时器推进:当jest.advanceTimersByTime()会触发 React 状态更新时,务必包在act()中,否则会出现 “An update to X inside a test was not wrapped in act(...)” 警告,且断言可能读到过期 DOM。
  2. 不清理定时器afterEach中先jest.runOnlyPendingTimers()jest.useRealTimers(),防止定时器泄漏到下一个用例(use-debounce.test.ts 即此结构)。
  3. 对同步断言使用waitFor:元素在render后已存在于 DOM 时,直接用getBy*waitFor的轮询只会无谓拖慢测试。
  4. 漏掉await:忘记awaitwaitForfindBy*userEvent方法,测试会“空洞地通过”——断言实际从未执行。这是文档中所列“说谎测试(The Liar)”反模式的典型来源。
  5. Fake timers 卡住 Promise:若 fake timers 下 Promise 迟迟不 resolve,尝试在act(async () => { ... })内调用jest.advanceTimersByTime(),让宏/微任务队列有机会在受控时间内跑完。

小结:在 Langflow 前端落地这些模式

将本文模式放进 Langflow 的测试工程语境,完整工作流是:

  • 测试文件按src/**/__tests__/**/*.test.tsx约定放置(见 jest.config.js 的testMatch);
  • 编写前检查 jest.setup.js 已全局 mock 的模块(react-i18nextdarkStorereact-markdown@radix-ui/react-form、存储与crypto等),不要重复 mock;
  • API 层统一 mock@/controllers/API/apijest.mock模块工厂 +mockResolvedValueOnce/mockRejectedValueOnce控制单次行为);
  • 出现类异步用waitFor/findBy*,消失类异步用waitForElementToBeRemoved,时间类异步用 fake timers 并在act中推进;
  • 运行npm testnpm run test:coverage验证,CI 下 jest.config.js 会自动接入jest-junit报告。

所有模式均可在技能目录 .agents/skills/frontend-testing/ 中对照阅读:references/mocking.md覆盖 mock 策略,references/common-patterns.md覆盖查询优先级与表单/弹层模式,references/checklist.md提供提交前核对清单,与本文的异步测试篇互为补充。

【免费下载链接】langflowLangflow is a powerful tool for building and deploying AI-powered agents and workflows.项目地址: https://gitcode.com/GitHub_Trending/la/langflow

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询