ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Sentry 前端 React 测试指南:Jest + React Testing Library 的用户中心测试实践

Sentry 前端 React 测试指南:Jest + React Testing Library 的用户中心测试实践 Sentry 前端 React 测试指南Jest React Testing Library 的用户中心测试实践【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry导读本文基于 Sentry 开源仓库中的.agents/skills/react-testing/SKILL.md测试规范系统讲解在 Sentry 前端static/下的*.spec.tsx组件测试中应遵循的 React/TypeScript 测试方法论从统一测试入口sentry-test/reactTestingLibrary的使用、查询 API 的选择优先级到 MockApiClient 网络请求模拟、路由与异步场景的测试技巧。读完本文你将掌握一套与 Sentry 实际测试基础设施完全对齐的 RTL 测试写法能写出接近用户真实行为、稳定不 flaky、可读性高的前端测试。一、Sentry 的测试哲学以用户为中心Sentry 前端测试遵循三条核心原则源出 SKILL.md用户中心User-centric testing测试应当还原用户与应用的真实交互方式而不是组件内部结构避免实现细节Avoid implementation details断言关注行为与结果不关注组件怎么实现测试间不共享状态Do not share state between tests单个测试的渲染结果、store 状态、mock 响应都不应影响同套件中的其他测试。这三条原则决定了后面所有具体规则为什么用getByRole优先于getByTestId、为什么用userEvent替代fireEvent、为什么禁止jest.mocked()去 mock hook、为什么查询要区分getBy/queryBy/findBy。二、统一测试入口从sentry-test/reactTestingLibrary导入永远不要直接from testing-library/react导入而应从统一封装模块导入import { render, screen, userEvent, waitFor, within, } from sentry-test/reactTestingLibrary;这个封装模块不是空壳而是 Sentry 前端测试基础设施的核心。在仓库中它的实现位于 tests/js/sentry-test/reactTestingLibrary.tsx重新导出了testing-library/react的全部能力见文件末尾export * from testing-library/react并额外导出了render、renderHookWithProviders、renderGlobalModal、waitForDrawerToHide、userEvent自定义的render定义见 reactTestingLibrary.tsx#L342-L388会把你渲染的组件包进一套完整的 Provider 栈由makeAllTheProvidersreactTestingLibrary.tsx#L136-L178构建包括EmotionCacheProvider、TanStack Query 的QueryClientProvider、OrganizationContext、GlobalAlertProvider、GlobalDrawer、CommandPaletteProvider、ThemeProvider以及基于createMemoryHistory的内存路由返回对象里多带一个router句柄TestRouterreactTestingLibrary.tsx#L257-L283用于读取当前 location 或编程式导航。因此你不需要、也不应该在自己的测试里手搓 Provider 包装——直接使用封装后的render/renderHookWithProviders上下文就已经齐备。该模块自身的行为还有对应测试守护见 tests/js/sentry-test/reactTestingLibrary.spec.tsx。三、查询优先级从最贴近用户的查询开始Sentry 的查询选用优先级从高到低如下按文档要求依次递减优先级查询适用场景1getByRole大多数元素的首选选择器2getByLabelText/getByPlaceholderText表单元素3getByText非交互元素如错误提示文本4getByTestId最后手段才使用screen.getByRole(button, {name: Save}); screen.getByRole(textbox, {name: Search}); screen.getByLabelText(Email Address); screen.getByPlaceholderText(Enter Search Term); screen.getByText(Error Message); screen.getByTestId(custom-component); // 万不得已getByRole之所以排在首位是因为它从无障碍语义层ARIA role accessible name定位元素与用户和辅助技术感知内容的方式一致同时也反向推动产品代码写出语义化的可访问结构。getByTestId只应在无法通过角色、文本、标签定位时使用——注意 Sentry 的 jest 配置把 test id 属性设置为data-test-id见下文测试环境配置与默认的data-testid不同。另外还有一条重要约定优先使用screen全局查询而不是从render返回值里解构查询函数// ❌ 不要这样 const {getByRole} render(Component /); // ✅ 应该这样 render(Component /); const button screen.getByRole(button);使用screen意味着查询与渲染位置解耦即使组件树变深、或需要多次rerender测试主体也不受影响。四、getBy / queryBy / findBy 的正确语义与异步断言三种查询变体的选用规则非常明确这也是大量 flaky 测试的根源所在getBy...断言元素应该存在时使用找不到会直接抛错、测试快速失败queryBy...仅当检查元素不存在时使用返回null配合not.toBeInTheDocument()await findBy...等待元素出现时使用内部封装了轮询等待。// ❌ 错误queryBy 用于应该存在的断言 expect(screen.queryByRole(alert)).toBeInTheDocument(); // ✅ 正确存在用 getBy不存在用 queryBy expect(screen.getByRole(alert)).toBeInTheDocument(); expect(screen.queryByRole(button)).not.toBeInTheDocument();4.1 异步出现的元素findBy 而非 waitFor等待元素出现应直接使用findBy只有需要同时断言多个条件/复合状态时才用waitFor// ❌ 不要用 waitFor 来等待出现 await waitFor(() { expect(screen.getByRole(alert)).toBeInTheDocument(); }); // ✅ 出现用 findBy expect(await screen.findByRole(alert)).toBeInTheDocument(); // ✅ 消失用 waitForElementToBeRemoved await waitForElementToBeRemoved(() screen.getByRole(alert));4.2 不要等待 loading 指示器这是一个很容易踩坑的 flaky 源不要用findBy.not.toBeInTheDocument()去等 loading 指示器消失。原因有二findBy在找不到元素时会抛错与断言其不存在的意图自相矛盾而且 loading 指示器只在屏幕上闪现几个 tick时机极不稳定。// ❌ 错误loading 指示器闪现即逝findBy 语义也不匹配 expect(await screen.findByTestId(loading-indicator)).not.toBeInTheDocument(); // ✅ 正确等待真正关心的内容出现 await waitFor(() { expect(screen.getByRole(button, {name: Submit})).toBeInTheDocument(); }); // ✅ 同样正确对加载完成后的内容用 findBy expect(await screen.findByRole(button, {name: Submit})).toBeInTheDocument();原则是等待加载完成后的真实内容而不是等待加载中的 UI 消失。五、模拟真实交互使用 userEvent 而不是 fireEvent与真实用户交互最接近的是userEvent它逐键派发事件、考虑焦点与键盘语义因此// ❌ 不要用 fireEvent fireEvent.change(input, {target: {value: text}}); // ✅ 使用 userEvent await userEvent.click(input); await userEvent.keyboard(text);由于userEvent的 API 是异步的调用处需要await。fireEvent在仓库的封装模块里仍然被导出但已被标记为deprecatedreactTestingLibrary.tsx#L437-L442注释明确建议尽量使用 userEvent。六、测试路由行为initialRouterConfig 与 router 句柄Sentry 的封装render允许通过initialRouterConfig指定初始路由并通过返回的router检查与驱动导航const {router} render(TestComponent /, { initialRouterConfig: { location: { pathname: /foo/, query: {page: 1}, }, }, }); // 传入的配置用于设置初始 location expect(router.location.pathname).toBe(/foo); expect(router.location.query.page).toBe(1); // 点击链接会跳转到正确位置 await userEvent.click(screen.getByRole(link, {name: Go to /bar/})); expect(router.location.pathname).toBe(/bar/); // 也可以手动路由跳转 router.navigate(/new/path/); router.navigate(-1); // 模拟点击浏览器返回按钮注意router.location.query已被封装模块自动解析为对象TestRouter的locationgetter 用query-string解析 search 参数见 reactTestingLibrary.tsx#L264-L272所以可以直接断言query.page不必手工解析 URL。6.1 组件使用 useParams() 时的 route 配置如果被测组件依赖useParams()读取路径参数仅设置location是不够的——还必须在initialRouterConfig中通过route声明带参数的路径模板让路由真正匹配并填充参数function TestComponent() { const {id} useParams(); return div{id}/div; } const {router} render(TestComponent /, { initialRouterConfig: { location: { pathname: /foo/123/, }, route: /foo/:id/, }, }); expect(screen.getByText(123)).toBeInTheDocument();这条规则背后的实现逻辑是封装模块的createRoutesFromConfigreactTestingLibrary.tsx#L180-L221会读取config.route单条路由模板或config.routes多条路由模板数组适用于同一组件被多个路由渲染的场景来构造匹配路由。它还会注入一个 catch-all 空路由提示检查 location 与 route 是否匹配以及一个会把路由异常重新抛出的ErrorBoundary——让测试中的渲染错误直接冒泡暴露而不是被 React Router 吞掉。七、测试网络请求MockApiClient组件发起的网络请求应通过MockApiClient.addMockResponse进行打桩而不是 mock hook、mock context 或使用真实网络。7.1 基础用法// 简单 GET 请求 MockApiClient.addMockResponse({ url: /projects/, body: [{id: 1, name: my project}], }); // POST 请求 MockApiClient.addMockResponse({ url: /projects/, method: POST, body: {id: 1, name: my project}, }); // 带 query 参数与请求体的复杂匹配 MockApiClient.addMockResponse({ url: /projects/, method: POST, body: {id: 2, name: other}, match: [ MockApiClient.matchQuery({param: 1}), MockApiClient.matchData({name: other}), ], }); // 错误响应 MockApiClient.addMockResponse({ url: /projects/, body: { detail: Internal Error, }, statusCode: 500, });从实现上看MockApiClient是sentry/api模块在 Jest 下的手动 mock位于 static/app/mocks/api.tsx。它维护一个静态的mockResponses列表api.tsx#L101addMockResponse负责向该列表注册响应api.tsx#L142matchQuery/matchData分别生成针对 query 参数与请求体数据的匹配器api.tsx#L120-L133。这个 mock 在测试 setup 中通过jest.mock(sentry/api)全局生效见 tests/js/setup.ts#L81因此各测试文件中无需重复声明 mock。7.2 异步断言必须 await网络请求天然是异步的凡依赖网络响应结果的断言都要用findBy或正确的异步等待否则会出现间歇性失败// ❌ 错误会在数据加载完成前执行间歇性失败 expect(screen.getByText(Loaded Data)).toBeInTheDocument(); // ✅ 正确等待元素出现 expect(await screen.findByText(Loaded Data)).toBeInTheDocument();7.3 mutation 触发的 refetch 要在 refetch 前更新 mock当测试提交 mutation 后列表自动刷新的场景时有一个关键细节必须先注册好 refetch 会用到的响应再触发 mutation否则刷新请求会命中旧的空的mockit(adds item and updates list, async () { // 初始空列表 MockApiClient.addMockResponse({ url: /items/, body: [], }); const createRequest MockApiClient.addMockResponse({ url: /items/, method: POST, body: {id: 1, name: New Item}, }); render(ItemList /); await userEvent.click(screen.getByRole(button, {name: Add Item})); // 关键在 refetch 发生之前覆盖 mock MockApiClient.addMockResponse({ url: /items/, body: [{id: 1, name: New Item}], }); await waitFor(() expect(createRequest).toHaveBeenCalled()); expect(await screen.findByText(New Item)).toBeInTheDocument(); });通过把createRequestaddMockResponse的返回值与waitFor结合还能断言POST 确实被调用了。八、不要 mock hook/函数/组件用数据与配置驱动真实行为Sentry 测试的核心要求是保留真实实现、用数据/配置/状态去驱动它。SKILL 文档给出了四类最常见的反例与正解反例禁止jest.mocked()正确做法mock 数据请求 hookuseDataFetchingHook用MockApiClient.addMockResponse设置响应数据mock 组织上下文useOrganizationrender(Component /, {organization: OrganizationFixture({...})})mock 路由 hookuseLocation通过render的initialRouterConfig提供路由配置mock 页面过滤器 hookusePageFilters直接往对应数据 store 写入数据如PageFiltersStore.onInitializeUrlState(PageFiltersFixture({projects: [1]}))手搓全套 context Provider 包装renderHook使用封装好的renderHookWithProviders(useNavigate)// ❌ Dont mock hooks jest.mocked(useDataFetchingHook) // ✅ 设置响应数据 MockApiClient.addMockResponse({ url: /data/, body: DataFixture(), }) // ❌ Dont mock router hooks jest.mocked(useLocation) // ✅ 使用提供的 router 配置 render(TestComponent /, { initialRouterConfig: { location: {pathname: /foo/}, }, }) // ❌ 不要手搓基础 context 的 wrapper renderHook(useNavigate, { wrapper: children AllTheProviders{children}/AllTheProviders, }) // ✅ 使用封装好一切的 helper renderHookWithProviders(useNavigate)这背后的设计意图是组件在真实 context、真实 store、mock 网络三层配合下被完整渲染测试覆盖的是组件的真实逻辑路径而非一层被 mock 掏空的空壳。Sentry 的封装render也确实接受organization、additionalWrapper等选项来注入上下文ProviderOptions 定义见 reactTestingLibrary.tsx#L45-L54。九、用 Fixture 构造测试数据构造领域数据时优先使用官方 Fixture不要手写类型对象// ❌ 不要导入类型再手工初始化 import type {Project} from sentry/types/project; const project: Project {...} // ✅ 导入 Fixture import {ProjectFixture} from sentry-fixture/project; const project ProjectFixture(partialProject);Fixture 位于以下两处Sentry 仓库自身的前端 fixture 位于tests/js/fixtures/通过sentry-fixture/*模块别名导入对应目录下的*.ts文件GetSentry 相关的 fixture 位于tests/js/getsentry-test/fixtures/。你可以按需传入 partial 覆盖默认值如ProjectFixture({slug: my-project})Fixture 会用合理默认值补齐其余字段省去大量样板代码。这一约定同样贯穿测试基础设施内部——例如 setup 里用ConfigFixture构造配置tests/js/setup.ts#L12封装渲染模块里用LocationFixture与ThemeFixture提供默认 location 和主题reactTestingLibrary.tsx#L24-L25。十、测试环境配置这些默认值如何支撑上述规则Sentry 前端测试的运行环境由 jest.config.ts 与 tests/js/setup.ts 共同定义理解它们能帮你解释上面许多规则为什么成立testing-library/jest-dom被全局引入setup.ts#L3所以toBeInTheDocument()、toHaveBeenCalled()等匹配器随处可用RTL 的 test id 属性被覆盖为data-test-idsetup.ts#L51configureRtl({testIdAttribute: data-test-id})如果你的组件使用自定义 test id 属性查询与断言需与之对齐enableFetchMocks()开启 fetch 层 mock 并补齐 jsdom 缺失的 fetch 原语setup.ts#L33时间被固定为 2017-10-17T02:41:20.000Z动画被全局跳过MotionGlobalConfig.skipAnimationslodash/debounce等被替换为同步版本setup.ts#L44-L79这些约定让测试结果确定、不依赖真实时钟与动画帧sentry/api被jest.mock替换为上述的MockApiClient手动 mocksetup.ts#L81。十一、快速自查清单写一个 Sentry 前端测试时可以用下面的清单做最终检查是否从sentry-test/reactTestingLibrary导入而非testing-library/react查询是否按getByRole → getByLabelText/getByPlaceholderText → getByText → getByTestId的优先级选择存在断言是否用getBy、不存在断言是否用queryBynot.toBeInTheDocument()异步出现的元素是否用了await findBy而非把getBy包进waitFor或对 loading 指示器做等待消失断言交互是否全部走await userEvent.*且没有使用fireEvent是否用MockApiClient.addMockResponse处理网络且所有依赖网络结果的断言都经过findBy/waitFor等待mutation 触发 refetch 的用例是否在 refetch 前更新了对应 mock是否没有出现jest.mocked()去 mock hook/context/router而是通过render选项、数据 store 或 Fixture 驱动路由相关用例是否设置了正确的initialRouterConfig含useParams所需的route数据对象是否来自sentry-fixture/*或 GetSentry 对应 fixture而非手写类型字面量把这 10 条落实到位你的测试就与 Sentry 主仓库数千个*.spec.tsx分布于static/app/各模块目录遵循同一套规范既稳定可维护也贴近真实用户体验。【免费下载链接】sentryDeveloper-first error tracking and performance monitoring项目地址: https://gitcode.com/GitHub_Trending/sen/sentry创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表