ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Epic Stack 测试指南:用 Vitest 与 Playwright 构建贴近真实用户的测试体系

Epic Stack 测试指南:用 Vitest 与 Playwright 构建贴近真实用户的测试体系 Epic Stack 测试指南用 Vitest 与 Playwright 构建贴近真实用户的测试体系【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stackEpic Stack 是一套预配置好认证、权限、数据库、表单等基础能力的全栈应用脚手架而测试是它开箱即用的核心能力之一。本指南基于仓库内 docs/skills/epic-testing/SKILL.md 展开结合 tests/playwright-utils.ts、tests/db-utils.ts、playwright.config.ts 等源码与真实测试用例系统讲解如何用 Vitest 编写单元测试、用 Playwright 编写端到端E2E测试以及如何通过内置 fixture、测试数据库和 MSW 模拟外部服务。读完你将掌握一套可直接复用的测试方法论与工程实践能够为工具函数、组件、表单、路由 loader/action、权限控制乃至 GitHub OAuth 编写高质量测试。何时使用本指南当你在 Epic Stack 项目中遇到以下需求时本指南即是直接的操作手册为工具函数和组件编写单元测试使用 Playwright 编写覆盖完整流程的 E2E 测试测试表单与校验逻辑测试路由的 loader 与 action使用 MSW 模拟 GitHub、Resend、Tigris、pwned-passwords 等外部服务测试认证与权限登录、管理员权限、资源所有权配置独立的测试数据库。测试哲学让测试贴近真实用户Epic Stack 遵循 Epic Web 的测试原则核心有两条测试应当像用户一样Tests should resemble users。测试要镜像真实用户与应用的交互方式——测试用户工作流而不是实现细节。如果用户会点击按钮测试就应该点击那个按钮如果用户会看到错误信息测试就应该断言那条具体信息。// ✅ 好 - 测试用户工作流 test(User can sign up and create their first note, async ({ page, navigate }) { // 用户访问注册页 await navigate(/signup) // 用户像真人一样填写表单 await page.getByRole(textbox, { name: /email/i }).fill(newuserexample.com) await page.getByRole(textbox, { name: /username/i }).fill(newuser) await page.getByRole(textbox, { name: /^password$/i }).fill(securepassword123) await page.getByRole(textbox, { name: /confirm/i }).fill(securepassword123) // 用户提交表单 await page.getByRole(button, { name: /sign up/i }).click() // 用户被重定向到 onboarding await expect(page).toHaveURL(/\/onboarding/) // 用户创建第一条笔记 await navigate(/notes/new) await page.getByRole(textbox, { name: /title/i }).fill(My First Note) await page.getByRole(textbox, { name: /content/i }).fill(This is my first note!) await page.getByRole(button, { name: /create/i }).click() // 用户看到自己的笔记 await expect(page.getByRole(heading, { name: My First Note })).toBeVisible() await expect(page.getByText(This is my first note!)).toBeVisible() }) // ❌ 避免 - 测试实现细节 test(Signup form calls API endpoint, async ({ page }) { // 这测试的是实现而不是用户体验 const response await page.request.post(/signup, { data: {...} }) expect(response.status()).toBe(200) })让断言具体明确Make assertions specific。不要用含糊的断言要写能清晰传达预期行为、失败时易于定位问题的断言。// ✅ 好 - 具体的断言 test(Form shows specific validation errors, async ({ page, navigate }) { await navigate(/signup) await page.getByRole(button, { name: /sign up/i }).click() // 用户实际能看到的具体错误消息 await expect(page.getByText(/email is required/i)).toBeVisible() await expect( page.getByText(/username must be at least 3 characters/i), ).toBeVisible() await expect( page.getByText(/password must be at least 6 characters/i), ).toBeVisible() }) // ❌ 避免 - 模糊的断言 test(Form shows errors, async ({ page, navigate }) { await navigate(/signup) await page.getByRole(button, { name: /sign up/i }).click() // 太模糊了 - 什么错误在哪里 expect(page.locator(.error)).toBeVisible() })两种测试类型Epic Stack 将测试明确分为两类各司其职Vitest 单元测试—— 针对独立的组件与工具函数配置见 vite.config.ts 的test段Playwright E2E 测试—— 端到端覆盖完整用户流程测试文件统一放在tests/e2e/目录。两者通过统一的 npm 脚本管理见 package.jsonnpm run test运行 Vitestnpm run test:e2e启动 Playwright 的 UI 模式npm run test:e2e:run在 CI 环境下运行npm run validate则串行执行单元测试、lint、类型检查与 E2E 全套校验。Vitest 单元测试最基本的工具函数测试// app/utils/my-util.test.ts import { describe, expect, it } from vitest import { myUtil } from ./my-util.ts describe(myUtil, () { it(should do something, () { expect(myUtil(input)).toBe(expected) }) })带 DOM 的组件测试使用 Testing Libraryimport { describe, expect, it } from vitest import { render, screen } from testing-library/react import { MyComponent } from ./my-component.tsx describe(MyComponent, () { it(should render correctly, () { render(MyComponent /) expect(screen.getByText(Hello)).toBeInTheDocument() }) })从仓库的 vite.config.ts 可以看到单元测试的工程化配置细节测试文件匹配./app/**/*.test.{ts,tsx}通过setupFiles: [./tests/setup/setup-test-env.ts]注入测试环境通过globalSetup: [./tests/setup/global-setup.ts]准备基础数据库并启用restoreMocks: true与 v8 覆盖率统计。仓库中可参考的真实单元测试包括 app/utils/auth.server.test.ts、app/utils/misc.error-message.test.ts 等。E2E 测试Playwright所有 E2E 测试都从 tests/playwright-utils.ts 导入测试基座以获得内置 fixture// tests/e2e/my-feature.test.ts import { expect, test } from #tests/playwright-utils.ts test(Users can do something, async ({ page, navigate, login }) { const user await login() await navigate(/my-page) // 与页面交互 await page.getByRole(button, { name: /Submit/i }).click() // 验证结果 await expect(page).toHaveURL(/success) })playwright.config.ts 中可以看到完整的 E2E 基础设施测试目录为./tests/e2e默认 15 秒超时、断言 5 秒超时、CI 下开启重试2 次且串行执行报告器为 HTML。关键的是webServer配置——CI 下使用npm run start:mocks即带 MSW mock 的生产构建启动被测服务本地则使用npm run dev并注入NODE_ENVtest环境变量。内置 Fixtures 与 HelpersEpic Stack 在 tests/playwright-utils.ts 中通过test.extend扩展了 Playwright 的测试对象提供了大量开箱即用的能力。这些 fixture 的源码即是最好的使用文档login登录 fixturelogin自动创建用户并注入登录态会话用于需要认证的测试。从 tests/playwright-utils.ts 的实现可见它内部会调用getOrInsertUser创建用户、通过prisma.session.create创建会话再用authSessionStorage生成 cookie 并通过page.context().addCookies注入浏览器。test(Protected route, async ({ page, navigate, login }) { const user await login() // 自动创建用户和会话 await navigate(/protected) // 用户已认证 await expect(page.getByText(Welcome ${user.username})).toBeVisible() })支持自定义用户参数const user await login({ username: testuser, email: testexample.com, password: password123, })注意测试结束后该用户会被自动删除实现中通过afterEach式的 teardown 执行prisma.user.deleteMany。insertNewUser只插入用户不登录当测试只需要一个已存在用户、而不需要会话时使用test(Public content, async ({ page, navigate, insertNewUser }) { const user await insertNewUser({ username: publicuser, email: publicexample.com, }) await navigate(/users/${user.username}) await expect(page.getByText(user.username)).toBeVisible() })从源码看tests/playwright-utils.tsinsertNewUser同样在 teardown 时通过prisma.user.delete清理数据。navigate类型安全的导航navigate是对page.goto的类型安全封装直接使用 React Router 的路由类型AppPages路由参数通过路径模板自动推断。源码实现为page.goto(href(...args))见 tests/playwright-utils.ts。// 类型安全导航自动补全路由参数 await navigate(/users/:username/notes, { username: user.username }) await navigate(/users/:username/notes/:noteId, { username: user.username, noteId: note.id, }) // 无参数路由同样适用 await navigate(/login)这比手写page.goto()更可靠路由路径一旦调整测试会立即在编译期报错而不是运行时 404。waitFor等待异步条件用于轮询等待异步条件满足比如等待邮件到达。其实现tests/playwright-utils.ts每 100ms 调用一次回调直到回调返回非空值或不抛错超时后抛出最后一次错误或兜底错误信息默认超时 5000ms。import { waitFor } from #tests/playwright-utils.ts await waitFor( async () { const element await page.getByText(Content loaded).first() expect(element).toBeVisible() return element }, { timeout: 5000, errorMessage: Content never loaded }, )测试数据库Epic Stack 使用独立的测试数据库且全自动配置、测试之间自动清理、测试产生的数据自动删除。其实现位于 tests/setup/db-setup.ts它根据 Vitest 的VITEST_POOL_ID为每个测试池生成独立的 SQLite 文件./tests/prisma/data.poolId.db并在beforeEach中从 tests/setup/global-setup.ts 生成的base.db复制出干净的数据库快照从而保证每个测试从同一基线开始。全局 setup 会在 Prisma schema 变更后自动执行prisma migrate reset重建基线库。因此在测试中可以直接用 Prisma 创建数据import { prisma } from #app/utils/db.server.ts test(User can see notes, async ({ page, navigate, login }) { const user await login() // 在数据库中创建笔记 const note await prisma.note.create({ data: { title: Test Note, content: Test Content, ownerId: user.id, }, }) await navigate(/users/:username/notes/:noteId, { username: user.username, noteId: note.id, }) await expect(page.getByText(Test Note)).toBeVisible() })DB Helpers生成唯一测试数据tests/db-utils.ts 提供两个数据工厂用 faker 生成随机、唯一的数据避免测试之间互相污染import { createUser, createPassword } from #tests/db-utils.ts const userData createUser() // 生成唯一随机数据username/name/email const password createPassword(mypassword) // { hash: ... }bcrypt 哈希值得注意的细节createUser使用UniqueEnforcerenforce-unique强制生成唯一用户名并对用户名做长度裁剪20 字符内与非法字符清洗createPassword默认使用bcrypt.hashSync(password, 10)生成密码哈希配合getPasswordHash可构造带密码的用户。该模块还导出getNoteImages、getUserImages等图片数据工厂用于笔记图片相关测试。用 MSW 模拟外部服务Epic Stack 使用 MSWMock Service Worker在 Node 层拦截并模拟所有外部服务请求让测试完全不依赖真实网络。所有 handlers 聚合在 tests/mocks/index.ts通过setupServer一次性注册 Resend邮件、GitHubOAuth、Tigris对象存储、pwned-passwords密码泄露检测四组 mock并智能忽略 Sentry 上报与 react-router-devtools 的内部请求。一个典型的 mock 示例GitHub API// tests/mocks/github.ts import { http, HttpResponse } from msw export const handlers [ http.get(https://api.github.com/user, () { return HttpResponse.json({ id: 123, login: testuser, email: testexample.com, }) }), ]启用方式当MOCKStrue时 mock 自动生效。这也解释了 package.json 中devMOCKStrue与dev:no-mocks两个开发脚本的差异——本地开发默认开启 mockE2E 的 CI 模式使用start:mocks。参考 tests/mocks/github.ts 可见其实战复杂度它用 JSON 文件tests/fixtures/github/users.poolId.local.json维护模拟用户池mock 了 token 交换、用户信息、邮箱列表与头像等端点tests/mocks/resend.ts 则把发出的邮件写入本地文件并打印内容便于断言。另外 tests/setup/setup-test-env.ts 会在每个测试后调用server.resetHandlers()重置 handler避免用例间相互影响。测试表单与校验测试表单的核心是像用户一样操作——用getByRole定位控件、fill填值、click提交然后断言跳转或错误消息test(User can submit form, async ({ page, navigate, login }) { const user await login() await navigate(/notes/new) // 填写表单 await page.getByRole(textbox, { name: /title/i }).fill(New Note) await page.getByRole(textbox, { name: /content/i }).fill(Note content) // 提交 await page.getByRole(button, { name: /submit/i }).click() // 验证重定向 await expect(page).toHaveURL(new RegExp(/users/.*/notes/.*)) })校验错误测试直接空表单提交再断言用户会看到的具体错误文案。test(Form shows validation errors, async ({ page, navigate }) { await navigate(/signup) // 不填写直接提交 await page.getByRole(button, { name: /submit/i }).click() // 验证错误 await expect(page.getByText(/email is required/i)).toBeVisible() })注意断言的是email is required这类用户可见文案而不是 DOM class——这正对应开篇的具体断言哲学。测试 Loaders 与 ActionsLoader 测试在 Vitest 中直接调用路由导出的loader函数构造Request传入执行// app/utils/my-util.test.ts import { describe, expect, it } from vitest import { loader } from ../routes/my-route.ts import { prisma } from ../utils/db.server.ts describe(loader, () { it(should load data, async () { // 创建数据 const user await prisma.user.create({ data: { email: testexample.com, username: testuser, roles: { connect: { name: user } }, }, }) // Mock 请求 const request new Request(http://localhost/my-route) // 执行 loader const result await loader({ request, params: {}, context: {} }) // 验证结果 expect(result.data).toBeDefined() }) })Action 测试E2E 层面通过真实表单交互驱动 action 执行然后验证其结果创建的数据、跳转的 URL、出现的消息。仓库中的 tests/e2e/notes.test.ts 是标准范本——它覆盖了笔记的创建、编辑、删除三个 action 的完整用户路径且使用了faker.lorem生成随机内容避免数据冲突。// tests/e2e/notes.test.ts test(User can create note, async ({ page, navigate, login }) { const user await login() await navigate(/users/:username/notes, { username: user.username }) await page.getByRole(link, { name: /new note/i }).click() await page.getByRole(textbox, { name: /title/i }).fill(Test Note) await page.getByRole(textbox, { name: /content/i }).fill(Test Content) await page.getByRole(button, { name: /submit/i }).click() // 验证笔记已创建 await expect(page.getByText(Test Note)).toBeVisible() })测试权限控制权限测试同时验证有权限能看到/操作与无权限被拒绝两个方向。Epic Stack 测试中切换登录身份的标准手法是先用login登录一个用户再用createSessiongetCookie手工注入另一个用户的会话 cookie。test(Only owner can delete note, async ({ page, navigate, login, insertNewUser, }) { const owner await login() const otherUser await insertNewUser() const note await prisma.note.create({ data: { title: Test Note, content: Test, ownerId: owner.id, }, }) // 以其他用户身份登录 const session await createSession(otherUser.id) await page.context().addCookies([getCookie(session)]) await navigate(/users/:username/notes/:noteId, { username: owner.username, noteId: note.id, }) // 验证无法删除删除按钮不可见 await expect(page.getByRole(button, { name: /delete/i })).not.toBeVisible() })createSession与getCookie分别来自#app/utils/auth.server.ts与会话工具模块——前者创建数据库会话记录后者将会话 cookie 序列化。这个组合也是下面管理员权限示例的基础。测试 GitHub OAuthEpic Stack 的 GitHub 登录依赖prepareGitHubUserfixture它会预先在 mock GitHub 服务中准备一个测试用户通过 tests/mocks/github.ts 的insertGitHubUser写入 fixture 文件并拦截/auth/github请求注入x-mock-code-github头常量定义见 app/utils/providers/constants.ts使 OAuth 回调拿到正确的 mock codetest(User can login with GitHub, async ({ page, navigate, prepareGitHubUser, }) { const ghUser await prepareGitHubUser() await navigate(/login) await page.getByRole(link, { name: /github/i }).click() // GitHub 用户已自动准备 await expect(page).toHaveURL(/onboarding/github) })从 tests/playwright-utils.ts 的源码可见该 fixture 在 teardown 阶段会同时清理数据库中的用户、会话以及 mock fixture 中的 GitHub 用户确保测试可重复运行。完整实战示例示例 1覆盖完整用户工作流的 E2E 测试创建 → 编辑 → 删除笔记这是仓库中笔记模块 E2E 测试的完整形态精简合并版原版见 tests/e2e/notes.test.ts// tests/e2e/notes.test.ts import { expect, test } from #tests/playwright-utils.ts import { prisma } from #app/utils/db.server.ts import { faker } from faker-js/faker test(Users can create, edit, and delete notes, async ({ page, navigate, login, }) { // 用户登录真实工作流 const user await login() await navigate(/users/:username/notes, { username: user.username }) // 用户创建新笔记点击链接、填写表单、提交 await page.getByRole(link, { name: /new note/i }).click() const newNote { title: faker.lorem.words(3), content: faker.lorem.paragraphs(2), } await page.getByRole(textbox, { name: /title/i }).fill(newNote.title) await page.getByRole(textbox, { name: /content/i }).fill(newNote.content) await page.getByRole(button, { name: /submit/i }).click() // 具体断言用户看到带正确标题和内容的笔记 await expect(page.getByRole(heading, { name: newNote.title })).toBeVisible() await expect(page.getByText(newNote.content)).toBeVisible() const noteUrl page.url() const noteId noteUrl.split(/).pop() // 用户编辑笔记点击编辑、更新字段、保存 await page.getByRole(link, { name: /edit/i }).click() const updatedNote { title: faker.lorem.words(3), content: faker.lorem.paragraphs(2), } await page.getByRole(textbox, { name: /title/i }).fill(updatedNote.title) await page .getByRole(textbox, { name: /content/i }) .fill(updatedNote.content) await page.getByRole(button, { name: /submit/i }).click() // 具体断言用户看到更新后的内容 await expect( page.getByRole(heading, { name: updatedNote.title }), ).toBeVisible() await expect(page.getByText(updatedNote.content)).toBeVisible() // 用户删除笔记点击删除按钮 await page.getByRole(button, { name: /delete/i }).click() // 具体断言重定向回笔记列表内容不再可见 await expect(page).toHaveURL(/users/${user.username}/notes) await expect(page.getByText(updatedNote.title)).not.toBeVisible() })示例 2工具函数单元测试仓库中 app/utils/misc.tsx 的cn工具class 合并测试示范了单元测试的标准写法// app/utils/misc.test.ts import { describe, expect, it } from vitest import { cn } from ./misc.tsx describe(cn, () { it(should merge class names, () { expect(cn(foo, bar)).toBe(foo bar) expect(cn(foo, undefined, bar)).toBe(foo bar) expect(cn(foo, false bar, baz)).toBe(foo baz) }) })仓库中同类测试还包括 app/utils/misc.use-double-check.test.tsx、app/utils/headers.server.test.ts、app/utils/sentry-event-filters.test.ts 等可作为不同类型单元测试的参考。示例 3注册表单校验测试// tests/e2e/signup.test.ts test(Signup form validation, async ({ page, navigate }) { await navigate(/signup) // 空表单提交 await page.getByRole(button, { name: /submit/i }).click() // 验证错误 await expect(page.getByText(/email is required/i)).toBeVisible() // 填写非法邮箱 await page.getByRole(textbox, { name: /email/i }).fill(invalid) await page.getByRole(button, { name: /submit/i }).click() // 验证邮箱错误 await expect(page.getByText(/email is invalid/i)).toBeVisible() // 填写合法邮箱 await page.getByRole(textbox, { name: /email/i }).fill(testexample.com) await page.getByRole(button, { name: /submit/i }).click() // 验证重定向到 onboarding await expect(page).toHaveURL(/\/onboarding/) })示例 4管理员权限测试// tests/e2e/permissions.test.ts test(Only admin can access admin routes, async ({ page, navigate, login, insertNewUser, }) { // 用普通用户测试 const normalUser await login() await navigate(/admin/users) // 应被重定向或显示错误 await expect(page).toHaveURL(/) // 或验证错误消息 // 用管理员测试 await page.context().clearCookies() const admin await insertNewUser() await prisma.user.update({ where: { id: admin.id }, data: { roles: { connect: { name: admin }, }, }, }) // 以管理员身份登录 const adminSession await createSession(admin.id) await page.context().addCookies([getCookie(adminSession)]) await navigate(/admin/users) // 现在应该可以访问 await expect(page.getByText(All Users)).toBeVisible() })常见错误与规避清单以下是 Epic Stack 测试实践中总结出的高频误区逐条对照检查可以显著降低测试的脆弱性❌测试实现细节而非用户工作流测试应该镜像用户使用应用的真实方式而不是调内部 API❌模糊断言使用具体、有意义的断言清晰传达预期行为❌测试后不清理数据Epic Stack 会自动清理但不要让用例之间相互依赖残留数据❌假设执行顺序测试必须是相互独立的任何顺序下都应通过❌不用内置 fixture优先使用login、insertNewUser等而不是手工创建一切❌硬编码数据使用faker生成唯一数据避免用例冲突参考 tests/db-utils.ts 中的UniqueEnforcer设计❌不等待元素出现用expect(...).toBeVisible()做显式等待而不是假设元素已存在❌不用类型安全导航用navigatehelper 替代直接page.goto()❌忘记 MSWMOCKStrue时外部服务会被自动 mock不要依赖真实网络❌不测试错误场景既要测 happy path也要测错误路径❌测试内部状态而非用户可见行为聚焦于用户看到和能做的内容。关键文件索引docs/skills/epic-testing/SKILL.md —— 本指南的原始 Skill 文档docs/testing.md —— 仓库的测试总览文档tests/playwright-utils.ts —— Playwright fixtures 与 helperslogin、insertNewUser、navigate、prepareGitHubUser、waitFortests/db-utils.ts —— 测试数据工厂createUser、createPassword、图片数据工厂tests/e2e/ —— E2E 测试用例集2fa、onboarding、passkey、notes、search、settings-profile 等tests/mocks/ —— MSW mock 服务github、resend、tigris、pwned-passwords、cache-servertests/setup/ —— 测试环境与数据库初始化db-setup、global-setup、setup-test-env、custom-matchersapp/utils/*.test.ts 与 app/routes/users/$username/index.test.tsx —— 单元测试参考实现vite.config.ts 与 playwright.config.ts —— 两套测试框架的工程配置package.json —— 全部测试相关 npm 脚本test、test:e2e、validate等。【免费下载链接】epic-stackThis is a Full Stack app starter with the foundational things setup and configured for you to hit the ground running on your next EPIC idea.项目地址: https://gitcode.com/GitHub_Trending/ep/epic-stack创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表