Feathers CLI 客户端端到端测试:深入 `client.test` 生成文件与身份认证集成测试
Feathers CLI 客户端端到端测试:深入 client.test 生成文件与身份认证集成测试
导读
client.test 是 Feathers CLI 为生成的应用自动产出的端到端(End-to-End)集成测试文件,用于验证"通过生成客户端(createClient)发起的外部访问流程"是否工作正常——包括创建用户、登录认证、登出以及用户数据隐私保护。本文将基于 docs/guides/cli/client.test.md 展开,结合仓库中生成器模板(packages/generators)与认证客户端源码(packages/authentication-client),完整解析该文件的生成机制、每个测试断言背后的原理,以及 client(客户端)与 app(服务端)在同一测试文件中协作的实战技巧。读完本文,你将能读懂并独立维护、扩展 Feathers 生成应用中的客户端集成测试。
client.test 是什么:与其它测试文件的分工
在 Feathers CLI 生成的工程中,test/ 目录下会按职责拆分出多个测试文件,client.test 负责的是完整的外部访问链路:
| 测试文件 | 测试对象 | 侧重点 |
|---|---|---|
| app.test | 应用本身 | 启动服务器、首页响应、404 JSON 错误、setup/teardown 钩子 |
| service.test | 具体服务 | 服务是否注册、通过 app 对象直接调用服务方法 |
| client.test(本文主题) | 生成的客户端 | 通过 createClient 建立的客户端发起真实请求,覆盖认证与登出全流程 |
三者中,service.test 通过 app.service('users').create(...) 这种进程内直接调用来测试服务内部逻辑;而 client.test 则像真实前端应用一样,经由 HTTP 传输层(默认是 REST + axios)访问服务端,因此更适合验证 生成的客户端(即打包成 SDK 供 React、Vue、React Native 等外部应用使用的部分)与服务器之间的契约是否正确。正如 service.test 文档中的提示所指出的:如果需要测试完整的认证和外部访问流程,就应使用 client.test。
生成机制:谁创建了 client.test
client.test 并非手写的样板文件,而是由 Feathers CLI 生成器在特定条件下自动渲染出来的。仓库中存在两个模板来源:
- 应用生成器的基础模板:packages/generators/src/app/templates/client.test.tpl.ts。当生成应用时选择了客户端(
ctx.client为真),生成器就会渲染一个最小化的客户端测试,其内容只有"客户端已初始化"这一条断言:
import assert from 'assert'
import axios from 'axios'
import type { Server } from 'http'
import { app } from '../src/app'
import { createClient } from '../src/client'
import rest from '@feathersjs/rest-client'
const port = app.get('port')
const appUrl = `http://${app.get('host')}:${port}`
describe('client tests', () => {
const client = createClient(rest(appUrl).axios(axios))
it('initialized the client', () => {
assert.ok(client)
})
})
- 认证生成器的完整模板:packages/generators/src/authentication/templates/client.test.tpl.ts。当运行
feathers generate authentication时,生成器会先检查src/client.<ts|js>是否已存在(fileExists(lib, 'client.${language}')),存在则以force: true覆盖写入test/client.test,追加认证集成测试。因此实际工程中你看到的client.test往往是这个"增强版"。
从 packages/generators/src/authentication/index.ts 的交互式提示可以看到,认证策略是一个复选列表,其中 Email + Password(即 local 策略)默认勾选。只有当 authStrategies 包含 'local' 时,commons.ts 中的 localTemplate 辅助函数才会把"本地策略认证测试"渲染进模板——这解释了为何文档强调"如果选择了 local 策略"才会看到认证测试。
认证版 client.test 全解析
当选择 local 策略后,生成的 test/client.test.ts 会形如:
import assert from 'assert'
import axios from 'axios'
import rest from '@feathersjs/rest-client'
import authenticationClient from '@feathersjs/authentication-client'
import { app } from '../src/app'
import { createClient } from '../src/client'
import type { UserData } from '../src/client'
const port = app.get('port')
const appUrl = `http://${app.get('host')}:${port}`
describe('application client tests', () => {
const client = createClient(rest(appUrl).axios(axios))
before(async () => {
await app.listen(port)
})
after(async () => {
await app.teardown()
})
it('initialized the client', () => {
assert.ok(client)
})
it('creates and authenticates a user with email and password', async () => {
const userData: UserData = {
email: 'someone@example.com',
password: 'supersecret'
}
await client.service('users').create(userData)
const { user, accessToken } = await client.authenticate({
strategy: 'local',
...userData
})
assert.ok(accessToken, 'Created access token for user')
assert.ok(user, 'Includes user in authentication data')
assert.strictEqual(user.password, undefined, 'Password is hidden to clients')
await client.logout()
// Remove the test user on the server
await app.service('users').remove(user.id)
})
})
测试脚手架:before / after 与 REST 连接
与 service.test 直接 import { app } 不同,client.test 需要一条真实的 HTTP 链路,因此模板做了三件事:
- 从服务端应用配置中取出
port和host拼出appUrl(app.get('port')/app.get('host')来自 configuration 模块); - 在
before钩子中调用await app.listen(port)真正启动服务器,在after钩子中调用await app.teardown()关闭并清理; - 使用
rest(appUrl).axios(axios)构造 REST 传输连接,交给createClient。
其中 createClient 的实现来自 packages/generators/src/app/templates/client.tpl.ts:它内部依次执行 client.configure(connection) 挂载传输层、client.configure(authenticationClient(authenticationOptions)) 挂载认证客户端,并把连接存入 client.set('connection', connection),最终返回一个与服务器共享类型定义的 ClientApplication。这套机制与 客户端文档 描述的 Feathers 客户端架构一致,也意味着同样的 createClient 可以被真实前端项目复用。
流程一:通过客户端创建用户
await client.service('users').create(userData)
这里经由 REST 传输层向服务器发送 POST /users,触发服务端的 hash-password 钩子(见 packages/authentication-local/src/hooks/hash-password.ts)对密码进行哈希,再由服务端 protect 钩子(见 packages/authentication-local/src/hooks/protect.ts)在返回结果中剥离密码字段。这一步同时验证了"外部客户端能成功写入数据"以及"服务端钩子链路工作正常"。
流程二:登录并校验认证结果
const { user, accessToken } = await client.authenticate({
strategy: 'local',
...userData
})
client.authenticate 是 @feathersjs/authentication-client 的核心方法。从源码看,它实际调用认证服务 client.service('authentication').create(authentication)(对应 POST /authentication),成功后:
- 将
accessToken写入存储(setAccessToken); - 触发应用的
login与authenticated事件; - 把认证结果(含
user与accessToken)作为 Promise 缓存在应用状态中。
随后的三条断言分别验证了:
assert.ok(accessToken, 'Created access token for user') // JWT 已签发
assert.ok(user, 'Includes user in authentication data') // 认证结果携带用户信息
assert.strictEqual(user.password, undefined, 'Password is hidden to clients') // 密码已对客户端隐藏
第三条断言尤其关键:它从端到端层面验证了服务端 protect 钩子确实生效——即便创建用户时传入了密码,客户端拿到的 user 对象中 password 也必须是 undefined。这与仓库中 packages/authentication-client/test/integration/commons.ts 的集成测试(authenticates with local strategy 用例)互相印证,后者同样断言 result.user.email 正确且认证策略为 local。
流程三:登出与清理
await client.logout()
// Remove the test user on the server
await app.service('users').remove(user.id)
logout 同样来自 packages/authentication-client/src/core.ts:它调用 service.remove(null) 通知服务器失效,移除本地存储中的 access token,重置内部认证状态并触发 logout 事件。登出完成后,测试通过服务端 app 对象直接删除测试用户,保证测试自包含(self-contained)——不依赖外部数据库状态,多次运行也不会残留数据。
数据库差异:id 还是 _id
细心的读者会发现,模板在删除用户时使用了条件渲染:
await app.service('users').remove(user.${type === 'mongodb' ? '_id' : 'id'})
也就是说,当认证服务选择 MongoDB 作为数据库时,删除语句使用 user._id,其余数据库(SQLite、PostgreSQL、MySQL 等,均走 knex 适配器)则使用 user.id。这是由生成上下文中的 type 字段驱动的,也是阅读生成代码时最常见的"分支差异"来源之一。
双对象协作:client 与 app 在同一文件中共存
client.test 最实用的设计在于:同一个测试文件中可以同时使用客户端对象与服务端应用对象。
client:模拟外部前端,所有调用都经过真实传输层(REST / Socket.io),用于验证对外契约;app:直接引用服务端应用实例(来自 docs/guides/cli/app.md,本质上是一个 Node 模块,可被任意导入),用于快速完成服务端侧的准备工作或清理工作。
文档给出了一个典型场景:如果用户需要邮箱验证,但你并不想在测试中真的发送邮件,可以直接在服务端把用户标记为已验证:
await app.service('users').patch(user.id, { isVerified: true })
这样既绕开了外部副作用(邮件发送),又让后续的登录认证测试得以继续。同理,app 对象也可以用于构造额外的测试数据、清理残留记录、或直接调用服务方法验证中间状态。
与仓库集成测试的互相印证
client.test 覆盖的认证链路并非孤立存在——仓库中 packages/authentication-client/test/integration/commons.ts 提供了与生成模板同构的集成测试套件(分别通过 Express 与 Socket.io 传输层运行,见 integration/express.test.ts 与 integration/socketio.test.ts),其中多个用例可以视为 client.test 的"超集":
- 错误凭据认证失败:
authentication with wrong credentials fails, does not maintain state断言错误为NotAuthenticated/Invalid login,且失败后客户端状态被重置(client.get('authentication')为null)——这是 core.ts 中handleError对 4xx 错误的处理结果; - 未认证访问被拒:
errors when not authenticated断言返回 401NotAuthenticated; - 认证后可访问受保护服务:
authenticates and allows access验证认证后请求会携带provider、authentication与user信息; - 登出后禁止访问:
after logout does not allow subsequent access验证logout之后再次访问会重新得到 401。
这些用例证明:生成模板中的 authenticate / logout 调用,其底层行为(token 存储、状态重置、错误归一化)都有完整的源码与测试支撑。如果你需要在生成模板基础上扩展更多场景(如刷新令牌、OAuth 重认证、并发请求下的认证状态),直接参照这份集成测试是最高效的路径。
运行与维护建议
- 在生成的应用工程中,
client.test与其它测试一样通过npm test或 Mocha 运行(生成模板使用describe/it/before/after的 Mocha 风格,同时依赖assert与axios,这两者已作为 devDependencies 写入工程的package.json模板); - 由于测试会真实启动服务器(
app.listen(port)),请确保port未被占用;port与host来自应用配置,可通过环境变量覆盖; - 新增认证策略(如 OAuth)时,认证生成器只会为
local策略注入认证测试;OAuth 流程涉及浏览器跳转与回调,更适合参照 认证集成测试 单独设计模拟用例; - 修改生成模板后,重新运行
feathers generate authentication会以force: true覆盖client.test——如果你在其中添加了自定义用例,请注意备份或在生成后重新合并。
参考文件索引
- 本文主题文档:docs/guides/cli/client.test.md
- 客户端使用指南:docs/guides/cli/client.md
- 应用与
app对象:docs/guides/cli/app.md - 生成模板(基础版):packages/generators/src/app/templates/client.test.tpl.ts
- 生成模板(认证版):packages/generators/src/authentication/templates/client.test.tpl.ts
- 客户端工厂实现:packages/generators/src/app/templates/client.tpl.ts
- 认证客户端运行时:packages/authentication-client/src/core.ts
- 认证集成测试参照:packages/authentication-client/test/integration/commons.ts