API 测试
本指南涵盖在 AdonisJS 应用中测试 JSON API 端点。你将学习如何:
- 配置 API 客户端及相关插件
- 使用路由名称为 API 端点编写测试
- 使用请求发送 JSON 和表单数据
- 在测试期间处理 cookie 和会话
- 使用会话或访问令牌对用户进行身份验证
- 调试请求与响应
- 对响应状态码、响应体、响应头等进行断言
概述
AdonisJS 中的 API 测试使用 Japa 的 API 客户端 来对你的应用发起真实的 HTTP 请求。与模拟或仿真的请求不同,API 客户端会启动你的 AdonisJS 服务器,并从外部发起真实的网络请求。这种方式测试了你的整个 HTTP 层——路由、中间件、控制器和响应——与生产环境中的实际行为完全一致。
API 客户端通过专用插件与 AdonisJS 的会话和身份验证等特性集成,使得测试受保护的端点和有状态的(stateful)交互变得简单直接。
配置
api starter kit 在 tests/bootstrap.ts 文件中预配置了三个插件。
import { apiClient } from '@japa/api-client'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
declare module '@japa/api-client/types' {
interface RoutesRegistry extends Registry {}
}
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
/**
* 配置 Japa 的 API 客户端以发起 HTTP 请求
*/
apiClient(),
/**
* 添加在请求期间读写会话数据的支持
*/
sessionApiClient(app),
/**
* 添加在请求期间对用户进行身份验证的支持
*/
authApiClient(app),
]
在测试中使用会话时,必须在你的 .env.test 文件中将会话驱动设置为 memory。starter kit 默认已配置好这一点。
SESSION_DRIVER=memory
编写你的第一个测试
我们来测试一个验证输入并创建新用户的账户创建端点。我们将编写两个测试:一个用于验证错误,一个用于成功创建。
该路由定义在 start/routes.ts 中。
router.post('signup', [controllers.NewAccount, 'store'])
第一个测试验证当必填字段缺失时返回验证错误。client.visit() 方法接受路由名称,并从你的路由定义中自动确定 HTTP 方法和 URL 模式。
import { test } from '@japa/runner'
test.group('Auth signup', () => {
test('return error when required fields are not provided', async ({ client }) => {
/**
* 向 signup 路由发起 POST 请求。
* 由于没有发送任何数据,验证应当失败。
*/
const response = await client.visit('new_account.store')
response.assertStatus(422)
response.assertBodyContains({
errors: [
{
field: 'fullName',
message: 'The fullName field must be defined',
rule: 'required',
},
{
field: 'email',
message: 'The email field must be defined',
rule: 'required',
},
{
field: 'password',
message: 'The password field must be defined',
rule: 'required',
},
{
field: 'passwordConfirmation',
message: 'The passwordConfirmation field must be defined',
rule: 'required',
},
],
})
})
})
第二个测试发送有效数据并验证用户已创建。你可以直接在测试中查询数据库来验证副作用。
import { test } from '@japa/runner'
import User from '#models/user'
test.group('Auth signup', () => {
test('create user account', async ({ client, assert }) => {
/**
* 使用链式 .json() 方法发送 JSON 数据
*/
const response = await client.visit('new_account.store').json({
fullName: 'John doe',
email: '[email protected]',
password: 'secret@123A',
passwordConfirmation: 'secret@123A',
})
response.assertStatus(200)
response.assertBodyContains({
data: {
fullName: 'John doe',
email: '[email protected]',
},
})
/**
* 验证用户已持久化到数据库
*/
const user = await User.findOrFail(response.body().data.id)
assert.equal(user.email, '[email protected]')
})
})
清理数据库状态
创建数据库记录的测试需要在两次运行之间进行清理,以确保隔离性。testUtils.db().truncate() 钩子会迁移数据库,并在每个测试之后截断所有表。
另请参阅: 数据库测试工具 ,了解更多方法,如迁移和种子数据(seeders)。
import { test } from '@japa/runner'
import testUtils from '@adonisjs/core/services/test_utils'
test.group('Auth signup', (group) => {
/**
* 在每个测试之后截断表,以确保
* 下一个测试拥有干净的状态
*/
group.each.setup(() => {
return testUtils.db().truncate()
})
test('create user account', async ({ client, assert }) => {
// ...
})
})
发起请求
API 客户端提供了两种发起 HTTP 请求的方式:使用路由名称或显式的 HTTP 方法。
使用路由名称
client.visit() 方法接受路由名称,并从你的路由器中查找 HTTP 方法和 URL 模式。这使你的测试与路由变更保持同步,并在测试内部提供类型安全。
const response = await client.visit('posts.store')
使用 HTTP 方法
当你需要直接访问特定的 URL 时,使用显式的 HTTP 方法函数。
const response = await client.get('/api/posts')
const response = await client.post('/api/posts')
const response = await client.put('/api/posts/1')
const response = await client.patch('/api/posts/1')
const response = await client.delete('/api/posts/1')
发送请求数据
JSON 数据
使用 json() 方法发送 JSON 负载。Content-Type 头会自动设置。
const response = await client.visit('posts.store').json({
title: 'Hello World',
content: 'This is my first post',
})
表单数据
使用 form() 方法发送 URL 编码的表单数据。
const response = await client.visit('posts.store').form({
title: 'Hello World',
content: 'This is my first post',
})
多部分数据
使用 field() 方法发送多部分表单字段。
const response = await client
.visit('posts.store')
.field('title', 'Hello World')
.field('content', 'This is my first post')
Cookie
你可以使用 withCookie() 方法及其变体在发出的请求上设置 cookie。
/**
* 设置一个常规 cookie
*/
const response = await client
.visit('checkout.store')
.withCookie('affiliateId', '1')
/**
* 设置一个加密 cookie(使用 AdonisJS 加密)
*/
const response = await client
.visit('checkout.store')
.withEncryptedCookie('affiliateId', '1')
/**
* 设置一个明文 cookie(不进行签名或加密)
*/
const response = await client
.visit('checkout.store')
.withPlainCookie('affiliateId', '1')
会话
withSession() 方法在发起请求之前填充会话存储。这对于测试依赖既有会话状态的工作流很有用。
const response = await client
.visit('checkout.store')
.withSession({ cartId: 1 })
身份验证
会话身份验证
loginAs() 方法使用你的默认身份验证守卫(auth guard)为该请求对用户进行身份验证。你必须在发起经过身份验证的请求之前创建该用户。
test('create a post', async ({ client }) => {
const user = await User.create({
fullName: 'John',
email: '[email protected]',
password: 'secret',
})
const response = await client.visit('posts.store').loginAs(user)
response.assertStatus(200)
})
令牌身份验证
当使用访问令牌或不同的 auth guard 时,在 loginAs() 之前链式调用 withGuard() 方法来指定要使用的守卫。
test('create a post via API', async ({ client }) => {
const user = await User.create({
fullName: 'John',
email: '[email protected]',
password: 'secret',
})
/**
* 使用 'api' 守卫进行基于令牌的身份验证
*/
const response = await client
.visit('posts.store')
.withGuard('api')
.loginAs(user)
response.assertStatus(200)
})
确保你的路由中间件允许使用指定的守卫进行身份验证。
router
.group(() => {
router.post('posts', [controllers.Posts, 'store'])
})
.use(middleware.auth({ guards: ['web', 'api'] }))
调试
转储请求
在构建请求时链式调用 dump() 方法,可在请求发送之前记录请求详情。
const response = await client
.visit('posts.store')
.dump()
.json({ title: 'Hello World' })
转储响应
响应对象提供了检查返回内容的方法。
const response = await client.visit('posts.index')
/**
* 转储整个响应(状态码、响应头、响应体)
*/
response.dump()
/**
* 仅转储响应体
*/
response.dumpBody()
/**
* 仅转储响应头
*/
response.dumpHeaders()
检查服务器错误
使用 hasFatalError() 检查服务器是否返回了 500 级别的错误。
const response = await client.visit('posts.store').json(data)
if (response.hasFatalError()) {
response.dump()
}
断言参考
响应对象提供了断言方法,用于验证状态码、响应体内容、响应头、cookie 和会话数据。
状态码与响应体断言
| Method | Description |
|---|---|
assertStatus(status) | 断言响应状态码与期望值匹配 |
assertBody(body) | 断言响应体精确匹配期望值 |
assertBodyContains(subset) | 断言响应体包含期望的子集 |
assertBodyNotContains(subset) | 断言响应体不包含该子集 |
assertTextIncludes(text) | 断言响应文本包含该子串 |
响应头断言
| Method | Description |
|---|---|
assertHeader(name, value?) | 断言某个响应头存在,可选检查其值 |
assertHeaderMissing(name) | 断言某个响应头不存在 |
Cookie 断言
| Method | Description |
|---|---|
assertCookie(name, value?) | 断言某个 cookie 存在,可选检查其值 |
assertCookieMissing(name) | 断言某个 cookie 不存在 |
重定向断言
| Method | Description |
|---|---|
assertRedirectsTo(pathname) | 断言响应重定向到给定的路径名 |
会话断言
| Method | Description |
|---|---|
assertSession(key, value?) | 断言某个会话键存在,可选检查其值 |
assertSessionMissing(key) | 断言某个键在会话存储中缺失 |
assertFlashMessage(key, value?) | 断言存在闪现消息(flash message),可选检查其值 |
assertFlashMissing(key) | 断言某个键在闪现消息中缺失 |
验证错误断言
| Method | Description |
|---|---|
assertHasValidationError(field) | 断言闪现消息包含该字段的验证错误 |
assertDoesNotHaveValidationError(field) | 断言闪现消息不包含该字段的验证错误 |
assertValidationError(field, message) | 断言该字段存在特定的错误消息 |
assertValidationErrors(field, messages) | 断言该字段的所有错误消息 |
OpenAPI 断言
| Method | Description |
|---|---|
assertAgainstApiSpec() | 断言响应体根据你的 OpenAPI 规范是有效的 |
另请参阅: Japa API Client 文档