测试数据库逻辑:该测试什么、该跳过什么以及为何重要
Source: Dev.to
要为您提供准确的中文翻译,请把您想要翻译的文章正文粘贴在这里(保持原有的 Markdown 格式)。我会在保留代码块、链接和技术术语的前提下,将正文内容翻译成简体中文。谢谢!
什么是“数据库逻辑”
当开发者提到 database logic(数据库逻辑)时,通常指的不仅仅是 CRUD 操作。实际上它包括:
- 模型层规则(计算字段、状态转换)
- 数据库强制的约束(唯一索引、外键)
- 持久化触发的副作用(事件、观察者、作业)
- 迁移,在保证安全的前提下演进模式
- 查询,其中编码了业务假设
测试数据库逻辑 不是 在测试数据库引擎本身,而是要验证在真实数据参与时,应用程序的行为是否正确。
测试类型划分
最常见的错误之一是试图用 单元测试 来测试所有内容。纯单元测试固然很好,但当逻辑依赖于数据库时,它们就显得力不从心。
我建议将与数据库相关的测试划分为三类:
- 快速模型和查询测试 – 使用 SQLite 内存数据库或专用的测试数据库。
- 集成测试 – 用于验证关系和约束。
- 迁移测试 – 关注安全性,而非完美。
你 不必 在每个层面上测试所有内容;只需测试那些实际可能出现问题的部分。一个稳定的测试环境比测试代码本身更为重要。
Laravel的默认测试数据库
DB_CONNECTION=sqlite
DB_DATABASE=:memory:
这为您提供快速的反馈和干净的隔离。然而,需要注意一个重要的限制:SQLite 的行为与 MySQL/PostgreSQL 不同,尤其是在外键和 JSON 列方面。
如果您的生产逻辑严重依赖于特定数据库的行为,请考虑使用 Docker 或 CI 在相同的引擎上运行测试。
关键规则: 测试在 CI 中应因相同的原因而失败,就像在生产环境中一样。
1️⃣ 强制唯一性
迁移
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('email')->unique();
$table->timestamps();
});
测试(关注数据库,而非验证)
public function test_user_email_must_be_unique()
{
User::factory()->create([
'email' => 'test@example.com',
]);
$this->expectException(QueryException::class);
User::factory()->create([
'email' => 'test@example.com',
]);
}
为什么重要: 该测试断言一个 硬性保证——数据库永远不会允许重复的电子邮件——无论验证如何实现。这些测试成本低、速度快,在重构期间极其有价值。
2️⃣ 关系与级联规则
迁移
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete();
});
测试(行为驱动)
public function test_orders_are_deleted_when_user_is_deleted()
{
$user = User::factory()->create();
$order = Order::factory()->create(['user_id' => $user->id]);
$user->delete();
$this->assertDatabaseMissing('orders', [
'id' => $order->id,
]);
}
Why this matters: 这可以防止因意外更改外键或级联规则而导致的问题——这种情况发生的频率比人们承认的要高。
3️⃣ 避免 Mocking 反模式
一种常见的反模式是 对 Eloquent 模型或仓库进行 mock 来处理数据库逻辑。这通常会导致测试通过,而生产环境却出错。
如果逻辑依赖于:
- 数据库约束
- 事务行为
- 实际持久化的状态
……那么 不要对其进行 mock。
示例:测试事务性操作
DB::transaction(function () {
$order->markAsPaid();
$invoice->generate();
});
正确的测试(验证最终状态)
public function test_order_is_paid_and_invoice_is_created()
{
$order = Order::factory()->create();
$service = new OrderPaymentService();
$service->pay($order);
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'status' => 'paid',
]);
$this->assertDatabaseHas('invoices', [
'order_id' => $order->id,
]);
}
这种测试方式在重构时的存活率远高于使用 mock。
Source: …
4️⃣ 迁移测试 – 测试风险,而不是每一列
迁移测试常常被跳过或以不切实际的方式进行。你不需要测试每一列;你需要测试 风险。
迁移测试的良好候选项
- 数据转换
- 列重命名
- 回填的值
- 删除或收紧约束
示例:添加一个带默认值的非空列
迁移
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_active')->default(true);
});
测试
public function test_existing_users_are_active_after_migration()
{
$user = User::factory()->create([
'is_active' => null,
]);
$this->artisan('migrate');
$user->refresh();
$this->assertTrue($user->is_active);
}
为什么这很重要: 它可以防止一个非常真实的生产问题——由于无效的已有数据导致部署失败。
5️⃣ 加速数据库测试
数据库测试因慢而臭名昭著。在大多数项目中,这不是因为数据库本身,而是测试设计的问题。
实用规则
- 使用具有最小默认值的工厂。
- 避免不必要的种子数据。
- 尽可能使用事务重置数据库。
- 不要在十个不同的测试中重复同一约束。
速度不仅仅是便利;慢的测试会被忽视。
TL;DR
| 测试类型 | 目标 | 常用工具 |
|---|---|---|
| 快速模型/查询 | 验证基本查询和作用域 | SQLite 内存模式 |
| 集成 | 验证关系、约束和级联规则 | 真实数据库(MySQL/Postgres)或 Docker |
| 迁移 | 确保风险模式更改不会破坏现有数据 | Artisan 迁移 + 断言 |
通过这种方式构建数据库测试套件,您可以获得 快速反馈、高可信度 和 可维护的代码——这正是使用 Laravel 强大的 Eloquent ORM 时所需的。祝测试愉快!
测试哲学:不该测试的内容
被跳过的测试,比没有测试更糟。
应避免测试的内容
- Laravel 的内部 Eloquent 行为
- 数据库引擎的实现细节
- 框架提供的迁移文件
- 没有业务逻辑的简单 getter / setter
应关注的内容
- 业务保证,而非机械实现。
测试数据库逻辑和迁移并不是为了实现 100 % 的覆盖率,而是为了 降低恐惧感:
- 对重构的恐惧
- 对部署的恐惧
- 对触碰旧代码的恐惧
写得好的数据库测试充当 可执行的文档。它们告诉未来的你(或你的团队成员)哪些东西绝不能被破坏,即使代码库在不断演进。
要点
把数据库当作协作者来测试,而不是外部依赖。
采用这种思路将显著提升你的测试套件质量,也会增强对系统的信心。