服务端阅读 05月27日 15:38
如何在 Astro 项目中使用 Vitest 和 Playwright 进行测试?
Astro 项目中的测试涉及单元测试、组件测试和端到端测试三个层次,不同层次的测试需要搭配不同的工具和策略。实际开发中,Vitest 负责 .ts 工具函数和框架组件的单元测试,Playwright 负责页面级别的端到端测试,而 .astro 组件的测试则需要借助 Container API 或 vitest-browser-astro 方案。测试框架选型Vitest 是 Astro 官方推荐的单元测试框架,它基于 Vite 构建,与 Astro 的构建体系天然兼容,配置简单且执行速度快。如果你的项目已经使用 Vite,Vitest 几乎可以零配置接入。Playwright 是端到端测试的首选,支持 Chromium、Firefox 和 WebKit 三大浏览器引擎,能够在真实浏览器环境中模拟用户操作,验证页面导航、表单提交、交互逻辑等完整流程。Jest 虽然生态成熟,但需要额外的 transform 配置才能处理 Astro 项目中的 TypeScript 和 JSX,配置成本较高,一般不推荐在 Astro 项目中使用。安装测试依赖# 安装 Vitestnpm install -D vitest# 安装 vitest-browser-astro(用于测试 .astro 组件)npm install -D vitest-browser-astro @vitest/browser-playwright playwright# 安装框架组件测试工具(按需选择)npm install -D @testing-library/react # React 组件npm install -D @testing-library/vue # Vue 组件npm install -D @vue/test-utils # Vue 组件(替代方案)# 安装 Playwrightnpm install -D @playwright/testnpx playwright install配置 VitestAstro 提供了 getViteConfig() 辅助函数,可以自动将项目的 Astro 配置应用到 Vitest,避免手动对齐 Vite 插件和路径别名。// vitest.config.ts/// <reference types="vitest" />import { getViteConfig } from 'astro/config';export default getViteConfig({ test: { // 服务端代码测试用 node 环境 environment: 'node', globals: true, setupFiles: ['./src/test/setup.ts'], },});如果部分测试依赖 DOM 环境,可以在单个测试文件顶部通过注释指定:// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';getViteConfig() 还支持第二个参数,用于在测试中覆盖 Astro 配置:export default getViteConfig( { test: { environment: 'node' } }, { site: 'https://example.com/', trailingSlash: 'always' });测试 .astro 组件.astro 组件是服务端渲染的,不能像 React 组件那样直接用 Testing Library 的 render 方法。Astro 提供了两种测试方案:方案一:Container APIContainer API 是 Astro 内置的实验性功能,可以在服务端渲染 .astro 组件并返回 HTML 字符串:// src/components/__tests__/Card.test.tsimport { describe, it, expect } from 'vitest';import { Container } from 'astro:container';import Card from '../Card.astro';describe('Card 组件', () => { it('渲染标题文本', async () => { const container = await Container.create(); const result = await container.renderToString(Card, { props: { title: 'Hello Astro' }, }); expect(result).toContain('Hello Astro'); });});方案二:vitest-browser-astrovitest-browser-astro 将组件渲染到真实浏览器 DOM 中,支持更丰富的交互断言:// vitest.config.ts 中添加插件import { getViteConfig } from 'astro/config';import { astroRenderer } from 'vitest-browser-astro/plugin';import { playwright } from '@vitest/browser-playwright';export default getViteConfig({ plugins: [astroRenderer()], test: { browser: { enabled: true, instances: [{ browser: 'chromium' }], provider: playwright(), headless: true, }, },});// src/components/__tests__/Card.test.tsimport { render } from 'vitest-browser-astro';import { expect, test } from 'vitest';import Card from '../Card.astro';test('渲染卡片标题', async () => { const screen = await render(Card, { props: { title: 'Hello Astro' }, }); await expect.element(screen.getByText('Hello Astro')).toBeVisible();});如果组件中嵌套了 React 或 Vue 框架组件,需要在配置中注册对应的 container renderer:import { getContainerRenderer } from '@astrojs/react';const container = await Container.create({ renderers: [getContainerRenderer()],});注意:同一个配置中只能使用一种 JSX 框架 renderer,Vue 和 Svelte 等非 JSX 框架可以与 JSX 框架共存。测试框架组件React 组件测试// src/components/__tests__/Counter.test.tsx// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';import { render, screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import Counter from '../Counter';describe('Counter', () => { it('显示初始计数值', () => { render(<Counter initialCount={0} />); expect(screen.getByText('Count: 0')).toBeInTheDocument(); }); it('点击按钮后计数值加一', async () => { const user = userEvent.setup(); render(<Counter initialCount={0} />); await user.click(screen.getByRole('button', { name: 'Increment' })); expect(screen.getByText('Count: 1')).toBeInTheDocument(); });});Vue 组件测试// src/components/__tests__/TodoList.test.ts// @vitest-environment jsdomimport { describe, it, expect } from 'vitest';import { mount } from '@vue/test-utils';import TodoList from '../TodoList.vue';describe('TodoList', () => { it('渲染待办事项列表', () => { const todos = [ { id: 1, text: 'Learn Astro', completed: false }, { id: 2, text: 'Build app', completed: true }, ]; const wrapper = mount(TodoList, { props: { todos } }); expect(wrapper.findAll('.todo-item')).toHaveLength(2); expect(wrapper.text()).toContain('Learn Astro'); }); it('勾选完成状态后触发事件', async () => { const wrapper = mount(TodoList, { props: { todos: [{ id: 1, text: 'Task', completed: false }] }, }); await wrapper.find('input[type="checkbox"]').setValue(true); expect(wrapper.emitted('complete')).toBeTruthy(); });});测试 API 路由Astro 的 API 路由导出 GET、POST 等函数,可以直接在测试中调用:// src/pages/api/__tests__/users.test.tsimport { describe, it, expect, beforeEach, vi } from 'vitest';import { GET, POST } from '../users';describe('Users API', () => { beforeEach(() => { vi.clearAllMocks(); }); it('GET 返回用户列表', async () => { const request = new Request('http://localhost/api/users'); const response = await GET({ request } as any); const data = await response.json(); expect(response.status).toBe(200); expect(Array.isArray(data.users)).toBe(true); }); it('POST 创建新用户', async () => { const request = new Request('http://localhost/api/users', { method: 'POST', body: JSON.stringify({ name: 'John', email: 'john@example.com' }), }); const response = await POST({ request } as any); const data = await response.json(); expect(response.status).toBe(201); expect(data.name).toBe('John'); });});测试中间件Astro 中间件的 onRequest 函数可以像普通函数一样测试,关键是构造正确的 context 对象:// src/middleware/__tests__/auth.test.tsimport { describe, it, expect, vi } from 'vitest';import { onRequest } from '../auth';describe('认证中间件', () => { it('无 token 时重定向到登录页', async () => { const request = new Request('http://localhost/dashboard'); const redirect = vi.fn(); await onRequest({ request, redirect } as any); expect(redirect).toHaveBeenCalledWith('/login'); }); it('有效 token 时放行请求', async () => { const request = new Request('http://localhost/dashboard', { headers: { Authorization: 'Bearer valid-token' }, }); const next = vi.fn().mockResolvedValue(new Response()); await onRequest({ request, next } as any); expect(next).toHaveBeenCalled(); });});端到端测试(Playwright)Playwright 测试在真实浏览器中运行,需要先启动开发服务器。在 playwright.config.ts 中配置 webServer:// playwright.config.tsimport { defineConfig } from '@playwright/test';export default defineConfig({ webServer: { command: 'npm run dev', port: 4321, reuseExistingServer: !process.env.CI, }, testDir: './e2e',});编写端到端测试:// e2e/navigation.spec.tsimport { test, expect } from '@playwright/test';test.describe('页面导航', () => { test('首页加载正常', async ({ page }) => { await page.goto('/'); await expect(page).toHaveTitle(/My Astro App/); await expect(page.locator('h1')).toContainText('Welcome'); }); test('导航到关于页', async ({ page }) => { await page.goto('/'); await page.click('text=About'); await expect(page).toHaveURL(/\/about/); }); test('表单提交成功', async ({ page }) => { await page.goto('/contact'); await page.fill('input[name="name"]', 'John'); await page.fill('input[name="email"]', 'john@example.com'); await page.fill('textarea[name="message"]', 'Hello!'); await page.click('button[type="submit"]'); await expect(page.locator('.success-message')).toBeVisible(); });});测试覆盖率在 vitest.config.ts 中配置覆盖率报告:// vitest.config.tsimport { getViteConfig } from 'astro/config';export default getViteConfig({ test: { coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', 'src/test/', '**/*.d.ts', '**/*.config.*', ], }, },});npm scripts 配置在 package.json 中添加测试命令:{ "scripts": { "test": "vitest", "test:run": "vitest run", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", "test:e2e:headed": "playwright test --headed" }}测试策略建议分层测试金字塔:底层大量单元测试覆盖工具函数和独立组件,中间适量集成测试验证组件协作和 API 逻辑,顶层少量端到端测试保障核心业务流程。.astro 组件测试优先用 Container API:简单场景用 renderToString 断言 HTML 内容即可,需要交互断言时再用 vitest-browser-astro。Mock 外部依赖:用 vi.mock() 隔离第三方模块和 API 调用,确保测试稳定且不依赖外部服务。CI 中强制执行:在 CI 流程中运行 vitest run 和 playwright test,并设置覆盖率阈值,低于阈值则构建失败。