mcpField
F
frontend-testing

frontend-testing

github.com/schalkneethling测试与质量745 次安装27 周前更新
代码仓库

关于 frontend-testing

这是一份前端测试技能指南,面向编写和维护 Web 应用测试的开发者。它以验收标准和用户可感知行为为起点,并指导选择单元、端到端、视觉及无障碍测试。

主要功能

  1. 01从验收标准规划测试
  2. 02区分验收测试与实现测试
  3. 03介绍 Vitest、Testing Library 和 Playwright
  4. 04优先使用语义化定位器
  5. 05涵盖 ARIA 快照与无障碍测试
  6. 06说明常见反模式及失败排查

适用场景

  1. 01按用户故事编写验收测试
  2. 02测试组件交互和纯逻辑
  3. 03验证浏览器中的用户流程
  4. 04检查界面结构与无障碍情况

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/schalkneethling/webdev-agent-skills --skill frontend-testing

安装后在 Claude Code 中输入/frontend-testing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

references/
accessibility-testing.md
aria-snapshots.md
locator-strategies.md
visual-regression.md

详细介绍

前端测试

首先编写验证验收标准的测试。然后在有帮助时添加实现测试。

核心原则

“测试越接近软件的实际使用方式,就越能让你对软件充满信心。”——Kent C. Dodds

这项原则可以指导测试决策,但并非全部:

  • 验收标准测试用于验证系统是否满足用户/利益相关者的需求。这类测试在重构后应保持稳定。
  • 实现测试用于验证各部分是否稳健,包括边界情况、错误处理和复杂逻辑。这类测试可能会随着重构而变化。

两类测试都很有价值。应避免的反模式是:测试只照搬实现,却没有验证有意义的行为。

何时加载参考资料

根据测试类型加载参考文件:

  • 包含 DOM 的单元测试:references/locator-strategies.md
  • E2E 测试:references/locator-strategies.md、references/aria-snapshots.md
  • 视觉回归测试:references/visual-regression.md
  • 无障碍审计:references/accessibility-testing.md
  • 结构验证:references/aria-snapshots.md——将多个断言合并为一个
  • 所有测试:从本文件开始,了解核心工作流

工作流

步骤 1:从验收标准开始

编写任何测试之前,先从用户的角度确定代码应该实现什么。

从以下来源获取或提取标准:

  • 工单描述或用户故事
  • Figma 注释
  • 功能需求
  • 产品负责人澄清

将标准记录为检查清单。这些将成为你的第一批测试。

**先编写验收测试,再阅读实现。**这样可以避免循环验证,即测试只是在确认“代码实现了代码所实现的功能”。

步骤 2:将标准映射到测试用例

针对每项标准,确定:

  • 正常路径:预期的常规行为
  • 边界情况:边界条件
  • 错误情况:无效输入、失败

映射示例:

Criterion: "User can filter products by category"
├─ Happy path: Select category, products filter correctly
├─ Edge case: No products match filter, show empty state
├─ Edge case: Clear filter, all products show again
├─ Error case: Filter API fails, show error message
└─ Accessibility: Filter controls are keyboard accessible
步骤 3:添加实现测试(单元测试)

验收测试通过后,添加单元测试以验证实现的稳健性:

  • 标准未涵盖的边界情况(null、undefined、空数组、边界值)
  • 复杂计算的算法正确性
  • 错误处理路径(异常、网络故障、解析错误)
  • 难以通过集成测试覆盖的复杂分支逻辑
  • 需要特定验证的性能敏感代码
Function: filterProducts(products, category)
├─ Acceptance: Returns matching products (from criteria)
├─ Implementation: Returns empty array when products is null
├─ Implementation: Returns all products when category is empty string
├─ Implementation: Handles case-insensitive category matching
└─ Implementation: Does not mutate original array

两者的区别:重构后,验收测试很少需要更改;内部实现变化时,实现测试可能需要更新。

步骤 4:选择测试类型
场景测试类型工具
纯逻辑(无 DOM)单元测试Vitest
组件行为包含 DOM 的单元测试Vitest + Testing Library
用户流程、真实浏览器E2E 测试Playwright
语义结构验证ARIA 快照Playwright toMatchAriaSnapshot
视觉外观VRTPlaywright 截图
无障碍合规性A11y 测试Playwright + axe-core

ARIA 快照对 E2E 测试尤其有价值。单个快照可以替代多个单独断言,同时验证无障碍树结构。

**单元测试的 DOM 环境:**优先使用 happy-dom,而非 jsdom。它速度更快,而且其 API 限制能提供有用的信号——如果 happy-dom 不支持你要测试的内容,请考虑这是否更适合放在 E2E 测试中。

步骤 5:在编写代码之前/同时编写测试
  • 理想做法:先编写测试,再实现功能(TDD)
  • 可接受做法:每实现一项标准,就立即编写相应测试
  • 避免:等实现“完成”后再编写所有测试

测试结构

单元测试(Vitest)
describe("calculateDiscount", () => {
  describe("when customer has premium membership", () => {
    it("applies 20% discount to order total", () => {
      // Arrange - Set up test data matching criterion
      const order = { total: 100, membership: "premium" };
      
      // Act - Call the function
      const result = calculateDiscount(order);
      
      // Assert - Verify expected outcome from requirements
      expect(result).toBe(80);
    });
  });
});
组件测试(Vitest + Testing Library)
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("LoginForm", () => {
  describe("when credentials are invalid", () => {
    it("displays error message to user", async () => {
      const user = userEvent.setup();
      render(<LoginForm />);
      
      // Interact using accessible queries
      await user.type(
        screen.getByLabelText(/email/i),
        "[email protected]"
      );
      await user.type(
        screen.getByLabelText(/password/i),
        "wrong"
      );
      await user.click(
        screen.getByRole("button", { name: /sign in/i })
      );
      
      // Assert on user-visible outcome
      expect(
        await screen.findByRole("alert")
      ).toHaveTextContent(/invalid credentials/i);
    });
  });
});
E2E 测试(Playwright)
import { test, expect } from "@playwright/test";

test.describe("Product Catalog", () => {
  test.describe("filtering by category", () => {
    test("shows only matching products", async ({ page }) => {
      await page.goto("/products");
      
      // Use semantic locators
      await page.getByRole("combobox", { name: /category/i }).selectOption("Electronics");
      
      // Assert count, then spot-check first/last
      const products = page.getByRole("article");
      await expect(products).toHaveCount(5);
      await expect(products.first()).toContainText(/electronics/i);
      await expect(products.last()).toContainText(/electronics/i);
    });
  });
});

需要验证所有项目时,使用 Promise.all 并行执行断言:

test("all products match filter", async ({ page }) => {
  await page.goto("/products");
  await page.getByRole("combobox", { name: /category/i }).selectOption("Electronics");
  
  const products = await page.getByRole("article").all();
  
  // Parallel assertions — faster than sequential await in a loop
  await Promise.all(
    products.map(product =>
      expect(product.getByText(/electronics/i)).toBeVisible()
    )
  );
});
使用 ARIA 快照进行 E2E 测试

ARIA 快照将多个断言合并为一个,用于验证语义结构:

test.describe("Login Page", () => {
  test("has correct form structure", async ({ page }) => {
    await page.goto("/login");
    
    // One snapshot replaces 5+ individual assertions
    await expect(page.getByRole("main")).toMatchAriaSnapshot(`
      - heading "Sign In" [level=1]
      - textbox "Email"
      - textbox "Password"
      - button "Sign In"
      - link "Forgot password?"
    `);
  });
  
  test("shows validation errors on empty submit", async ({ page }) => {
    await page.goto("/login");
    await page.getByRole("button", { name: /sign in/i }).click();
    
    await expect(page.getByRole("form")).toMatchAriaSnapshot(`
      - textbox "Email"
      - text "Email is required"
      - textbox "Password"
      - text "Password is required"
      - button "Sign In"
    `);
  });
});

定位器优先级

使用符合用户和辅助技术查找元素方式的定位器:

  1. getByRole — 首选。查询无障碍树。
  2. getByLabelText — 最适合表单字段。用户通过标签查找输入项。
  3. getByPlaceholderText — 不存在标签时使用(不理想)。
  4. getByText — 用于非交互元素。
  5. getByAltText — 用于图像。
  6. getByTestId — 最后的备用方案。

如果无法通过语义查询找到元素,UI 可能存在无障碍问题。

反模式

只测试实现细节

只验证内部细节而不验证有意义行为的测试:

// BAD: Tests internal method exists, provides no behavior guarantee
it("has a validateFields method", () => {
  expect(form.#validateFields).toBeDefined();
});

// BAD: Asserts implementation without verifying outcome
it("calls the validator", () => {
  expect(mockValidator).toHaveBeenCalledWith(data);
});

// GOOD: Tests observable behavior (acceptance)
it("prevents submission with invalid email", async () => {
  await user.type(emailInput, "not-an-email");
  await user.click(submitButton);
  expect(screen.getByRole("alert")).toHaveTextContent(/valid email/i);
});

// ALSO GOOD: Tests implementation robustness (unit)
it("returns validation errors for malformed email", () => {
  const result = validateEmail("not-an-email");
  expect(result.valid).toBe(false);
  expect(result.error).toBe("Invalid email format");
});

关键区别在于:实现测试应验证单元的有意义行为,而不只是确认代码路径已执行。

循环验证
// BAD: Test data derived from implementation
const expected = formatPrice(100); // Don't compute expected from code!
expect(formatPrice(100)).toBe(expected);

// GOOD: Expected value from requirements
expect(formatPrice(100)).toBe("$100.00");
过度模拟
// BAD: Mock everything, test nothing real
jest.mock("./api");
jest.mock("./utils");
jest.mock("./formatter");
// Now just testing mocks talk to each other

// GOOD: Mock only external boundaries
// Mock: APIs, databases, time, file system
// Real: Business logic, components, formatters
脆弱的选择器
// BAD: Implementation-dependent selectors
page.locator(".btn-primary.submit-form");
page.locator("#root > div > form > button:nth-child(3)");

// GOOD: Semantic locators
page.getByRole("button", { name: /submit order/i });

测试失败通常意味着存在错误

测试失败时,应根据测试类型进行调查:

验收测试失败:

  1. 先检查被测代码——很可能存在真实错误
  2. 确认测试仍符合当前需求——需求可能已变更
  3. 确认新行为正确后,再更新测试

实现测试失败:

  1. 如果正在重构,测试可能确实需要更新
  2. 如果没有重构,检查代码——很可能存在错误
  3. 考虑测试是否与实现耦合过紧

不要不假思索地更新测试来让它通过。应调查测试失败的原因。

提交测试前的检查清单

  • 每条验收标准至少有一个测试
  • 已覆盖标准中的边界情况
  • 已为复杂逻辑、错误处理和边界条件添加实现测试
  • 测试名称描述清晰(基于标准或行为)
  • 使用语义定位器(getByRole、getByLabelText)
  • 没有只验证“代码能运行”却不验证有意义行为的测试
  • 已根据测试类型(验收测试或实现测试)妥善调查失败的测试
  • 已为交互式组件加入无障碍检查
  • 考虑使用 ARIA 快照验证结构(可合并多个断言)

参考资料

加载以下文件以了解详细指南:

  • references/locator-strategies.md — 完整的定位器层级及示例
  • references/aria-snapshots.md — 结构验证与合并断言
  • references/accessibility-testing.md — axe-core 集成、WCAG 目标设定
  • references/visual-regression.md — 截图测试、基线管理

评论

支持 Markdown