测试替身

本指南涵盖 AdonisJS 应用中的测试替身(test doubles)。你将学习如何:

  • 使用针对 Mail、Hash、Emitter 和 Drive 服务的内置 fakes
  • 使用 swapuseFake 替换容器绑定以 fake 掉依赖
  • 在测试时间敏感的代码时冻结(freeze)和穿越(travel)时间
  • 集成 Sinon.js 以满足额外的桩(stub)和模拟(mock)需求

概述

测试替身在测试期间用受控的替代品替换真实的实现。它们让你能够隔离被测代码、避免诸如发送真实邮件之类的副作用,并验证你的代码与其依赖项之间的交互是否正确。

AdonisJS 对测试替身采取了务实的方法。对于数据库查询等内部操作,我们建议直接命中真实数据库,而不是模拟查询方法。真实的数据库交互能捕获模拟会遗漏的问题,例如约束冲突、查询语法错误或迁移问题。然而,对于邮件提供商、支付网关或第三方 API 等外部服务,fakes 可以防止不必要的副作用,并使测试更快、更可靠。

该框架为通常与外部系统交互的常见服务提供了内置 fakes,同时还提供了用于替换你自身依赖的容器替换功能。对于这些工具未覆盖的边缘情况,你可以集成 Sinon.js 等库。

内置 fakes

AdonisJS 为通常与外部系统交互的服务提供了 fake 实现。每个 fake 都会拦截对真实服务的调用,并捕获它们以供断言。

所有内置 fakes 都通过 using 关键字支持 显式资源管理(Explicit Resource Management) 。当变量超出作用域(在测试函数结束时),fake 会被自动恢复。如果你希望手动控制,仍然可以直接调用 .restore(),或使用 cleanup 钩子。

Emitter fake

Emitter fake 会阻止事件监听器执行,同时捕获发出的事件以供断言。当你测试会发出事件、但不想触发诸如发送通知或更新外部系统等副作用的代码时,这很有用。

tests/functional/users/register.spec.ts
import { test } from '@japa/runner'
import emitter from '@adonisjs/core/services/emitter'
import { events } from '#generated/events'

test.group('User registration', () => {
  test('emits registration event on signup', async ({ client }) => {
    /**
     * Fake 掉 emitter 以捕获事件,
     * 而不执行监听器。`using` 关键字会在
     * 测试结束时自动恢复 emitter。
     */
    using fakeEmitter = emitter.fake()

    await client.post('/signup').form({
      email: '[email protected]',
      password: 'secret123',
    })

    /**
     * 断言事件已被发出
     */
    fakeEmitter.assertEmitted(events.UserRegistered)
  })
})

你可以 fake 特定的事件,同时让其他事件正常运行,只需将事件名称或类传递给 fake 方法。

// 只 fake 这些事件,让其他事件正常运行
emitter.fake([events.UserRegistered, events.OrderUpdated])

emitter.fake() 返回的 EventBuffer 提供了多个断言方法。

MethodDescription
assertEmitted(event)断言某个事件已被发出
assertNotEmitted(event)断言某个事件未被发出
assertEmittedCount(event, count)断言某个事件被发出了特定次数
assertNoneEmitted()断言没有发出任何事件

对于条件断言,向 assertEmitted 传入一个回调函数,该函数接收事件数据,并在事件符合你的条件时返回 true

tests/functional/orders/update.spec.ts
fakeEmitter.assertEmitted(events.OrderUpdated, ({ data }) => {
  return data.order.id === orderId
})

另请参阅: Events

Hash fake

Hash fake 用一个快速、不执行任何实际哈希运算的替代实现替换了真实的哈希实现。像 bcrypt 和 argon2 这样的密码哈希算法出于安全考虑故意设计得很慢,但这会显著拖慢创建大量用户的测试套件。

tests/functional/users/list.spec.ts
import { test } from '@japa/runner'
import hash from '@adonisjs/core/services/hash'
import { UserFactory } from '#database/factories/user_factory'

test.group('Users list', () => {
  test('paginates users correctly', async ({ client }) => {
    /**
     * Fake 掉 hash 服务,使创建用户瞬时完成。
     * 否则,使用 bcrypt 创建 50 个用户大约需要 ~5 秒。
     * `using` 关键字会在测试结束时
     * 自动恢复真实的实现。
     */
    using _hash = hash.fake()

    await UserFactory.createMany(50)

    const response = await client.get('/users')
    response.assertStatus(200)
  })
})

Fake 会存储明文并直接比较字符串。它只应用于密码哈希并非你所测试重点的测试中。

另请参阅: Hashing

Mail fake

Mail fake 会拦截所有邮件并捕获它们以供断言。这能防止你的测试发送真实邮件,同时又能让你验证应当发送的邮件是否正确。

tests/functional/users/register.spec.ts
import { test } from '@japa/runner'
import mail from '@adonisjs/mail/services/main'
import VerifyEmailNotification from '#mails/verify_email'

test.group('User registration', () => {
  test('sends verification email on signup', async ({ client }) => {
    /**
     * Fake 掉 mailer。`using` 关键字会在
     * 测试结束时自动恢复真实的 mailer。
     */
    using fake = mail.fake()

    await client.post('/register').form({ email: '[email protected]', password: 'secret123' })

    /**
     * 断言邮件以正确的收件人和主题发送
     */
    fake.mails.assertSent(VerifyEmailNotification, ({ message }) => {
      return message.hasTo('[email protected]').hasSubject('Please verify your email address')
    })
  })

  test('does not send reset email for unknown user', async ({ client }) => {
    using fake = mail.fake()

    await client.post('/forgot-password').form({ email: '[email protected]' })

    fake.mails.assertNotSent(PasswordResetNotification)
  })
})

mails 对象提供了针对已发送和已排队(queued)邮件的断言方法。

MethodDescription
assertSent(Mail, finder?)断言某个邮件类已被发送
assertNotSent(Mail, finder?)断言某个邮件类未被发送
assertSentCount(count)断言已发送邮件的总数
assertSentCount(Mail, count)断言某个特定邮件类所发送的数量
assertNoneSent()断言没有发送任何邮件
assertQueued(Mail, finder?)断言某个邮件已通过 sendLater 排队
assertNotQueued(Mail, finder?)断言某个邮件未被排队
assertQueuedCount(count)断言已排队邮件的总数
assertNoneQueued()断言没有邮件被排队

你也可以通过在不发送的情况下构建邮件类来单独测试邮件。

tests/unit/mails/verify_email.spec.ts
import { test } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'
import VerifyEmailNotification from '#mails/verify_email'

test.group('VerifyEmailNotification', () => {
  test('builds correct message', async () => {
    const user = await UserFactory.create()
    const email = new VerifyEmailNotification(user)

    /**
     * 构建消息并渲染模板,而不发送
     */
    await email.buildWithContents()

    email.message.assertTo(user.email)
    email.message.assertFrom('[email protected]')
    email.message.assertSubject('Please verify your email address')
    email.message.assertHtmlIncludes(`Hello ${user.name}`)
  })
})

另请参阅: Mail

Drive fake

Drive fake 用一个本地文件系统实现替换了某个磁盘。文件被写入 ./tmp/drive-fakes,并在你恢复 fake 时自动删除。

tests/functional/users/update.spec.ts
import { test } from '@japa/runner'
import drive from '@adonisjs/drive/services/main'
import fileGenerator from '@poppinss/file-generator'
import { UserFactory } from '#database/factories/user_factory'

test.group('User avatar upload', () => {
  test('uploads avatar to storage', async ({ client }) => {
    /**
     * Fake 掉 spaces 磁盘,避免上传到真实的 S3。
     * `using` 关键字会在测试结束时
     * 自动恢复真实的磁盘。
     */
    using fakeDisk = drive.fake('spaces')

    const user = await UserFactory.create()

    /**
     * 生成一个 1mb 的假 PNG 文件
     */
    const { contents, mime, name } = await fileGenerator.generatePng('1mb')

    await client
      .put('/me')
      .file('avatar', contents, { filename: name, contentType: mime })
      .loginAs(user)

    /**
     * 断言文件已被存储
     */
    fakeDisk.assertExists(user.avatar)
  })
})

另请参阅: Drive

容器替换

当使用依赖注入时,你可以替换容器绑定,用 fake 实现来替换服务。这对于 fake 掉你自己的服务(例如支付网关或外部 API 客户端)很有用。

推荐的做法是创建一个专用的 fake 实现,它继承或实现与真实服务相同的接口。

app/services/payment_gateway.ts
export default class PaymentGateway {
  async charge(amount: number, token: string): Promise<ChargeResult> {
    /**
     * 调用 Stripe、Braintree 等的真实实现
     */
  }

  async refund(chargeId: string): Promise<RefundResult> {
    /**
     * 真实实现
     */
  }
}
app/services/fake_payment_gateway.ts
import PaymentGateway from './payment_gateway.js'

export default class FakePaymentGateway extends PaymentGateway {
  /**
   * 存储收费记录以供断言
   */
  charges: Array<{ amount: number; token: string }> = []

  async charge(amount: number, token: string): Promise<ChargeResult> {
    this.charges.push({ amount, token })

    return {
      id: 'fake_charge_123',
      status: 'succeeded',
    }
  }

  async refund(chargeId: string): Promise<RefundResult> {
    return {
      id: 'fake_refund_123',
      status: 'succeeded',
    }
  }

  /**
   * 用于断言已发生收费的辅助方法
   */
  assertCharged(amount: number) {
    const charge = this.charges.find((c) => c.amount === amount)
    if (!charge) {
      throw new Error(`Expected charge of ${amount} but none found`)
    }
  }
}

在测试中替换绑定

测试上下文提供了一个 swap 方法,用于在测试期间用 fake 替换某个容器绑定。原始的绑定会在测试完成后自动恢复,因此你无需自己管理清理工作。

tests/functional/orders/checkout.spec.ts
import { test } from '@japa/runner'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'

test.group('Checkout', () => {
  test('charges the customer on checkout', async ({ client, swap }) => {
    /**
     * 用 fake 替换真实的支付网关。
     * 原始绑定会在测试结束时恢复。
     */
    const fakePayment = swap(PaymentGateway, new FakePaymentGateway())

    await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })

    fakePayment.assertCharged(9999)
  })
})

swap 方法既接受直接的实例,也接受工厂函数。当你每次解析该绑定时都需要一个新实例时,请使用工厂函数。

tests/functional/orders/checkout.spec.ts
// 传入工厂函数而非实例
swap(PaymentGateway, () => new FakePaymentGateway())

useFake 辅助方法

useFake 辅助方法提供了与 swap 相同的功能,但作为一个独立函数。当你想将替换逻辑提取到测试回调之外的可复用测试辅助方法时,这很有用。

tests/helpers/fakes.ts
import { useFake } from '@japa/plugin-adonisjs/helpers'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'

/**
 * 可复用的辅助方法,替换支付网关
 * 并返回 fake 以供断言
 */
export function useFakePaymentGateway() {
  return useFake(PaymentGateway, new FakePaymentGateway())
}
tests/functional/orders/checkout.spec.ts
import { test } from '@japa/runner'
import { useFakePaymentGateway } from '#tests/helpers/fakes'

test.group('Checkout', () => {
  test('charges the customer on checkout', async ({ client }) => {
    const fakePayment = useFakePaymentGateway()

    await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })

    fakePayment.assertCharged(9999)
  })
})

swap 一样,useFake 会在测试完成时自动恢复原始绑定。无论是 swap 还是 useFake,都只能在运行中的 Japa 测试内部调用。

使用 container.swap 手动替换

你也可以直接在应用容器上调用 container.swapcontainer.restore。这让你可以完全控制绑定的恢复时机,在组级别的设置钩子中,或当你需要在测试结束之前恢复某个绑定时,这会很有用。

tests/functional/orders/checkout.spec.ts
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import PaymentGateway from '#services/payment_gateway'
import FakePaymentGateway from '#services/fake_payment_gateway'

test.group('Checkout', () => {
  test('charges the customer on checkout', async ({ client, cleanup }) => {
    const fakePayment = new FakePaymentGateway()

    /**
     * 替换绑定并手动注册清理
     */
    app.container.swap(PaymentGateway, () => fakePayment)
    cleanup(() => app.container.restore(PaymentGateway))

    await client.post('/checkout').json({ cartId: 'cart_123', paymentToken: 'tok_visa' })

    fakePayment.assertCharged(9999)
  })
})

另请参阅: Dependency injection

时间工具

Japa 提供了在测试期间控制时间的工具。freezeTimetimeTravel 都会模拟 new Date()Date.now(),并在测试完成后自动恢复真实的实现。

冻结时间

freezeTime 函数将时间锁定在特定的时刻。这在测试检查时间戳的代码(例如令牌过期)时很有用。

tests/functional/auth/token.spec.ts
import { test } from '@japa/runner'
import { freezeTime } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'

test.group('Token expiration', () => {
  test('rejects expired tokens', async ({ client }) => {
    const user = await UserFactory.create()

    /**
     * 以当前时间创建一个令牌
     */
    const token = await user.createToken()

    /**
     * 将时间冻结到未来 2 小时,已超出令牌
     * 1 小时的过期窗口
     */
    const futureDate = new Date(Date.now() + 2 * 60 * 60 * 1000)
    freezeTime(futureDate)

    const response = await client.get('/protected').header('Authorization', `Bearer ${token.value}`)

    response.assertStatus(401)
  })
})

穿越时间

timeTravel 函数将时间向前移动一段持续时间。你可以传入一个人类可读的字符串表达式或一个 Date 对象。

tests/functional/subscriptions/expiry.spec.ts
import { test } from '@japa/runner'
import { timeTravel } from '@japa/runner'
import { UserFactory } from '#database/factories/user_factory'

test.group('Subscription expiry', () => {
  test('marks subscription as expired after 30 days', async ({ client }) => {
    const user = await UserFactory.with('subscription', 1, (s) =>
      s.merge({ startsAt: new Date() })
    ).create()

    /**
     * 穿越到未来 31 天
     */
    timeTravel('31 days')

    const response = await client.get('/subscription').loginAs(user)

    response.assertStatus(200)
    response.assertBodyContains({ status: 'expired' })
  })
})

这两个工具都只模拟 Date 对象。它们不会影响像 setTimeoutsetInterval 这样的定时器。

Sinon.js

对于内置 fakes 未覆盖的桩和模拟需求,你可以使用 Sinon.js 。将其作为开发依赖安装。

npm install -D sinon @types/sinon

Sinon 提供了桩、间谍(spy)和模拟(mock),用于对函数行为进行细粒度控制。测试后请务必调用 sinon.restore() 进行清理。

tests/functional/reports/generate.spec.ts
import { test } from '@japa/runner'
import sinon from 'sinon'
import ReportService from '#services/report_service'

test.group('Report generation', (group) => {
  group.each.teardown(() => {
    sinon.restore()
  })

  test('retries on temporary failure', async ({ client }) => {
    const stub = sinon.stub(ReportService.prototype, 'generate')
    stub.onFirstCall().rejects(new Error('Temporary failure'))
    stub.onSecondCall().resolves({ id: 'report_123' })

    const response = await client.post('/reports')

    response.assertStatus(200)
    sinon.assert.calledTwice(stub)
  })
})

有关桩、间谍、模拟和假定时器的完整文档,请参阅 Sinon.js 文档