authentik WebUI 测试体系实战指南:Unit / Browser / Lit 三层测试架构与规范全解析
【免费下载链接】authentikThe authentication glue you need.项目地址: https://gitcode.com/GitHub_Trending/au/authentik
authentik 的 Web 前端是一个基于 Lit Web Components 与 PatternFly 4 的 TypeScript monorepo,包含 Flow(/if/flow/)、User(/if/user/)、Admin(/if/admin/)三个独立 UI 应用。为了在不同抽象层级上持续保障这些界面的质量,仓库在 web/test 目录下建立了一套分层测试体系。本文以 web/test/AGENTS.md 及其子目录约定文档为主体,结合真实测试源码(web/test/browser/*.test.ts、web/test/unit/*.test.ts)、Playwright fixtures(web/e2e/)与种子蓝图(web/test/blueprints/),完整讲解这套体系的目录结构、层级选择决策、跨层通用规则、fixtures 用法与测试编写规范,帮助你在为 authentik WebUI 贡献测试时写出符合项目惯例、可稳定运行、易于排查失败的高质量用例。
一、测试体系的整体设计:三个目录,三种运行环境
web/test/是 authentik WebUI 的测试目录路由器。它并不直接存放测试实现,而是把三类不同运行环境的自动化测试组织到三个子目录中,每个子目录都有自己独立的约定文档:
| 目录 | 存放内容 | 运行环境 / Runner | 约定文档 |
|---|---|---|---|
test/unit/ | 无 DOM 依赖的函数、类、模块的纯 Node 测试 | Vitest,Node 环境 | test/unit/AGENTS.md |
test/browser/ | 在 Chromium 中驱动 Admin / User UI、面向运行中的 authentik 实例的端到端测试 | Vitest browser provider(基于 Playwright)+#e2efixtures | test/browser/AGENTS.md |
test/lit/ | 组件级浏览器测试共用的 Lit 渲染辅助(renderLit、LitViteContext),本身不直接存放测试 | — | — |
test/blueprints/ | 注入 authentik 的 YAML 蓝图(如test-admin-user.yaml),供浏览器测试登录认证使用 | — | — |
这套结构的关键设计意图是:按依赖面而非按功能划分测试。纯逻辑测试不引入浏览器开销,可快速覆盖分支与边界;UI 流程测试则严格走真实界面路径,确保回归能被完整捕获。
从 CLAUDE.md 到 AGENTS.md:文档的路由机制
web/test/CLAUDE.md本身只有一行@AGENTS.md,这是 Claude Code 的文件包含指令,表示该目录的行为约定实际定义在web/test/AGENTS.md中。web/test/browser/CLAUDE.md与web/test/unit/CLAUDE.md同样通过@AGENTS.md分别指向web/test/browser/AGENTS.md和web/test/unit/AGENTS.md。因此,阅读任何一份测试代码之前,应先按此链找到对应的约定文档——文档路由本身就是分层体系的组成部分。
二、如何选择正确的测试层级
test/AGENTS.md给出了一个自顶向下的决策清单,命中即停,避免把测试放错层级:
- 纯函数、无 DOM、无网络?→ 放入
test/unit/。这类测试廉价、快速,适合密集的分支覆盖(详见unit/AGENTS.md)。 - 用户实际点击走通的特性流程(向导、对话框、导航、列表表格、登录)?→ 放入
test/browser/。必须驱动真实 UI,不要用@goauthentik/api客户端伪造一个单元测试来模拟它。 - 针对某个具体 bug 的回归测试?→ 找到它所属的特性套件(feature suite),在
test/browser/中追加一个test(...)用例;不要新建以 bug 命名的孤立文件。如果 bug 在纯函数中,则在对应的test/unit/文件中追加it(...)。 - Lit 组件在隔离环境下的行为(生命周期、slots、事件、响应式更新,无整体应用上下文)?→ 将测试与源码放在一起,命名为
Component.browser.test.ts,由 Vitest 配置通过**/*.browser.test.tsglob 自动拾取,并用test/lit/setup.js暴露的page.renderLit(...)挂载组件。
文档特别强调了一个"危险信号"判断法:如果你发现自己在做不属于任何一个桶的事——比如在单元测试里 import 一个 Lit 组件,或是在浏览器测试里直接调用 REST API 播种数据——这强烈说明你选错了层级,应重新阅读目标层级的约定文档。
从web/test/目录的实际文件可以看到这套决策的落地:纯逻辑测试集中在 test/unit(如lexer.test.ts、flow-graph.test.ts、labels.test.ts);用户流程测试按特性编号分布在 test/browser(100-session.test.ts、300-users.test.ts、400-groups.test.ts、500-roles.test.ts、600-providers.test.ts、700-applications.test.ts、900-invitations.test.ts等);组件级测试则采用就近放置的方式,如test/component/ak-map.browser.test.ts。
三、跨层通用规则:在test/内处处适用
无论写哪一层测试,以下规则都是硬性约束:
- 禁止自建 API 客户端。不要在测试文件里手写基于
fetch的 admin 客户端。单元测试不需要它;浏览器测试必须驱动 UI;如果确有播种缺口,应扩展 fixture 或蓝图。 - 禁止硬编码超出 fixture 范围的凭据。浏览器测试通过
session.login()使用 test/blueprints/test-admin-user.yaml 中定义的 bootstrap 管理员认证;不要在测试里读取process.env.AK_TEST_BOOTSTRAP_TOKEN。 - 实体命名必须确定性唯一。浏览器测试创建数据时用
IDGenerator.randomID(...)保证唯一性(约定见 browser conventions);单元测试通常不需要。 - 一个特性/符号对应一个文件。抵制创建以 bug、工单号或日期命名的临时文件。
- 测试名必须是完整句子。如
"returns null once the input is exhausted"、"Create application with existing provider",而不是"works"、"#22383"。
四、运行方式与前置条件
web/test/AGENTS.md与各子约定文档给出了完整的运行命令集,均需在 web 目录下执行(依赖见 web/package.json 的scripts字段):
npm test # 同时运行两个 Vitest project(unit + browser) npx vitest run test/unit # 只跑单元测试 npx vitest run test/browser # 只跑浏览器测试 npx vitest run path/to/single.test.ts # 只跑单个测试文件 npm run test:e2e # Playwright e2e CLI 路径(使用相同的 test/browser 源码)单元测试的快速迭代方式(按名称过滤):
npx vitest run test/unit/lexer.test.ts # 单个文件 npx vitest test/unit/lexer.test.ts -t "tokenization" # 按 describe 名称过滤浏览器测试的关键前置条件:需要一个运行中的 authentik 实例,地址由环境变量AK_TEST_RUNNER_PAGE_URL指定,默认http://localhost:9000。web/test/browser/prerequisites.setup.ts中的健康检查会在实例不可达时报错退出,保证后续测试不会在错误环境上静默失败:
setup("Web server availability", async ({ baseURL }) => { expect(baseURL, "Base URL is set").toBeTruthy(); const ok = await fetch(baseURL!) .then((res) => res.ok) .catch(() => false); expect(ok, `Web server should be listening on ${baseURL}`).toBeTruthy(); });除了健康检查,prerequisites.setup.ts还演示了跨套件共享前置状态的正确做法:101-session-lifecycle的记住登录(remember-me)覆盖依赖默认识别阶段上的一个开关,因此在所有 worker 启动之前,通过一次管理员登录把该开关打开——而不是在某个beforeAll里边保存流程阶段边与其他 worker 并发登录(并发写会互相干扰)。
五、单元测试(test/unit/)规范深度解读
test/unit/AGENTS.md将单元测试定义为纯 Node、无浏览器的测试:针对独立函数、纯逻辑和无 DOM 依赖的模块,运行在 Vitest 的 Node 环境下——不涉及 Playwright、不渲染 Lit、不依赖真实 authentik 实例。
何时该用单元测试
- 被测对象是普通函数或类,无 DOM、无网络、无组件生命周期;
- 想快速且彻底地覆盖分支、边界、错误路径和不变量;
- 行为对输入是确定性的——没有定时器、没有外部服务、没有
customElements.define。
一旦涉及渲染 Lit 组件、点击、等待网络或断言 DOM,就不属于这里,应推送到就近的组件测试或test/browser/。
文件布局与导入
- 文件位于
test/unit/*.test.ts,一个文件对应一个被测模块/特性,以符号或模块命名(如lexer.test.ts、authenticator-validate-challenge-selection.test.ts)。 - Vitest 配置还会拾取全工作区内的
**/*.unit.test.ts,因此紧耦合的测试可以就近放在源码旁(foo.unit.test.ts)。 - 导入必须走包级
#alias(#flow/…、#elements/…、#common/…),禁止用相对路径深入src/。这些别名在 web/package.json 的imports字段中映射。 - 只从
vitest导入describe/it/expect/vi;不要从#e2e导入test/expect——那是浏览器测试专用的,会拖入 Playwright。
测试形态与命名
test/unit/lexer.test.ts是文档反复引用的范本。它的结构是describe("Lexer")下按行为嵌套describe("addRule")、describe("setInput")等,每个it用完整句子同时陈述前提与结果:
describe("addRule", () => { it("returns the lexer for chaining", () => { const lexer = new Lexer(); expect(lexer.addRule(/a/, () => "a")).toBe(lexer); }); it("preserves multiline, ignoreCase, and unicode flags when re-compiling", () => { const lexer = new Lexer(() => null); const seen: string[] = []; lexer.addRule(/^a/im, (m) => { seen.push(m); }); lexer.setInput("A\nA"); drain(lexer); expect(seen).toEqual(["A", "A"]); }); });约定要点:
- 顶层用
describe(symbolName),可按方法或行为嵌套;it("returns X when Y")以动词开头写完整句子,同时陈述结果与前提。坏的命名:"works"、"handles nulls";好的命名:"returns null once the input is exhausted"、"rolls back the lexer index when an action rejects"。 - Arrange / act / assert 三阶段之间用空行分隔,便于扫读;重复的测试数据形状用文件顶部的内联工厂函数(如
makeDeviceChallenge(...)),直到两个文件都需要时才提到共享 helpers。 - 一个
it只测一个概念:如果命名时想用 "and",就拆分。 - 单元测试的
expect()不加断言消息——测试名与 matcher 已表达意图,Vitest 的输出足够。
断言与 Mock
- 使用纯 Vitest matcher:
toBe(基本类型与引用同一性)、toEqual(结构相等)、toThrow(/regex/)(错误路径,匹配消息的稳定片段而非整句)、.mock.calls[i]?.[j](精确断言 spy 参数)。 - 优先用
vi.fn()内联构造测试替身,而不是模块级vi.mock(...);只有被测代码真正读取时钟时才用vi.useFakeTimers();如果必须vi.mock("module"),把它提升到文件顶部,并在理由不明显时用一行注释说明原因。 - 错误路径必须用
expect(() => …).toThrow(...)显式断言,不要用静默的try/catch吞掉异常导致测试假通过。 - 除非输出是稳定、有意的产物(如 token 流),否则不要断言快照——快照在代替思考契约时腐烂得很快。
单元测试的禁区
不 import@playwright/test或#e2e;不调用customElements.define或 import Lit 组件(Node 环境没有 DOM);不访问网络或文件系统(需要 IO 说明测错了层)。
六、浏览器测试(test/browser/)规范深度解读
test/browser/AGENTS.md定义了这类测试的本质:在 Vitest 的 browser runner(Chromium)下运行的 Playwright 测试,端到端地驱动 Admin 与 User UI,面向运行中的 authentik 实例。测试位于test/browser/*.test.ts,支撑的 fixtures 与 helpers 位于web/e2e/。
三条核心理念
- 驱动 UI,而不是驱动 API。特性测试应走用户路径:点"New Provider"、填表单、点"Create"、验证它出现。不通过 REST API 播种实体然后只点一个按钮验证单一副作用。如果 UI 流程坏了,测试必须跟着坏;如果从 API 抄近路,向导、模态框、导航、表单绑定的回归就全部漏检。
- 覆盖特性,而不是覆盖 bug。测试文件以特性命名(
providers.test.ts、applications.test.ts),而非以 bug 命名;特定缺陷的回归测试作为既有特性套件里追加的test(...)用例,而非带专属 API 管线的孤立文件。 - 无自建 HTTP 客户端。如果开始写
makeAPIClient辅助函数,停下来:要么驱动 UI 创建前置状态,要么(当前置确实超出被测特性范围时)扩展 fixture 使其可复用。
此外还有一条务实的不做显式清理规则:实体名用IDGenerator.randomID(...)播种,每次运行产生唯一 slug,陈旧实体不会冲突,允许在开发环境中自然累积。不要加try/finally清理块——它们会遮蔽测试末尾的断言,且在 UI 流程崩溃时倾向于吞掉真正的失败。
#e2e入口与 fixtures
测试从#e2e别名导入,绝不直接 import@playwright/test:
import { expect, test } from "#e2e"; import { randomName } from "#e2e/utils/generators"; import { IDGenerator } from "@goauthentik/core/id"; import { series } from "@goauthentik/core/promises";#e2e入口即 web/e2e/index.ts:它从@playwright/test重新导出expect,并通过base.extend注册自定义 fixtures。从源码可以看到每个 fixture 都是按测试(test scope)构造的,接收page与测试标题:
export const test = base.extend<E2EFixturesTestScope, E2EWorkerScope>({ navigator: async ({ page }, use, { title }) => { await use(new NavigatorFixture(page, title)); }, session: async ({ page, navigator }, use, { title: testName }) => { await use(new SessionFixture({ page, testName, navigator })); }, form: async ({ page }, use, { title }) => { await use(new FormFixture(page, title)); }, pointer: async ({ page }, use, { title: testName }) => { await use(new PointerFixture({ page, testName })); }, passkey: async ({ page, context }, use, { title: testName }) => { await use(new PasskeyFixture({ page, testName, context })); }, });约定文档总结了每个 fixture 的职责:
| Fixture | 用途 |
|---|---|
session | login({ to, username?, password?, rememberMe? })、toLoginPage()、checkAuthenticated()。默认使用test-admin@goauthentik.io/test-runner |
navigator | navigate(to)与waitForPathname(to)——用它们替代page.goto,保证 URL 等待行为一致 |
form | fill(label, value, ctx?)、search(query, ctx?)、selectSearchValue(label, pattern, ctx?)、setInputCheck(label, bool, ctx?)、setRadio(group, name, ctx?)、setFormGroup(pattern, open, ctx?)。知晓ak-switch-input、ak-form-group与搜索选择下拉框 |
pointer | click(name, role?, ctx?)——按可访问名称(accessible name)的高层点击,默认按钮/链接 |
page | 原始 PlaywrightPage,用于 fixtures 未覆盖的场景;Shadow DOM 自动穿透 |
baseURL | 实例 URL,来自AK_TEST_RUNNER_PAGE_URL(默认http://localhost:9000) |
session.login()的凭据对应 test/blueprints/test-admin-user.yaml 播种的 bootstrap 管理员(用户名akadmin、邮箱test-admin@goauthentik.io、密码test-runner,隶属于authentik Admins组)——测试通过真实登录流程获得会话,而不是从环境变量偷 token。
一个标准浏览器测试的完整形态
文档给出的范本结构如下:
test.describe("Feature name", () => { const names = new Map<string, string>(); test.beforeEach("Seed names", async ({ page: _page }, { testId }) => { const seed = IDGenerator.randomID(6); names.set(testId, `${randomName(seed)} (${seed})`); }); test("Do the thing", async ({ session, navigator, form, pointer, page }, testInfo) => { const name = names.get(testInfo.testId)!; const { fill, search, selectSearchValue } = form; const { click } = pointer; await test.step("Authenticate", async () => { await session.login({ to: "/if/admin/core/providers" }); }); const dialog = page.getByRole("dialog", { name: "New Provider Wizard" }); await test.step("Open wizard", async () => { await expect(dialog, "Wizard is initially closed").toBeHidden(); await click("New Provider"); await expect(dialog, "Wizard opens").toBeVisible(); }); await test.step("Fill form", async () => { await series( [click, "OAuth2/OpenID", "option"], [fill, "Provider Name", name], [ selectSearchValue, "Authorization Flow", /default-provider-authorization-explicit-consent/, ], [click, "Create"], ); }); await test.step("Verify created", async () => { await expect(await search(name), "Provider is visible").toBeVisible(); }); }); });其中蕴含的约定包括:
- 每个特性一个
test.describe,测试名用朴素的祈使句; - 每个有意义的阶段都用
test.step(...)包裹——它们会出现在 trace 与 HTML 报告中,让失败自定位; - 名字以
testId为键存放在模块级Map中,在beforeEach里播种; series([fn, ...args], ...)用于有序的表单填写序列,自上而下读起来就像一段用户操作脚本;- 对话框 locator只捕获一次,之后作为
ctx?参数传给内部的fill/click/selectSearchValue以限定作用域; - 每个
expect都有第二个参数作为断言消息(以被断言的属性措辞,如 "Wizard opens"、"Provider is visible",而非复述 matcher); - 第一个参数必须是解构模式,即使不引用任何 fixture 也要写
async ({ page: _page }, { testId }) => {…}:裸标识符async (_, { testId }) => {…}会在运行时抛First argument must use the object destructuring pattern(Playwright 靠解析参数模式决定注入哪些 fixtures),而空解构async ({}, { testId }) => {…}会触发 ESLint 的no-empty-pattern——"解构并重命名"是唯一同时满足两者的写法。
Locator 优先级
按顺序优先使用:
- ARIA role 查询:
page.getByRole("button", { name: "Create" })、page.getByRole("dialog", { name: /Launch Endpoint/i })、page.getByLabel("Username")——能扛住样式/标记变化并表达意图; - Web Component 标签:
page.locator("ak-stage-identification")、page.locator("ak-form-group", { hasText: /Advanced/ })——稳定的元素契约; data-test-id:page.getByTestId("...")——Playwright 配置已设置testIdAttribute: "data-test-id",仅在 role/label 无法区分时新增;- CSS 选择器:最后手段。
Shadow DOM 是透明穿透的——不要写.shadowRoot遍历,Playwright 自动穿过。
断言风格
await expect(dialog, "Dialog is initially closed").toBeHidden(); await expect(dialog, "Dialog opens").toBeVisible(); await expect(row, "Endpoint row appears without manual refresh").toBeVisible({ timeout: 5_000 }); await expect(input, "Input has expected value").toHaveValue("foo"); await expect(checkbox, "Checkbox is checked").toBeChecked();- 永远带消息;只有在默认 5s 确实不够时才显式指定
{ timeout: ... }(通常是对话框挂载或导航等异步 UI 转换后的首个断言); - 不写
page.waitForTimeout——等待你真正关心的 locator 条件即可。
反模式清单
- 测试文件里自建 API 客户端(
makeAPIClient、裸fetch(${baseURL}/api/v3/...)做前置); - 从测试中读取
process.env.AK_TEST_BOOTSTRAP_TOKEN(应通过session.login()以真实用户身份认证); - 为单个 bug 建独立文件的回归测试(应并入相关特性套件);
try/finally清理块(名字已随机化,允许实体累积);- 无等待的
page.goto(用navigator.navigate(to)或session.login({ to })); - 存在 role/label 时仍用 CSS 选择器断言(如
.locator('button[type="submit"]')); - 跳过
test.step(又长又平的测试难以调试,每个阶段都要包裹)。
七、Lit 组件级测试与渲染辅助
当需要测试单个 Lit 组件在隔离环境下的行为(生命周期、slots、事件、响应式更新,且无整体应用上下文)时,采用就近放置的方式:在组件源码旁创建Component.browser.test.ts,Vitest 配置通过**/*.browser.test.ts自动拾取(如test/component/ak-map.browser.test.ts)。
挂载组件的机制在 web/test/lit/setup.js:
import { LitViteContext } from "./rendering.js"; import { beforeEach } from "vitest"; import { page } from "vitest/browser"; page.extend({ // @ts-expect-error Extension is not properly typed. renderLit: LitViteContext.render, [Symbol.for("vitest:component-cleanup")]: LitViteContext.cleanup, }); beforeEach(() => LitViteContext.cleanup());它把LitViteContext.render扩展为page.renderLit(...)(实现见 web/test/lit/rendering.js),并在每个测试前后自动执行 cleanup,避免组件实例泄漏。文档同时提醒:目前该渲染辅助还没有真实消费者,在添加第一个用例之前先与团队确认。
八、种子蓝图与测试环境准备
浏览器测试的认证与数据准备依赖 web/test/blueprints/test-admin-user.yaml——一个 version 1 的 authentik 蓝图,播种 bootstrap 管理员:
version: 1 entries: - attrs: email: test-admin@goauthentik.io is_active: true name: authentik Default Admin password: test-runner path: users type: internal groups: - !Find [authentik_core.group, [name, "authentik Admins"]] conditions: [] identifiers: username: akadmin model: authentik_core.user state: present该文件正是session.login()默认凭据(test-admin@goauthentik.io/test-runner)的来源。如需新的播种能力,规范要求扩展 fixture 或蓝图,而不是在测试内自建客户端——这与跨层通用规则一脉相承。
九、与 WebUI 架构的呼应
这套测试体系与 web/AGENTS.md 描述的 WebUI 架构严格对应:三个 UI 应用(Flow / User / Admin)共享 Config、CurrentTenant/Brand、SessionUser 三个核心上下文对象;组件分components/(依赖应用上下文)与elements/(可移植、不依赖上下文)两层。浏览器测试中的sessionfixture 正是围绕登录会话与权限上下文展开,formfixture 则感知ak-switch-input、ak-form-group等ak-前缀自定义元素——测试约定与组件前缀、Context API 的架构约束是配套设计的。
架构文档还强调了一条铁律,与"无自建 API 客户端"的测试规范互为表里:绝不允许用@goauthentik/api包之外的方式调用 authentik API,任何情况下都不得使用 Fetch、Axios 或其他方法——测试代码同样遵守这条约束。
十、总结:给贡献者的落地建议
在 authentik WebUI 仓库添加或修改测试时,按以下顺序操作即可避免大多数返工:
- 先读目标层级的约定文档(
unit/AGENTS.md或browser/AGENTS.md),确认被测对象属于该层; - 纯逻辑进
test/unit/(或就近*.unit.test.ts),用describe/it完整句子命名,走#alias导入,vi.fn()构造替身; - 用户流程进
test/browser/,找到对应特性套件追加用例,用#e2efixtures +test.step+ 带消息的expect,命名用IDGenerator.randomID(...)保证唯一; - 组件隔离行为就近写
Component.browser.test.ts,用page.renderLit(...)挂载; - 运行验证:本地先
npx vitest run test/unit/<file>快速迭代,浏览器测试则确保AK_TEST_RUNNER_PAGE_URL指向可用的 authentik 实例,最后用npm test全量通过。
遵循这套体系,测试既能覆盖真实用户路径、捕获向导与表单绑定层的回归,又能以纯 Node 的速度覆盖分支边界,是 authentik WebUI 长期可维护性的重要保障。
【免费下载链接】authentikThe authentication glue you need.项目地址: https://gitcode.com/GitHub_Trending/au/authentik
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考