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 文件中预配置了三个插件。

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 默认已配置好这一点。

.env.test
SESSION_DRIVER=memory

编写你的第一个测试

我们来测试一个验证输入并创建新用户的账户创建端点。我们将编写两个测试:一个用于验证错误,一个用于成功创建。

该路由定义在 start/routes.ts 中。

start/routes.ts
router.post('signup', [controllers.NewAccount, 'store'])

第一个测试验证当必填字段缺失时返回验证错误。client.visit() 方法接受路由名称,并从你的路由定义中自动确定 HTTP 方法和 URL 模式。

tests/functional/auth/signup.spec.ts
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',
        },
      ],
    })
  })
})

第二个测试发送有效数据并验证用户已创建。你可以直接在测试中查询数据库来验证副作用。

tests/functional/auth/signup.spec.ts
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: 'john@example.com',
      password: 'secret@123A',
      passwordConfirmation: 'secret@123A',
    })

    response.assertStatus(200)
    response.assertBodyContains({
      data: {
        fullName: 'John doe',
        email: 'john@example.com',
      },
    })

    /**
     * 验证用户已持久化到数据库
     */
    const user = await User.findOrFail(response.body().data.id)
    assert.equal(user.email, 'john@example.com')
  })
})

清理数据库状态

创建数据库记录的测试需要在两次运行之间进行清理,以确保隔离性。testUtils.db().truncate() 钩子会迁移数据库,并在每个测试之后截断所有表。

另请参阅: 数据库测试工具 ,了解更多方法,如迁移和种子数据(seeders)。

tests/functional/auth/signup.spec.ts
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')

你可以使用 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: 'john@example.com',
    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: 'john@example.com',
    password: 'secret',
  })

  /**
   * 使用 'api' 守卫进行基于令牌的身份验证
   */
  const response = await client
    .visit('posts.store')
    .withGuard('api')
    .loginAs(user)

  response.assertStatus(200)
})

确保你的路由中间件允许使用指定的守卫进行身份验证。

start/routes.ts
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 和会话数据。

状态码与响应体断言

MethodDescription
assertStatus(status)断言响应状态码与期望值匹配
assertBody(body)断言响应体精确匹配期望值
assertBodyContains(subset)断言响应体包含期望的子集
assertBodyNotContains(subset)断言响应体不包含该子集
assertTextIncludes(text)断言响应文本包含该子串

响应头断言

MethodDescription
assertHeader(name, value?)断言某个响应头存在,可选检查其值
assertHeaderMissing(name)断言某个响应头不存在
MethodDescription
assertCookie(name, value?)断言某个 cookie 存在,可选检查其值
assertCookieMissing(name)断言某个 cookie 不存在

重定向断言

MethodDescription
assertRedirectsTo(pathname)断言响应重定向到给定的路径名

会话断言

MethodDescription
assertSession(key, value?)断言某个会话键存在,可选检查其值
assertSessionMissing(key)断言某个键在会话存储中缺失
assertFlashMessage(key, value?)断言存在闪现消息(flash message),可选检查其值
assertFlashMissing(key)断言某个键在闪现消息中缺失

验证错误断言

MethodDescription
assertHasValidationError(field)断言闪现消息包含该字段的验证错误
assertDoesNotHaveValidationError(field)断言闪现消息不包含该字段的验证错误
assertValidationError(field, message)断言该字段存在特定的错误消息
assertValidationErrors(field, messages)断言该字段的所有错误消息

OpenAPI 断言

MethodDescription
assertAgainstApiSpec()断言响应体根据你的 OpenAPI 规范是有效的

另请参阅: Japa API Client 文档