Feathers CLI 客户端端到端测试:深入 `client.test` 生成文件与身份认证集成测试

原创2026-09-21 16:01:391,639 阅读
文章标签:后端Web框架

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 生成器在特定条件下自动渲染出来的。仓库中存在两个模板来源:

  1. 应用生成器的基础模板: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)
  })
})
  1. 认证生成器的完整模板: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 断言返回 401 NotAuthenticated;
  • 认证后可访问受保护服务: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——如果你在其中添加了自定义用例,请注意备份或在生成后重新合并。

参考文件索引

登录后查看全文
feathers