专业编程教程与实战项目分享平台

网站首页 > 技术文章 正文

译文:SafeTest 介绍:一种新颖的前端测试方法

ins518 2024-09-22 11:38:38 技术文章 11 ℃ 0 评论

在这篇文章中,我们很高兴介绍 SafeTest,这是一个革命性的库,它为基于 Web 的用户界面 (UI) 应用程序的端到端 (E2E) 测试提供了全新的视角。

传统 UI 测试的挑战

传统上,UI 测试是通过单元测试或集成测试(也称为端到端 (E2E) 测试)进行的。但是,每种方法都有其独特的权衡:您必须在控制测试装置和设置与控制测试驱动程序之间做出选择。

例如,当使用单元测试解决方案react-testing-library时,你可以完全控制要渲染的内容以及底层服务和导入的行为方式。但是,你失去了与实际页面交互的能力,这可能导致无数痛点:

  • 难以与 <Dropdown /> 组件等复杂的 UI 元素交互。
  • 无法测试 CORS 设置或 GraphQL 调用。
  • 缺乏对影响按钮可点击性的 z-index 问题的可见性。
  • 测试的编写和调试复杂且不直观。

相反,使用 Cypress 或 Playwright 等集成测试工具可以控制页面,但会牺牲为应用检测引导代码的能力。这些工具通过远程控制浏览器来访问 URL 并与页面交互。这种方法有其自身的挑战:

  • 如果不实施自定义网络层 API 重写规则,则很难调用备用 API 端点。
  • 无法对间谍/模拟做出断言或在应用程序内执行代码。
  • 测试诸如暗模式之类的功能需要单击主题切换器或了解要覆盖的本地存储机制。
  • 无法测试应用程序的各个部分,例如,如果仅在单击按钮并等待 60 秒计时器倒计时后才可见组件,则测试将需要运行这些操作,并且至少需要一分钟。

认识到这些挑战后, Cypress和Playwright推出了诸如 E2E 组件测试之类的解决方案。虽然这些工具试图纠正传统集成测试方法的缺点,但由于其架构,它们还存在其他限制。它们使用引导代码启动开发服务器来加载所需的组件和/或设置代码,这限制了它们处理可能具有 OAuth 或复杂构建管道的复杂企业应用程序的能力。此外,更新 TypeScript 使用可能会破坏您的测试,直到 Cypress/Playwright 团队更新其运行器为止。

欢迎来到 SafeTest

SafeTest 旨在通过一种新颖的 UI 测试方法解决这些问题。主要思想是在我们的应用程序引导阶段插入一段代码,注入钩子来运行我们的测试(有关其工作原理的更多信息,请参阅Safetest 的工作原理部分)。请注意,这种工作原理对应用程序的常规使用没有可衡量的影响,因为 SafeTest 利用延迟加载仅在运行测试时动态加载测试(在 README 示例中,测试根本不在生产包中)。一旦完成,我们就可以使用 Playwright 运行常规测试,从而实现我们想要的测试的理想浏览器控制。

这种方法还解锁了一些令人兴奋的功能:

  • 深度链接到特定测试,无需运行节点测试服务器。
  • 浏览器和测试(节点)上下文之间的双向通信。
  • 访问 Playwright 附带的所有 DX 功能(@playwright/test 附带的功能除外)。
  • 测试的视频录制、跟踪查看和暂停页面功能,用于尝试不同的页面选择器/操作。
  • 能够对节点中浏览器中的间谍做出断言,匹配浏览器内的调用快照。

使用 SafeTest 测试示例

SafeTest 的设计旨在让任何进行过 UI 测试的人都感到熟悉,因为它利用了现有解决方案的最佳部分。以下是如何测试整个应用程序的示例:

import { describe, it, expect } from 'safetest/jest';
import { render } from 'safetest/react';

describe('my app', () => {
  it('loads the main page', async () => {
    const { page } = await render();

    await expect(page.getByText('Welcome to the app')).toBeVisible();
    expect(await page.screenshot()).toMatchImageSnapshot();
  });
});

我们可以轻松地测试特定组件

import { describe, it, expect, browserMock } from 'safetest/jest';
import { render } from 'safetest/react';

describe('Header component', () => {
  it('has a normal mode', async () => {
    const { page } = await render(<Header />);

    await expect(page.getByText('Admin')).not.toBeVisible();
   });

  it('has an admin mode', async () => {
    const { page } = await render(<Header admin={true} />);

    await expect(page.getByText('Admin')).toBeVisible();
  });

  it('calls the logout handler when signing out', async () => {
    const spy = browserMock.fn();
    const { page } = await render(<Header handleLogout={spy} />);

    await page.getByText('logout').click();
    expect(await spy).toHaveBeenCalledWith();
  });
});

利用覆盖

SafeTest 利用 React Context 允许在测试期间覆盖值。为了举例说明其工作原理,我们假设我们在组件中使用了一个 fetchPeople 函数:

import { useAsync } from 'react-use';
import { fetchPerson } from './api/person';

export const People: React.FC = () => {
  const { data: people, loading, error } = useAsync(fetchPeople);
  
  if (loading) return <Loader />;
  if (error) return <ErrorPage error={error} />;
  return <Table data={data} rows=[...] />;
}

我们可以修改 People 组件以使用 Override:

 import { fetchPerson } from './api/person';
+import { createOverride } from 'safetest/react';

+const FetchPerson = createOverride(fetchPerson);

 export const People: React.FC = () => {
+  const fetchPeople = FetchPerson.useValue();
   const { data: people, loading, error } = useAsync(fetchPeople);
  
   if (loading) return <Loader />;
   if (error) return <ErrorPage error={error} />;
   return <Table data={data} rows=[...] />;
 }

现在,在我们的测试中,我们可以覆盖此调用的响应:

const pending = new Promise(r => { /* Do nothing */ });
const resolved = [{name: 'Foo', age: 23], {name: 'Bar', age: 32]}];
const error = new Error('Whoops');

describe('People', () => {
  it('has a loading state', async () => {
    const { page } = await render(
      <FetchPerson.Override with={() => () => pending}>
        <People />
      </FetchPerson.Override>
    );

    await expect(page.getByText('Loading')).toBeVisible();
  });

  it('has a loaded state', async () => {
    const { page } = await render(
      <FetchPerson.Override with={() => async () => resolved}>
        <People />
      </FetchPerson.Override>
    );

    await expect(page.getByText('User: Foo, name: 23')).toBeVisible();
  });

  it('has an error state', async () => {
    const { page } = await render(
      <FetchPerson.Override with={() => async () => { throw error }}>
        <People />
      </FetchPerson.Override>
    );

    await expect(page.getByText('Error getting users: "Whoops"')).toBeVisible();
  });
});

渲染函数还接受将传递初始应用程序组件的函数,允许在应用程序的任何位置注入任何所需的元素:

it('has a people loaded state', async () => {
  const { page } = await render(app =>
    <FetchPerson.Override with={() => async () => resolved}>
      {app}
    </FetchPerson.Override>
  );
   await expect(page.getByText('User: Foo, name: 23')).toBeVisible();
});

通过覆盖,我们可以编写复杂的测试用例,例如确保结合了来自、和的 API 请求的服务方法/foo具有/bar/baz针对失败的 API 请求的正确重试机制,并且仍然正确映射返回值。因此,如果/bar需要 3 次尝试才能解决该方法,则总共将进行 5 次 API 调用。

覆盖不仅限于 API 调用(因为我们也可以使用page.route),我们还可以覆盖特定的应用程序级别值,如功能标志或更改某些静态值:

+const UseFlags = createOverride(useFlags);
 export const Admin = () => {
+  const useFlags = UseFlags.useValue();
   const { isAdmin } = useFlags();
   if (!isAdmin) return <div>Permission error</div>;
   // ...
 }

+const Language = createOverride(navigator.language);
 export const LanguageChanger = () => {
-  const language = navigator.language;
+  const language = Language.useValue();
   return <div>Current language is { language } </div>;
 }

 describe('Admin', () => {
   it('works with admin flag', async () => {
     const { page } = await render(
       <UseIsAdmin.Override with={oldHook => {
         const oldFlags = oldHook();
         return { ...oldFlags, isAdmin: true };
       }}>
         <MyComponent />
       </UseIsAdmin.Override>
     );

     await expect(page.getByText('Permission error')).not.toBeVisible();
   });
 });

 describe('Language', () => {
   it('displays', async () => {
     const { page } = await render(
       <Language.Override with={old => 'abc'}>
         <MyComponent />
       </Language.Override>
     );

     await expect(page.getByText('Current language is abc')).toBeVisible();
   });
 });

覆盖是 SafeTest 的一项强大功能,此处的示例只是冰山一角。有关更多信息和示例,请参阅README中的覆盖部分。

报告

SafeTest 开箱即用,具有强大的报告功能,例如自动链接视频重播、Playwright 跟踪查看器,甚至直接深度链接到已安装的测试组件。SafeTest repo README链接到所有示例应用程序以及报告

企业环境中的 SafeTest

许多大型公司需要某种形式的身份验证才能使用应用程序。通常,导航到 localhost:3000 只会导致页面不断加载。您需要转到其他端口,例如 localhost:8000,该端口具有代理服务器来检查和/或将身份验证凭据注入底层服务调用。此限制是 Cypress/Playwright 组件测试不适合在 Netflix 使用的主要原因之一。

但是,通常有一种服务可以生成测试用户,我们可以使用其凭据登录并与应用程序交互。这有助于在 SafeTest 周围创建一个轻量级包装器,以自动生成并假设该测试用户。例如,以下是我们在 Netflix 上的基本做法:

import { setup } from 'safetest/setup';
import { createTestUser, addCookies } from 'netflix-test-helper';

type Setup = Parameters<typeof setup>[0] & {
  extraUserOptions?: UserOptions;
};


export const setupNetflix = (options: Setup) => {
  setup({
    ...options,
    hooks: { beforeNavigate: [async page => addCookies(page)] },
  });

  beforeAll(async () => {
    createTestUser(options.extraUserOptions)
  });
};

设置完成后,我们只需在使用 safetest/setup 的位置导入上述包即可。

超越 React

虽然这篇文章重点介绍了 SafeTest 如何与 React 配合使用,但它并不仅限于 React。SafeTest 还可以与 Vue、Svelte、Angular 配合使用,甚至可以在 NextJS 或 Gatsby 上运行。它还可以使用 Jest 或 Vitest 运行,具体取决于您的脚手架开始时使用的测试运行器。示例文件夹演示了如何使用 SafeTest 与不同的工具组合,我们鼓励您贡献更多案例。

从本质上讲,SafeTest 是测试运行器、UI 库和浏览器运行器的智能粘合剂。虽然 Netflix 最常用的是 Jest/React/Playwright,但很容易为其他选项添加更多适配器。

结论

SafeTest 是一个功能强大的测试框架,Netflix 正在采用该框架。它允许轻松编写测试,并提供全面的报告,说明何时以及如何发生故障,并附有查看播放视频或手动运行测试步骤以查看故障原因的链接。我们很高兴看到它将如何彻底改变 UI 测试,并期待您的反馈和贡献。

作者:Moshe Kolodny

出处:https://netflixtechblog.com/introducing-safetest-a-novel-approach-to-front-end-testing-37f9f88c152d

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表