《Astro 测试入门:Vitest 与 Playwright 各管一段》
Astro 的测试为什么”不一样”
给 Astro 站配测试,第一件事是卸掉从 React/Vue 带来的习惯:那里面的”组件测试”默认组件在浏览器里活着、有状态、可交互。Astro 不是——.astro 组件在构建期跑完,输出成静态 HTML,浏览器里默认零 JavaScript。所以传统那套”渲染组件、点按钮、断言状态”在这里根本没对象可测。
我的分段口径是把测试拆成两截,各用各的工具:构建期的事交给 Vitest(工具函数、组件渲染产物),上线产物交给 Playwright(页面可达、关键元素在不在)。这样分还有一个好处:两截可以独立失败、独立修复,工具函数挂了不影响你怀疑路由,冒烟挂了也不用先去看纯函数。边界先说死:岛里 React/Vue 组件的交互测试归各自框架的测试体系,本文不碰;视觉回归、跨浏览器矩阵也不在本文范围。下面所有示例的语境是 Astro 内容站实战搭出来的那个文档站——内容集合存文档、动态路由出详情页。
三类测试对象,一张清单
- 内容集合的工具函数——阅读时长、排序、分组这类纯逻辑,标准 Vitest 单元测试,最便宜也最值得先写;
- .astro 组件——用 Astro 的 container API 把组件渲染成 HTML 字符串再断言,测的是”构建期产物长什么样”;
- 最终页面——build 出来的站点起个服务,Playwright 逐路由冒烟,这是唯一能证明”站真的能用”的一层。
先说省下来的那部分:内容集合数据本身不用单独写用例。defineCollection 的 Zod schema 在 build 时就会全量校验 frontmatter,字段类型不对、必填缺失直接构建失败——那次构建就是一次全量测试,失败日志会指出哪个文件的哪个字段不符。测试资源应该花在 schema 管不到的地方:schema 只管”每条数据长得对”,管不住”这些数据组合起来展示得对”,后者才是下面三层要覆盖的。
Vitest 接入:让两边共享一份 Vite 配置
Astro 官方给 Vitest 留了一个入口函数,让测试环境拿到和 Astro 相同的 Vite 配置(别名、JSX 处理这些都省得配两遍):
npm install -D vitest
// vitest.config.ts
import { getViteConfig } from 'astro/config';
export default getViteConfig({});
就这么两行。版本口径:Astro 5.6.x(5.6 发布于 2025-04-03,写这篇时是当前稳定线)、Vitest 3.1.x(3.0 GA 于 2025-01-17)。别手写一份普通 defineConfig 了事——没有 getViteConfig,测试里 import 带 .astro 依赖的模块会直接报错,这是我第一次接入时踩的坑,报错信息完全看不出是 Vite 配置缺了 Astro 插件,排查绕了远路。
先测工具函数:最便宜的一层
实战篇的文档站里有个阅读时长函数,拿它当靶子。中文按字数、英文按词数折算,口径取公开阅读速度区间的中位数 400 字/分钟:
// src/utils/reading.ts
const CJK = /[u4e00-u9fa5]/g;
export function readingTime(content: string): number {
const cjk = (content.match(CJK) ?? []).length;
const words = content.replace(CJK, ' ').split(/s+/).filter(Boolean).length;
return Math.max(1, Math.ceil((cjk + words) / 400));
}
// tests/reading.test.ts
import { describe, expect, it } from 'vitest';
import { readingTime } from '../src/utils/reading';
describe('readingTime', () => {
it('纯中文按字数折算', () => {
expect(readingTime('文'.repeat(1200))).toBe(3);
});
it('中英混排两边都计数', () => {
expect(readingTime('文档 ' + 'word '.repeat(400))).toBe(2);
});
it('空内容至少一分钟', () => {
expect(readingTime('')).toBe(1);
});
});
预期输出(npx vitest run,时长忽略):
✓ tests/reading.test.ts (3 tests)
Test Files 1 passed (1)
Tests 3 passed (3)
空内容那条不是凑数——文档站上线后我真见过 frontmatter 漏了正文的条目,卡片上渲染出”0 分钟”,这条用例就是那时候补的。同类值得补的还有排序函数(侧边栏按 order 排,order 相同时按标题兜底)、分类分组函数——它们都是 schema 拦不住、页面又直接展示的逻辑。
组件渲染:container API,认准 experimental 前缀
测 .astro 组件用官方 container API,把组件渲染成字符串:
---
// src/components/DocCard.astro(节选)
const { title, href, desc } = Astro.props;
---
<a class="doc-card" href={href}>
<h3>{title}</h3>
<p>{desc}</p>
</a>
// tests/DocCard.test.ts
import { describe, expect, it } from 'vitest';
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import DocCard from '../src/components/DocCard.astro';
describe('DocCard', () => {
it('渲染出标题链接与描述', async () => {
const container = await AstroContainer.create();
const html = await container.renderToString(DocCard, {
props: { title: '快速开始', href: '/docs/getting-started', desc: '从零到第一个页面' },
});
expect(html).toContain('快速开始');
expect(html).toContain('href="/docs/getting-started"');
});
});
slots 也能传,测布局组件时常用:
const html = await container.renderToString(Card, {
slots: { default: '插槽里的字' },
});
expect(html).toContain('插槽里的字');
三点提醒。一是 experimental_ 前缀不是装饰——这个 API 可能变,升级 Astro 时它是重点检查对象,用之前先看对应版本文档。二是断言对象是 HTML 字符串,toContain 或正则够用;别指望 querySelector,那不是字符串的方法,想要结构化查询就把产物丢给 cheerio 解析,但我的判断是组件级断言浅一点刚好——组件该不该长成那样,留给下一层。三是能力边界:container 渲染纯 Astro 组件没问题,岛(React/Vue 组件)在里面只会得到壳,client: 指令的注水逻辑不会跑——这正好符合分层,岛的行为归各框架测试,container 只负责确认”壳在正确位置、带了正确属性”。
Playwright 冒烟:给最终产物上保险
前面两层全绿也证明不了”站能用”——路由写错、链接指错页、构建产物缺文件,只有真渲染过的最终站点才暴露。Playwright 冒烟测三样:路由可达(200)、关键元素在(h1、导航)、没有明显坏况。先装:
npm install -D @playwright/test
npx playwright install chromium
配置里让 Playwright 自己构建并起预览服务——astro preview 伺候的是 build 产物,这一步不能省,省了测到的就不是上线的东西:
// playwright.config.ts
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
baseURL: 'http://localhost:4321',
webServer: {
command: 'npm run build && npm run preview',
url: 'http://localhost:4321',
reuseExistingServer: true,
timeout: 120_000,
},
});
// e2e/smoke.spec.ts
import { expect, test } from '@playwright/test';
const routes = ['/', '/docs/getting-started', '/docs/collections'];
for (const route of routes) {
test(`${route} 可达且有关键元素`, async ({ page }) => {
const res = await page.goto(route);
expect(res?.status()).toBe(200);
await expect(page.locator('h1')).toBeVisible();
await expect(page.locator('nav a').first()).toBeVisible();
});
}
npx playwright test 的预期输出大致如下(时长每次不同):
Running 3 tests using 1 worker
✓ e2e/smoke.spec.ts:7:7 / 可达且有关键元素
✓ e2e/smoke.spec.ts:7:7 /docs/getting-started 可达且有关键元素
✓ e2e/smoke.spec.ts:7:7 /docs/collections 可达且有关键元素
3 passed
路由清单是手工维护的,这正是它的价值:新页面上线前往数组里加一行,冒烟就多扫一条。站点一大可以改成从构建产物里扫 dist 的 HTML 文件自动生成清单,起步阶段手写足够。冒烟用例会多挑一条”内容正确性”的:详情页 h1 要等于 frontmatter 的 title——这是防”路由通但渲染错数据”的,一行断言的成本。
有人会问:检查页面可达,用脚本发 HTTP 请求看状态码不是更便宜?我对比过:纯 HTTP 检查只能证明”服务器回了 200″,证明不了页面渲染对了——链接指向不存在的路由、模板里变量写错渲染出空标题,HTTP 状态照样是 200。Playwright 是真浏览器加载,JS 报错、坏图、元素缺失都会现形。冒烟这层买的不是状态码,是”用户打开页面能看到东西”,那就得用接近用户的办法测。
失败时的输出也看一眼。删掉 /docs/collections 这条路由再跑:
✘ e2e/smoke.spec.ts:7:7 /docs/collections 可达且有关键元素
1) e2e/smoke.spec.ts:9:27 expect(received).toBe(expected)
Expected: 200
Received: 404
1 failed
状态码、路由、挂在哪一行,一眼定位。这比”上线后用户发消息说页面 404 了”的发现方式,成本差着两个数量级。
不值得测的,明确不测
- 样式与视觉细节。静态站更像出版物,排版问题人眼验收比断言快,像素级断言在 jsdom/Playwright 里都昂贵且脆;
- pagefind 搜索质量。索引是 build 后生成的二进制产物,测它等于替 pagefind 写测试;要保障的只是”索引在 build 之后生成”这个顺序(实战篇的坑);
- 岛内框架组件的内部逻辑。归 React/Vue 各自的测试体系,冒烟层只要岛在页面上出现即可;
- frontmatter 合法性。Zod schema 在构建期全量校验,构建失败就是红灯,重复写用例是浪费。
本地节奏与 CI 排队
本地开发我的节奏是两窗口并行:一个跑 npm run test:watch 盯构建期逻辑,另一个 npx playwright test --ui 开 UI 模式——能看到每条用例的时间线、DOM 快照与网络记录,挂了点开 trace 就知道是选择器写错还是真没渲染。调试期同样能收窄范围:npx playwright test e2e/smoke.spec.ts -g "docs" 只跑文件名匹配、用例名含 docs 的那几条,不用每次全量等三分钟。日常改文案不必跑 Playwright,push 前跑一次全量冒烟;改路由结构、动布局模板时才值得盯 UI 模式调试。
CI 里按”最便宜的先跑、最快失败的先跑”排队,第一道闸挡住的错误就轮不到后面花钱:
# .github/workflows/ci.yml(节选)
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx astro check # 构建期诊断,.astro 文件的类型与语法
- run: npx vitest run # 工具函数 + 组件渲染
- run: npx playwright install --with-deps chromium
- run: npx playwright test # 内部自带 build + preview
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: test-results/
两个省钱细节。astro check 要先装 npm i -D @astrojs/check typescript,它把 .astro 文件里的类型错误也查了——frontmatter 里写错变量名这种事,静态站上线后可没有报错控制台给你看。node-version 固定 22 不是随手填的:本地开发、构建、CI 三处用同一个 LTS 大版本,”我这儿能跑”类扯皮少一半。Playwright 的浏览器二进制很肥,CI 上可以用 actions/cache 按 ~/.cache/ms-playwright 目录缓存、key 带上版本号,装浏览器的几分钟基本省掉;失败时上面那段 artifact 配置会把 test-results/ 里的 trace 和报错截图传上去,CI 挂了不用本地复现一遍。
验收清单
npx vitest run全绿;把 readingTime 的空内容分支临时改成返回 0,用例必须变红;npx astro check零错误;故意在某个.astro的 frontmatter 里引用不存在的变量,它必须报出来;- 故意把某个 frontmatter 字段类型写错(order 给成字符串),
astro build必须在构建期失败而不是渲染出坏页; npx playwright test全绿;删掉一条真实路由再跑,对应冒烟用例必须因为 404 挂掉;- CI 日志里三道闸顺序是 check → vitest → playwright,playwright 阶段能看到 webServer 起服务的日志。
接进系列
本文是 Astro 学习路线阶段四”构建与部署”的收尾一环:那条路线反复强调”验收以 build 产物为准”,Playwright 冒烟就是把这句话变成自动化的那一步。内容集合、schema、动态路由的来龙去脉都在 Astro 内容站实战里,本文的工具函数与组件示例都站在它的地基上。继续往深走两条路:一是给岛内的框架组件补各自的测试(Vitest 配置里加对应框架插件即可,getViteConfig 的底子不用换),二是把冒烟清单扩展成带交互的 E2E——从”页面在”走到”功能对”,每一步都让内容站的维护成本降一截。
本文数据核对时间:2025-04-07:Astro 5.6.x(5.6 发布于 2025-04-03,官方博客口径,写作时最新补丁 5.6.1);Playwright 1.51.x(1.51 发布于 2025-03-06);Vitest 3.1.x(3.0 GA 2025-01-17,3.1 于 2025-04 初发布);Node 22 LTS;container API 仍为 experimental 前缀(官方文档口径)。API 细节以 docs.astro.build 与 playwright.dev 为准。