worksome/request-factories

在Laravel中测试表单请求,无需所有样板代码。

资助包维护!
worksome

v3.3.0 2024-01-11 10:25 UTC

README

在Laravel中测试表单请求,无需所有样板代码。

Unit Tests PHPStan

💡 嘘。虽然我们的示例使用了Pest PHP,但这在PHPUnit中也同样有效。

看看下面的测试

it('can sign up a user with an international phone number', function () {
    $this->put('/users', [
        'phone' => '+375 154 767 1088',
        'email' => 'foo@bar.com', 🙄
        'name' => 'Luke Downing', 😛
        'company' => 'Worksome', 😒
        'bio' => 'Blah blah blah', 😫
        'profile_picture' => UploadedFile::fake()->image('luke.png', 200, 200), 😭
        'accepts_terms_and_conditions' => true, 🤬
    ]);
    
    expect(User::latest()->first()->phone)->toBe('+375 154 767 1088');
});

哎呀。看,我们只想测试电话号码,但由于我们的路由FormRequest有验证规则,我们必须同时发送所有这些额外字段。这种方法有几个缺点

  1. 它使测试变得混乱。测试应该是简洁且易于阅读的。但这完全不是。
  2. 它使编写测试变得令人讨厌。你可能对每个路由都有多个测试。你每次编写的测试都需要重复使用所有这些字段。
  3. 它需要了解FormRequest。在能够编写通过测试之前,你需要了解这个表单中每个字段的作用。如果不了解,你可能会陷入试错循环,或者在创建测试时出现错误。

我们认为这种体验可以大大改进。看看

it('can sign up a user with an international phone number', function () {
    SignupRequest::fake();

    $this->put('/users', ['phone' => '+375 154 767 1088']);

    expect(User::latest()->first()->phone)->toBe('+375 154 767 1088');
});

要酷得多。这一切都归功于Request Factories。让我们深入了解...

安装

您可以通过Composer将包作为开发依赖项安装

composer require --dev worksome/request-factories 

使用方法

首先,让我们创建一个新的RequestFactory。一个RequestFactory通常与您的应用程序中的FormRequest配合使用(请求工厂也可以与标准请求一起使用!)。您可以使用make:request-factory Artisan命令创建一个RequestFactory

php artisan make:request-factory "App\Http\Requests\SignupRequest"

请注意,我们将SignupRequest FQCN作为参数传递。这将创建一个新的请求工厂在tests/RequestFactories/SignupRequestFactory.php

您也可以将所需的请求工厂名称作为参数传递

php artisan make:request-factory SignupRequestFactory

虽然您可以随意命名请求工厂,但我们推荐两种默认值以获得无缝体验

  1. 将它们放在tests/RequestFactories中。Artisan命令会为您完成这项工作。
  2. 使用Factory后缀。所以SignupRequest变成SignupRequestFactory

工厂基础

让我们看看我们新创建的SignupRequestFactory。您会看到类似以下内容

namespace Tests\RequestFactories;

use Worksome\RequestFactories\RequestFactory;

class SignupRequestFactory extends RequestFactory
{
    public function definition(): array
    {
        return [
            // 'email' => $this->faker->email,
        ];
    }
}

如果您之前使用过Laravel的模型工厂,这看起来会很熟悉。这是因为基本概念是相同的:模型工厂旨在为Eloquent模型生成数据,请求工厂旨在为表单请求生成数据。

definition方法应该返回一个数组,包含可用于提交表单的有效数据。让我们为我们的示例SignupRequestFactory填充它

namespace Tests\RequestFactories;

use Worksome\RequestFactories\RequestFactory;

class SignupRequestFactory extends RequestFactory
{
    public function definition(): array
    {
        return [
            'phone' => '01234567890',
            'email' => 'foo@bar.com',
            'name' => 'Luke Downing',
            'company' => 'Worksome',
            'bio' => $this->faker->words(300, true),
            'accepts_terms_and_conditions' => true,
        ];
    }
    
    public function files(): array
    {
        return [
            'profile_picture' => $this->file()->image('luke.png', 200, 200),
        ];
    }
}

请注意,我们有一个faker属性,可以轻松生成假内容,例如为我们的个人简介生成一段段落,还有一个我们可以声明的files方法,以将文件与其他请求数据分开。

测试中的使用

那么我们如何在测试中使用这个工厂呢?根据您喜欢的风格,有几个选项。

在工厂中使用 create

此方法与 Laravel 的模型工厂最为相似。create 方法返回一个数组,然后您可以将它作为数据传递给 putpost 或其他任何请求测试方法。

it('can sign up a user with an international phone number', function () {
    $data = SignupRequest::factory()->create(['phone' => '+44 1234 567890']);
    
    $this->put('/users', $data)->assertValid();
});

在请求工厂中使用 fake

由于您通常在每次测试中只发送一个请求,因此我们支持使用 fake 在全局范围内注册您的工厂。如果您使用这种方法,请确保它是 工厂上最后调用的方法,并且您在向相关端点发送请求之前调用它。

it('can sign up a user with an international phone number', function () {
    SignupRequestFactory::new()->fake();
    
    $this->put('/users')->assertValid();
});

在表单请求中使用 fake

如果您使用过 Laravel 模型工厂,您可能已经习惯了在 Eloquent 模型上调用 ::factory() 来获取新的工厂实例。请求工厂具有类似的功能。您不需要做任何事情来启用此功能;我们自动通过宏将 ::fake()::factory() 方法注册到所有 FormRequests 上!

您可以在测试中使用这些方法,而不是直接实例化请求工厂

it('can sign up a user with an international phone number', function () {
    // Using the factory method...
    SignupRequest::factory()->fake();
    
    // ...or using the fake method
    SignupRequest::fake();
    
    $this->put('/users')->assertValid();
});

Pest PHP 中的 fakeRequest

如果您使用 Pest,我们提供了一种更高阶的方法,您可以将它链到您的测试中

// You can provide the form request FQCN...
it('can sign up a user with an international phone number', function () {
    $this->put('/users')->assertValid();
})->fakeRequest(SignupRequest::class);

// Or the request factory FQCN...
it('can sign up a user with an international phone number', function () {
    $this->put('/users')->assertValid();
})->fakeRequest(SignupRequestFactory::class);

// Or even a closure that returns a request factory...
it('can sign up a user with an international phone number', function () {
    $this->put('/users')->assertValid();
})->fakeRequest(fn () => SignupRequest::factory());

您甚至可以将工厂方法链接到 fakeRequest 方法的末尾

it('can sign up a user with an international phone number', function () {
    $this->put('/users')->assertValid();
})
    ->fakeRequest(SignupRequest::class)
    ->state(['name' => 'Jane Bloggs']);

覆盖请求工厂数据

请注意请求工厂在向您的请求注入数据时的优先级顺序。

  1. 传递给 getpostputpatchdelete 或类似方法的任何数据都将始终具有优先级。
  2. 使用 state 定义的或对工厂调用并改变状态的函数将排在第二位。
  3. 工厂中定义的 definitionfiles 方法中的数据排在最后,仅填充请求中缺少的属性。

让我们通过一个示例来展示这个优先级顺序

it('can sign up a user with an international phone number', function () {
    SignupRequest::factory()->state(['name' => 'Oliver Nybroe', 'email' => 'oliver@worksome.com'])->fake();
    
    $this->put('/users', ['email' => 'luke@worksome.com'])->assertValid();
});

SignupRequestFactory 中定义的默认电子邮件是 foo@bar.com。默认名称是 Luke Downing。由于我们在调用 fake 之前使用 state 方法覆盖了 name 属性,因此表单请求中使用的名称实际上是 Oliver Nybroe,而不是 Luke Downing

然而,由于我们向 put 方法传递了 luke@worksome.com 作为数据,这将优先于 所有其他定义的数据,包括 foo@bar.comoliver@worksome.com

工厂的力量

工厂真的很酷,因为它们允许我们为我们的特性测试创建一个领域特定语言。由于工厂是类,我们可以添加声明式方法,这些方法作为状态转换器。

// In our factory...
class SignupRequestFactory extends RequestFactory
{
    // After the definition...
    public function withOversizedProfilePicture(): static
    {
        return $this->state(['profile_picture' => $this->file()->image('profile.png', 2001, 2001)])
    }
}

// In our test...
it('does not allow profile pictures larger than 2000 pixels', function () {
    SignupRequest::factory()->withOversizedProfilePicture()->fake();
    
    $this->put('/users')->assertInvalid(['profile_picture' => 'size']);
});

您还可以在 state 方法中使用点符号来改变请求数据中的深层嵌套键。

it('requires a postcode with the first line of an address', function () {
    SignupRequest::factory()->state(['address.line_one' => '1 Test Street'])->fake();
    
    $this->put('/users')->assertInvalid(['address.postcode' => 'required']);
});

state 方法是您在工厂上添加或更改数据的得力助手。那么,如果您想从请求中省略一个属性怎么办?尝试 without 方法!

it('requires an email address', function () {
    SignupRequest::factory()->without('email')->fake();
    
    $this->put('/users')->assertInvalid(['email' => 'required']);
});

💡 您可以在 without 方法中使用点符号来取消深层嵌套键的设置

您还可以将数组传递给 without 来一次性取消多个属性的设置。

有时,您可能有一个属性,您希望它基于其他属性的值。在这种情况下,您可以将闭包作为属性值提供,该闭包接收所有其他参数的数组

class SignupRequestFactory extends RequestFactory
{
    public function definition(): array
    {
        return [
            'name' => 'Luke Downing',
            'company' => 'Worksome',
            'email' => fn ($properties) => Str::of($properties['name'])
                ->replace(' ', '.')
                ->append("@{$properties['company']}.com")
                ->lower()
                ->__toString(), // luke.downing@worksome.com
        ];
    }
}

偶尔,您可能会注意到您的应用程序中多个请求共享一组类似的字段子集。例如,注册表单和支付表单可能都包含地址数组。而不是在您的工厂中重复这些字段,您可以在工厂内部嵌套工厂

class SignupRequestFactory extends RequestFactory
{
    public function definition(): array
    {
        return [
            'name' => 'Luke Downing',
            'company' => 'Worksome',
            'address' => AddressRequestFactory::new(),
        ];
    }
}

现在,当创建 SignupRequestFactory 时,它将为您解析 AddressRequestFactory 并用 AddressRequestFactory 定义中包含的所有字段填充 address 属性。很酷,不是吗?

请求工厂也与模型工厂协同工作。想象一下,你想要将一个 User ID 传递给表单请求,但你需要创建数据库中的用户才能这样做。在请求工厂定义中实例化 UserFactory 就这么简单。

class StoreMovieController extends RequestFactory
{
    public function definition(): array
    {
        return [
            'name' => 'My Cool Movie'
            'owner_id' => User::factory(),
        ];
    }
}

由于 UserFactory 是在编译时创建的,我们避免了在手动覆盖 owner_id 字段时意外地将模型持久化到测试数据库。

不使用表单请求使用工厂

你的应用中的每个控制器都不需要后置表单请求。幸运的是,我们也支持伪造通用请求。

it('lets a guest sign up to the newsletter', function () {
    NewsletterSignupFactory::new()->fake();
    
    post('/newsletter', ['email' => 'foo@bar.com'])->assertRedirect('/thanks');
});

解决常见问题

我遇到了一个 CouldNotLocateRequestFactoryException

当在 FormRequest 上使用 ::fake()::factory() 方法时,我们会尝试自动定位相关的请求工厂。如果你的目录结构由于某种原因不匹配,这个异常将会被抛出。

很容易解决,只需在你的表单请求中添加一个 public static $factory 属性。

class SignupRequest extends FormRequest
{
    public static $factory = SignupRequestFactory::class; 
}

我在单个测试中调用多个路由并想要伪造它们

没问题。只需在每个请求之前对相关的请求工厂调用 fake

it('allows a user to sign up and update their profile', function () {
    SignupRequest::fake();
    post('/signup');
    
    ProfileRequest::fake();
    post('/profile')->assertValid();
});

我不想使用默认位置存储请求工厂

没问题。我们提供了一个配置文件,你可以发布它来编辑请求工厂的路径和命名空间。首先,发布配置文件。

php artisan vendor:publish --tag=request-factories

现在,在新建的 config/request-factories.php 中,修改 pathnamespace 键以满足你的要求。

return [
    'path' => base_path('request_factories'),
    'namespace' => 'App\\RequestFactories',
];

测试

我们自豪于全面的测试套件和严格的静态分析。你可以通过 composer 脚本运行所有的检查。

composer test

为了使贡献变得极其容易,我们还提供了一个 docker-compose 文件,它会启动一个带有所有必需依赖项的容器。假设你已经安装了 docker,只需运行

docker-compose run --rm composer install # Only needed the first time
docker-compose run --rm composer test # Run tests and static analysis 

变更日志

请参阅 变更日志 以获取最近更改的更多信息。

致谢

许可

MIT 许可证 (MIT)。请参阅 许可文件 以获取更多信息。