测试数据库逻辑:该测试什么、该跳过什么以及为何重要

发布: (2026年1月6日 GMT+8 23:00)
8 min read
原文: Dev.to

Source: Dev.to

要为您提供准确的中文翻译,请把您想要翻译的文章正文粘贴在这里(保持原有的 Markdown 格式)。我会在保留代码块、链接和技术术语的前提下,将正文内容翻译成简体中文。谢谢!

什么是“数据库逻辑”

当开发者提到 database logic(数据库逻辑)时,通常指的不仅仅是 CRUD 操作。实际上它包括:

  • 模型层规则(计算字段、状态转换)
  • 数据库强制的约束(唯一索引、外键)
  • 持久化触发的副作用(事件、观察者、作业)
  • 迁移,在保证安全的前提下演进模式
  • 查询,其中编码了业务假设

测试数据库逻辑 不是 在测试数据库引擎本身,而是要验证在真实数据参与时,应用程序的行为是否正确。

测试类型划分

最常见的错误之一是试图用 单元测试 来测试所有内容。纯单元测试固然很好,但当逻辑依赖于数据库时,它们就显得力不从心。

我建议将与数据库相关的测试划分为三类:

  1. 快速模型和查询测试 – 使用 SQLite 内存数据库或专用的测试数据库。
  2. 集成测试 – 用于验证关系和约束。
  3. 迁移测试 – 关注安全性,而非完美。

不必 在每个层面上测试所有内容;只需测试那些实际可能出现问题的部分。一个稳定的测试环境比测试代码本身更为重要。

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 % 的覆盖率,而是为了 降低恐惧感

  • 对重构的恐惧
  • 对部署的恐惧
  • 对触碰旧代码的恐惧

写得好的数据库测试充当 可执行的文档。它们告诉未来的你(或你的团队成员)哪些东西绝不能被破坏,即使代码库在不断演进。

要点

把数据库当作协作者来测试,而不是外部依赖。

采用这种思路将显著提升你的测试套件质量,也会增强对系统的信心。

Back to Blog

相关文章

阅读更多 »

使用 DataBlock 探索真实世界 API

比较 Symfony 和 Laravel 使用 GitHub 与 Packagist 数据 在第一篇文章《Handling Nested PHP Arrays Using DataBlock》中,我们探索了 DataBlock 与一个 s...