数据库测试


简介

Laravel 提供了多个有用的工具让测试数据库驱动的应用变得更加简单。首先,你可以使用辅助函数 assertDatabaseHas 来断言数据库中的数据是否和给定数据集合匹配。例如,如果你想要通过 email 值为 test@xueyuanjun.com 的条件去数据表 users 查询是否存在该记录 ,我们可以这样做:

public function testDatabase()
{
    // Make call to application...
    
    $this->assertDatabaseHas('users', [
        'email' => 'test@xueyuanjun.com'
    ]);
}

你还可以使用 assertDatabaseMissing 辅助函数断言数据在数据库中不存在。

当然,assertDatabaseHas 方法和其它类似辅助方法都是为了方便起见进行的封装,你也可以使用其它 PHPUnit 内置的断言方法来进行测试。

每次测试后重置数据库

每次测试后重置数据库通常很有用,这样的话上次测试的数据不会影响下一次测试。RefreshDatabase Trait 基于你是用的是内存数据库还是关系数据库使用最优方式来迁移测试数据库。在测试类上使用这个 Trait,一切都不需要操心,系统会自动帮你在每次测试后重置数据库:

<?php
    
namespace Tests\Feature;
    
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
    
class ExampleTest extends TestCase
{
    use RefreshDatabase;
    
    /**
     * A basic functional test example.
     *
     * @return void
     */
    public function testBasicExample()
    {
        $response = $this->get('/');
    
        // ...
    }
}

创建模型工厂

在测试过程中,通常需要在执行测试前插入测试数据到数据库。在创建这些测试数据时,Laravel 提供了模型工厂功能为每个 Eloquent 模型定义伪造的属性值集合,而不用手动为每一个字段指定具体值,这样一来,就可以极大提高测试数据的创建效率。

我们可以使用 Artisan 命令 make:factory 创建模型工厂:

php artisan make:factory PostFactory

新创建的工厂类默认存放在 database/factories 目录下。

创建模型工厂时还可以使用 --model 选项指定对应的模型类,该选项通过传递的模型类名预填充生成的工厂类:

php artisan make:factory PostFactory --model=Post

编写模型工厂

开始之前,我们先来看一下 Laravel 开箱提供的、定义在 database/factories/UserFactory.php 文件中的用户模型工厂:

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class UserFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = User::class;

    /**
     * Define the model's default state.
     *
     * @return array
     */
    public function definition()
    {
        return [
            'name' => $this->faker->name,
            'email' => $this->faker->unique()->safeEmail,
            'email_verified_at' => now(),
            'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password
            'remember_token' => Str::random(10),
        ];
    }
}

正如你所看到的,以上就是模型工厂的最基本形式,它是一个继承自 Laravel 模型工厂基类的 PHP 类,定义了一个 model 属性和 definition 方法,definition 方法会返回伪造的模型属性值集合,该集合会在使用模型工厂创建新的模型实例时应用到模型属性。

通过继承自父类的 $faker 属性,模型工厂可以使用 Faker 对象实例为数据库测试生成不同类型的随机伪造数据。

Faker 本地化

你可以通过添加 faker_locale 配置项到 config/app.php 配置文件来设置 Faker 的本地化:

'faker_locale' => 'zh_CN',

这样,我们通过模型工厂创建的模型实例伪造属性就是基于中文的了:

-w789

工厂状态

状态操作方法允许你在任意组合中定义可用于模型工厂的离散修改(即运行时修改由 definition 方法定义的默认属性值,这里不要望文生义,这个状态是指整个模型属性值的变化,而不是某个具体的状态字段),例如,User 模型可能有一个 suspended 状态用于修饰某个默认属性值,你可以使用工厂基类提供的 state 方法来定义这个状态转化,状态转化对应的方法名可以随便起,毕竟,这只是一个常规的 PHP 方法而已:

/**
 * Indicate that the user is suspended.
 *
 * @return \Illuminate\Database\Eloquent\Factories\Factory
 */
public function suspended()
{
    return $this->state([
        'account_status' => 'suspended',
    ]);
}

如果状态转化要求访问模型工厂定义的其它属性,可以传递一个回调函数到 state 方法,该回调接收模型工厂定义的原始属性数组作为参数:

/**
 * Indicate that the user is suspended.
 *
 * @return \Illuminate\Database\Eloquent\Factories\Factory
 */
public function suspended()
{
    return $this->state(function (array $attributes) {
        return [
            'account_status' => 'suspended',
        ];
    });
}

工厂回调

工厂回调使用 afterMakingafterCreating 方法注册,通过工厂回调你可以在创建模型(基于 make 或者 create 方法)后执行额外任务。你需要在工厂类中编写 configure 方法来注册这些回调,该方法会在工厂实例化时被 Laravel 自动调用:

namespace Database\Factories;

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;

class UserFactory extends Factory
{
    /**
     * The name of the factory's corresponding model.
     *
     * @var string
     */
    protected $model = User::class;

    /**
     * Configure the model factory.
     *
     * @return $this
     */
    public function configure()
    {
        return $this->afterMaking(function (User $user) {
            //
        })->afterCreating(function (User $user) {
            //
        });
    }

    // ...
}

使用模模型工厂

创建模型

定义好模型工厂后,可以在对应模型类上通过静态方法 factory 为该模型类实例化一个模型工厂实例,factory 方法由模型类引入的 Illuminate\Database\Eloquent\Factories\HasFactory Trait 提供,因此没有引用该 Trait 的模型类无法调用:

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    use HasFactory;
}

接下来,我们来看一些创建模型的示例。首先,我们使用 make 方法创建不会持久化到数据库的模型实例:

use App\Models\User;

public function testDatabase()
{
    $user = User::factory()->make();

    // 在测试中使用模型...
}

你可以使用 count 方法创建一个包含多个模型的集合:

// 创建 3 个 App\Models\User 实例...
$users = User::factory()->count(3)->make();

HasFactory Trait 的 factory 方法会使用如下约定来判定该模型对应的模型工厂类:从 Database\Factories 命名空间下查找「模型类名 + Factory 后缀」类名对应的工厂类。如果你的模型类或者工厂类没有遵循这个约定,可以通过在模型类中重写 newFactory 方法直接返回模型类对应的工厂对象实例:

/**
 * 为模型创建一个新的工厂实例
 *
 * @return \Illuminate\Database\Eloquent\Factories\Factory
 */
protected static function newFactory()
{
    return \Database\Factories\Administration\FlightFactory::new();
}

应用状态

还可以应用任意状态到模型类,如果你想要应用多个状态转化到模型类,需要调用对应的状态转化方法:

$users = User::factory()->count(5)->suspended()->make();

覆盖属性

如果你想要覆盖模型中的某些默认值,可以传递值数组到 make 方法,只有指定值才会被替换,剩下值保持工厂指定的默认值不变:

$user = User::factory()->make([
    'name' => '学院君',
]);

或者,可以在工厂实例上直接调用 state 方法来执行状态转化:

$user = User::factory()->state([
    'name' => '学院君',
])->make();

注:使用模型工厂创建模型时批量赋值保护默认会自动禁用。

持久化模型

create 方法不仅能创建模型实例,还可以使用 Eloquent 的 save 方法将它们持久化到数据库:

use App\Models\User;

public function testDatabase()
{
    // 创建单个 App\Models\User 实例...
    $user = User::factory()->create();

    // 创建 3 个 App\Models\User 实例...
    $users = User::factory()->count(3)->create();

    // 在测试中使用模型...
}

你可以通过传递属性数组到 create 方法覆盖模型上的默认属性(通过模型工厂定义):

$user = User::factory()->create([
    'name' => '学院君',
]);

交替设置属性值

有时候你可能想要为每个创建的模型交替设置给定模型属性值,这可以通过将状态转化定义为 Sequence 实例来实现。例如,我们想要为每个创建的 User 模型交替设置 admin 属性值为 YN,可以这么做:

use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Sequence;

$users = User::factory()
                ->count(10)
                ->state(new Sequence(
                    ['admin' => 'Y'],
                    ['admin' => 'N'],
                ))
                ->create();

在这个例子中,5 个被创建的用户 admin 属性值为 Y,另外 5 个被创建的用户 admin 属性值为 N

关联关系

工厂定义中应用关联关系

你可以在工厂定义中应用关联关系,例如,如果你想要在创建一个新的 Post 实例时创建一个与之关联的 User 实例,可以这么做:

use App\Models\User;

/**
 * 定义模型的默认状态
 *
 * @return array
 */
public function definition()
{
    return [
        'user_id' => User::factory(),
        'title' => $this->faker->title,
        'content' => $this->faker->paragraph,
    ];
}

如果这个关联关系的字段依赖定义它的模型工厂,你可以提供一个接收待执行属性数组作为参数的回调函数进行设置:

/**
 * 定义模型的默认状态
 *
 * @return array
 */
public function definition()
{
    return [
        'user_id' => User::factory(),
        'user_type' => function (array $attributes) {
            return User::find($attributes['user_id'])->type;
        },
        'title' => $this->faker->title,
        'content' => $this->faker->paragraph,
    ];
}

一对多关联

接下来,我们来探索如何使用 Laravel 的流式工厂方法构建 Eloquent 模型关联关系。首先,假设应用包含一个 User 模型和 Post 模型,并且这个 User 模型定义了一个 与 Post 模型之间的 hasMany 一对多关联。我们可以使用模型工厂提供的 has 方法创建一个拥有 3 篇文章的用户,has 方法接收一个模型工厂实例作为参数:

use App\Models\Post;
use App\Models\User;

$users = User::factory()
            ->has(Post::factory()->count(3))
            ->create();

按照惯例,传递 Post 模型到 has 方法时,Laravel 会假设 User 模型必须包含 posts 方法定义这个关联关系。如果需要的话,你可以显式指定你想要操作的关联关系名称:

$users = User::factory()
            ->has(Post::factory()->count(3), 'posts')
            ->create();

当然,你也可以在关联模型上执行状态转化。此外,如果状态修改需要访问父模型的话,你可以传递基于闭包的状态转化:

$users = User::factory()
            ->has(
                Post::factory()
                        ->count(3)
                        ->state(function (array $attributes, User $user) {
                            return ['user_type' => $user->type];
                        })
            )
            ->create();

使用魔术方法

为了方便起见,你可以使用模型工厂的魔术关联方法定义关联关系。例如,下面这个例子会使用约定来判定关联模型需要通过 User 模型上的 posts 关联方法创建:

$users = User::factory()
            ->hasPosts(3)
            ->create();

使用魔术方法创建工厂关联关系时,你可以传递属性数组来覆盖关联模型:

$users = User::factory()
            ->hasPosts(3, [
                'published' => false,
            ])
            ->create();

如果状态修改需要访问父模型,可以使用基于闭包的状态转化:

$users = User::factory()
            ->hasPosts(3, function (array $attributes, User $user) {
                return ['user_type' => $user->type];
            })
            ->create();

归属关联(逆向一对多)

现在我们已经了解了如何使用模型工厂构建「一对多」关联,接下来,我们来看一对多的逆向关联。可以使用 for 方法来定义模型工厂创建模型的所属模型,例如,我们可以创建归属于单个用户的 3 个 Post 模型实例:

use App\Models\Post;
use App\Models\User;

$posts = Post::factory()
            ->count(3)
            ->for(User::factory()->state([
                'name' => '学院君',
            ]))
            ->create();

使用魔术方法

为了方便起见,你可以使用模型工厂的魔术关联方法来定义「归属」关联关系。例如,下面这个示例会使用约定判定三个 Post 实例会使用 Post 模型上的 user 关联方法归属于同一个用户:

$posts = Post::factory()
            ->count(3)
            ->forUser([
                'name' => '学院君',
            ])
            ->create();

多对多关联

一对多关联一样,「多对多」关联也使用 has 方法创建:

use App\Models\Role;
use App\Models\User;

$users = User::factory()
            ->has(Role::factory()->count(3))
            ->create();

中间表属性

如果你需要定义应该设置到链接不同模型的中间表的属性,可以使用 hasAttached 方法。该方法接收中间表属性名和对应属性值数组作为第二个参数:

use App\Models\Role;
use App\Models\User;

$users = User::factory()
            ->hasAttached(
                Role::factory()->count(3),
                ['active' => true]
            )
            ->create();

如果状态修改需要访问关联模型,可以使用基于闭包的状态转化:

$users = User::factory()
            ->hasAttached(
                Role::factory()
                    ->count(3)
                    ->state(function (array $attributes, User $user) {
                        return ['name' => $user->name.' Role'];
                    }),
                ['active' => true]
            )
            ->create();

使用魔术方法

为了方便起见,你可以使用模型工厂的魔术关联方法来定义多对多关联。例如,下面这个例子会使用约定来判定关联模型需要使用 User 模型上的 roles 关联方法来创建:

$users = User::factory()
            ->hasRoles(1, [
                'name' => 'Editor'
            ])
            ->create();

多态关联

多态关联也可以使用模型工厂创建。一对多的多态关联使用了普通一对多关联完全一样的方式创建,例如,如果 Post 模型包含与 Comment 模型的 morphMany 关联,可以这么做:

use App\Models\Post;

$post = Post::factory()->hasComments(3)->create();

多态归属关联

魔术方法不能用于创建 morphTo 关联关系,取而代之地,必须直接使用 for 方法并显式提供关联关系名称。例如,假设 Comment 模型有一个 commentable 方法定义 morphTo 关联,在这个场景下,我们可以直接使用 for 方法创建 3 个归属于单篇文章的评论:

$comments = Comment::factory()->count(3)->for(
    Post::factory(), 'commentable'
)->create();

多态的多对多关联

多态的「多对多」关联可以像和非多态的「多对多」关联一样创建:

use App\Models\Tag;
use App\Models\Video;

$users = Video::factory()
            ->hasAttached(
                Tag::factory()->count(3),
                ['public' => true]
            )
            ->create();

当然,也可以使用 has 魔术方法创建多态的「多对多」关联:

$users = Video::factory()
            ->hasTags(3, ['public' => true])
            ->create();

使用填充器

如果你想要在测试期间使用数据库填充器初始化数据库,可以使用 seed 方法。默认情况下,seed 方法会返回 DatabaseSeeder 以便可以执行所有其他填充器,当然,你也可以传递指定的填充器类名到 seed 方法:

<?php
    
namespace Tests\Feature;
    
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithoutMiddleware;
use OrderStatusesTableSeeder;
use Tests\TestCase;
    
class ExampleTest extends TestCase
{
    use RefreshDatabase;
    
    /**
     * Test creating a new order.
     *
     * @return void
     */
    public function testCreatingANewOrder()
    {
        // Run the DatabaseSeeder...
        $this->seed();
    
        // Run a single seeder...
        $this->seed(OrderStatusesTableSeeder::class);
    
        // ...
    }
}

有效的断言方法

Laravel 为 PHPUnit 测试提供了多个数据库断言方法:

方法 描述
$this->assertDatabaseCount($table, int $count); 断言数据表包含给定数量的实体
$this->assertDatabaseHas($table, array $data); 断言数据表包含给定数据
$this->assertDatabaseMissing($table, array $data); 断言数据表不包含给定数据
$this->assertDeleted($table, array $data); 断言给定记录是否被删除
$this->assertSoftDeleted($table, array $data); 断言给定记录已经被软删除

为了方便起见,你可以传递一个模型实例到 assertDeletedassertSoftDeleted 函数来断言对应数据库记录是否被删除或软删除,底层依据的是模型主键与数据表记录建立关联。

例如,如果你在测试中使用了模型工厂,可以传递这个模型到其中一个函数来测试应用是否删除了对应的数据库记录:

public function testDatabase()
{
    $user = User::factory()->create();

    // Make call to application...

    $this->assertDeleted($user);
}

点赞 取消点赞 收藏 取消收藏

<< 上一篇: 基于 Laravel Dusk 进行浏览器测试

>> 下一篇: 模拟