跳到内容

Foundry

编辑此页

Foundry 通过富有表现力、自动补全、按需的 fixtures 系统,使创建 fixtures 数据再次充满乐趣,适用于 Symfony 和 Doctrine

工厂可以在 DoctrineFixturesBundle 中用于加载 fixtures,或在您的测试中使用,它具有更多功能

Foundry 支持 doctrine/orm (使用 doctrine/doctrine-bundle)、doctrine/mongodb-odm (使用 doctrine/mongodb-odm-bundle) 或它们的组合。

想观看关于它的视频教程 🎥 吗?查看 https://symfonycasts.com/foundry

安装

1
$ composer require zenstruck/foundry --dev

要使用此 bundle 中的 make:* 命令,请确保已安装 Symfony MakerBundle

如果未使用 Symfony Flex,请确保在您的 test/dev 环境中启用该 bundle。

本文档中使用的相同实体

对于文档的其余部分,将使用以下示例实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
namespace App\Entity;

use App\Repository\CategoryRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: CategoryRepository::class)]
class Category
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'string')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    // ... getters/setters
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
namespace App\Entity;

use App\Repository\PostRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: PostRepository::class)]
class Post
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'string')]
    private $id;

    #[ORM\Column(type: 'string', length: 255)]
    private $title;

    #[ORM\Column(type: 'text', nullable: true)]
    private $body;

    #[ORM\Column(type: 'datetime')]
    private $createdAt;

    #[ORM\Column(type: 'datetime', nullable: true)]
    private $publishedAt;

    #[ORM\ManyToOne(targetEntity: Category::class)]
    #[ORM\JoinColumn]
    private $category;

    public function __construct(string $title)
    {
        $this->title = $title;
        $this->createdAt = new \DateTime('now');
    }

    // ... getters/setters
}

持久对象工厂

使用 Foundry 的最佳方式是为每个 ORM 实体或 MongoDB 文档生成一个工厂类。您可以跳过此步骤并使用 匿名工厂,但持久对象工厂为您提供 IDE 自动完成功能,并可以访问其他有用的功能。

生成

使用 maker 命令为您的实体之一创建持久对象工厂

1
2
3
4
5
6
7
8
$ bin/console make:factory

> Entity class to create a factory for:
> Post

created: src/Factory/PostFactory.php

Next: Open your new factory and set default values/states.

此命令将生成一个 PostFactory 类,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
// src/Factory/PostFactory.php

namespace App\Factory;

use App\Entity\Post;
use App\Repository\PostRepository;
use Zenstruck\Foundry\Persistence\PersistentProxyObjectFactory;
use Zenstruck\Foundry\Persistence\Proxy;
use Zenstruck\Foundry\Persistence\ProxyRepositoryDecorator;

/**
 * @extends PersistentProxyObjectFactory<Post>
 *
 * @method        Post|Proxy                              create(array|callable $attributes = [])
 * @method static Post|Proxy                              createOne(array $attributes = [])
 * @method static Post|Proxy                              find(object|array|mixed $criteria)
 * @method static Post|Proxy                              findOrCreate(array $attributes)
 * @method static Post|Proxy                              first(string $sortedField = 'id')
 * @method static Post|Proxy                              last(string $sortedField = 'id')
 * @method static Post|Proxy                              random(array $attributes = [])
 * @method static Post|Proxy                              randomOrCreate(array $attributes = [])
 * @method static PostRepository|ProxyRepositoryDecorator repository()
 * @method static Post[]|Proxy[]                          all()
 * @method static Post[]|Proxy[]                          createMany(int $number, array|callable $attributes = [])
 * @method static Post[]|Proxy[]                          createSequence(iterable|callable $sequence)
 * @method static Post[]|Proxy[]                          findBy(array $attributes)
 * @method static Post[]|Proxy[]                          randomRange(int $min, int $max, array $attributes = [])
 * @method static Post[]|Proxy[]                          randomSet(int $number, array $attributes = [])
 */
final class PostFactory extends PersistentProxyObjectFactory
{
    /**
     * @see https://symfony.ac.cn/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
     *
     * @todo inject services if required
     */
    public function __construct()
    {
    }

    public static function class(): string
    {
        return Post::class;
    }

    /**
     * @see https://symfony.ac.cn/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
     *
     * @todo add your default values here
     */
    protected function defaults(): array|callable
    {
        return [
            'createdAt' => \DateTimeImmutable::createFromMutable(self::faker()->dateTime()),
            'title' => self::faker()->text(255),
        ];
    }

    /**
     * @see https://symfony.ac.cn/bundles/ZenstruckFoundryBundle/current/index.html#initialization
     */
    protected function initialize(): static
    {
        return $this
            // ->afterInstantiate(function(Post $post): void {})
        ;
    }
}

提示

使用 make:factory --test 将在 tests/Factory 中生成工厂。

提示

您还可以从 `ZenstruckFoundryPersistencePersistentObjectFactory` 继承。这将创建没有代理的常规对象(有关更多信息,请参见 代理对象部分)。

提示

您可以全局配置将在其中生成工厂的命名空间

1
2
3
4
5
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        make_factory:
            default_namespace: 'App\\MyFactories'

您可以使用 --namespace 选项覆盖此配置。

注意

上面生成的 @method 文档块启用 PhpStorm 的自动完成功能,但会导致 PHPStan 出现错误。为了支持 PHPStan 用于您的工厂,您还需要添加以下文档块

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
 * ...
 *
 * @phpstan-method Proxy<Post> create(array|callable $attributes = [])
 * @phpstan-method static Proxy<Post> createOne(array $attributes = [])
 * @phpstan-method static Proxy<Post> find(object|array|mixed $criteria)
 * @phpstan-method static Proxy<Post> findOrCreate(array $attributes)
 * @phpstan-method static Proxy<Post> first(string $sortedField = 'id')
 * @phpstan-method static Proxy<Post> last(string $sortedField = 'id')
 * @phpstan-method static Proxy<Post> random(array $attributes = [])
 * @phpstan-method static Proxy<Post> randomOrCreate(array $attributes = [])
 * @phpstan-method static list<Proxy<Post>> all()
 * @phpstan-method static list<Proxy<Post>> createMany(int $number, array|callable $attributes = [])
 * @phpstan-method static list<Proxy<Post>> createSequence(array|callable $sequence)
 * @phpstan-method static list<Proxy<Post>> findBy(array $attributes)
 * @phpstan-method static list<Proxy<Post>> randomRange(int $min, int $max, array $attributes = [])
 * @phpstan-method static list<Proxy<Post>> randomSet(int $number, array $attributes = [])
 * @phpstan-method static RepositoryProxy<Post> repository()
 */
final class PostFactory extends ModelFactory
{
    // ...
}

defaults() 中,您可以返回一个所有默认值的数组,任何新对象都应具有这些默认值。Faker 可用于轻松获取随机数据

1
2
3
4
5
6
7
8
9
protected function defaults(): array
{
    return [
        // Symfony's property-access component is used to populate the properties
        // this means that setTitle() will be called or you can have a $title constructor argument
        'title' => self::faker()->unique()->sentence(),
        'body' => self::faker()->sentence(),
    ];
}

提示

最好让 defaults() 返回属性以持久化有效对象(所有非空字段)。

提示

使用 make:factory --all-fields 将为实体的所有字段生成默认值,而不仅仅是非空字段。

注意

defaults() 每次实例化工厂时都会被调用(即使您最终没有创建它)。惰性值 允许您确保仅在需要时/如果需要时才计算该值。

使用您的工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
use App\Factory\PostFactory;

// create/persist Post with random data from `defaults()`
PostFactory::createOne();

// or provide values for some properties (others will be random)
PostFactory::createOne(['title' => 'My Title']);

// createOne() returns the persisted Post object wrapped in a Proxy object
$post = PostFactory::createOne();

// the "Proxy" magically calls the underlying Post methods and is type-hinted to "Post"
$title = $post->getTitle(); // getTitle() can be autocompleted by your IDE!

// if you need the actual Post object, use ->_real()
$realPost = $post->_real();

// create/persist 5 Posts with random data from defaults()
PostFactory::createMany(5); // returns Post[]|Proxy[]
PostFactory::createMany(5, ['title' => 'My Title']);

// Create 5 posts with incremental title
PostFactory::createMany(
    5,
    static function(int $i) {
        return ['title' => "Title $i"]; // "Title 1", "Title 2", ... "Title 5"
    }
);

// find a persisted object for the given attributes, if not found, create with the attributes
PostFactory::findOrCreate(['title' => 'My Title']); // returns Post|Proxy

PostFactory::first(); // get the first object (assumes an auto-incremented "id" column)
PostFactory::first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
PostFactory::last(); // get the last object (assumes an auto-incremented "id" column)
PostFactory::last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object

PostFactory::truncate(); // empty the database table

PostFactory::count(); // the number of persisted Posts
PostFactory::count(['category' => $category]); // the number of persisted Posts with the given category

PostFactory::all(); // Post[]|Proxy[] all the persisted Posts

PostFactory::findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter

$post = PostFactory::find(5); // Post|Proxy with the id of 5
$post = PostFactory::find(['title' => 'My First Post']); // Post|Proxy matching the filter

// get a random object that has been persisted
$post = PostFactory::random(); // returns Post|Proxy
$post = PostFactory::random(['author' => 'kevin']); // filter by the passed attributes

// or automatically persist a new random object if none exists
$post = PostFactory::randomOrCreate();
$post = PostFactory::randomOrCreate(['author' => 'kevin']); // filter by or create with the passed attributes

// get a random set of objects that have been persisted
$posts = PostFactory::randomSet(4); // array containing 4 "Post|Proxy" objects
$posts = PostFactory::randomSet(4, ['author' => 'kevin']); // filter by the passed attributes

// random range of persisted objects
$posts = PostFactory::randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects
$posts = PostFactory::randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes

可重用的工厂“状态”

您可以向工厂添加任何您想要的方法(例如,以特定方式创建对象的静态方法),但您也可以添加状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
final class PostFactory extends PersistentProxyObjectFactory
{
    // ...

    public function published(): self
    {
        // call setPublishedAt() and pass a random DateTime
        return $this->with(['published_at' => self::faker()->dateTime()]);
    }

    public function unpublished(): self
    {
        return $this->with(['published_at' => null]);
    }

    public function withViewCount(int $count = null): self
    {
        return $this->with(function () use ($count) {
            return ['view_count' => $count ?? self::faker()->numberBetween(0, 10000)];
        });
    }
}

您可以使用状态使您的测试非常明确,以提高可读性

1
2
3
4
5
6
7
8
9
10
11
12
// never use the constructor (i.e. "new PostFactory()"), but use the
// "new()" method. After defining the states, call "create()" to create
// and persist the model.
$post = PostFactory::new()->unpublished()->create();
$post = PostFactory::new()->withViewCount(3)->create();

// combine multiple states
$post = PostFactory::new()
    ->unpublished()
    ->withViewCount(10)
    ->create()
;

注意

请务必将状态/钩子从 $this 链式调用,因为工厂是 不可变的

属性

用于实例化对象的属性可以通过多种方式添加。属性可以是数组,也可以是返回数组的可调用对象。使用可调用对象可确保随机数据,因为可调用对象在实例化期间为每个对象单独运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use App\Entity\Category;
use App\Entity\Post;
use App\Factory\CategoryFactory;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\faker;

// The first argument to "new()" allows you to overwrite the default
// values that are defined in the `PostFactory::defaults()`
$posts = PostFactory::new(['title' => 'Post A'])
    ->with([
        'body' => 'Post Body...',

        // CategoryFactory will be used to create a new Category for each Post
        'category' => CategoryFactory::new(['name' => 'php']),
    ])
    ->with([
        // Proxies are automatically converted to their wrapped object
        'category' => CategoryFactory::createOne(),
    ])
    ->with(function() { return ['createdAt' => faker()->dateTime()]; }) // see faker section below

    // create "2" Post's
    ->many(2)->create(['title' => 'Different Title'])
;

$posts[0]->getTitle(); // "Different Title"
$posts[0]->getBody(); // "Post Body..."
$posts[0]->getCategory(); // random Category
$posts[0]->getPublishedAt(); // \DateTime('last week')
$posts[0]->getCreatedAt(); // random \DateTime

$posts[1]->getTitle(); // "Different Title"
$posts[1]->getBody(); // "Post Body..."
$posts[1]->getCategory(); // random Category (different than above)
$posts[1]->getPublishedAt(); // \DateTime('last week')
$posts[1]->getCreatedAt(); // random \DateTime (different than above)

注意

传递给 create* 方法的属性将与通过 defaults()with() 设置的任何属性合并。

序列

序列有助于在一个调用中创建不同的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
use App\Factory\PostFactory;

// create/persist 2 posts based on a sequence of attributes
PostFactory::createSequence(
    [
        ['name' => 'title 1'],
        ['name' => 'title 2'],
    ]
);

// create 10 posts using a sequence callback with an incremental index
PostFactory::createSequence(
    function() {
        foreach (range(1, 10) as $i) {
            yield ['name' => "title $i"];
        }
    }
);

// sequences could also be used with a factory with states
$posts = PostFactory::new()
    ->unpublished()
    ->sequence(
        [
            ['name' => 'title 1'],
            ['name' => 'title 2'],
        ]
    )->create();

Faker

此库为 FakerPHP 提供了一个包装器,以帮助为您的工厂生成随机数据

1
2
3
use function Zenstruck\Foundry\faker;

faker()->email(); // random email

注意

您可以自定义 Faker 的 locale 和随机 seed

1
2
3
4
5
6
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        faker:
            locale: fr_FR # set the locale
            seed: 5678 # set the random number generator seed

注意

您可以通过标记任何带有 foundry.faker_provider 的服务来注册您自己的Faker Provider。此服务上的所有公共方法都将在 Foundry 的 Faker 实例上可用

1
2
3
use function Zenstruck\Foundry\faker;

faker()->customMethodOnMyService();

注意

为了完全控制,您可以注册您自己的 Faker\Generator 服务

1
2
3
4
5
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        faker:
            service: my_faker # service id for your own instance of Faker\Generator

事件 / 钩子

以下事件可以添加到工厂。可以添加多个事件回调,它们按照添加的顺序运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use App\Factory\PostFactory;
use Zenstruck\Foundry\Proxy;

PostFactory::new()
    ->beforeInstantiate(function(array $attributes): array {
        // $attributes is what will be used to instantiate the object, manipulate as required
        $attributes['title'] = 'Different title';

        return $attributes; // must return the final $attributes
    })
    ->afterInstantiate(function(Post $object, array $attributes): void {
        // $object is the instantiated object
        // $attributes contains the attributes used to instantiate the object and any extras
    })
    ->afterPersist(function(Proxy $proxy, array $attributes) {
        /* @var Post $proxy */
        // this event is only called if the object was persisted
        // $proxy is a Proxy wrapping the persisted object
        // $attributes contains the attributes used to instantiate the object and any extras
    })

    // if the first argument is type-hinted as the object, it will be passed to the closure (and not the proxy)
    ->afterPersist(function(Post $object, array $attributes) {
        // this event is only called if the object was persisted
        // $object is the persisted Post object
        // $attributes contains the attributes used to instantiate the object and any extras
    })

    // multiple events are allowed
    ->beforeInstantiate(function($attributes) { return $attributes; })
    ->afterInstantiate(function() {})
    ->afterPersist(function() {})
;

您还可以在工厂类中直接添加钩子

1
2
3
4
5
6
protected function initialize(): static
{
    return $this
        ->afterPersist(function() {})
    ;
}

阅读 初始化 以了解有关 initialize() 方法的更多信息。

初始化

您可以覆盖工厂的 initialize() 方法以添加默认状态/逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
final class PostFactory extends PersistentProxyObjectFactory
{
    // ...

    protected function initialize(): static
    {
        return $this
            ->published() // published by default
            ->instantiateWith(function (array $attributes) {
                return new Post(); // custom instantiation for this factory
            })
            ->afterPersist(function () {}) // default event for this factory
        ;
    }
}

实例化

默认情况下,对象以正常方式实例化,通过使用对象的构造函数。使用与构造函数参数匹配的属性。其余属性使用 Symfony 的 PropertyAccess 组件(setter/公共属性)设置到对象。任何额外的属性都会导致抛出异常。

您可以通过多种方式自定义实例化器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
use App\Entity\Post;
use App\Factory\PostFactory;
use Zenstruck\Foundry\Object\Instantiator;

// set the instantiator for the current factory
PostFactory::new()
    // instantiate the object without calling the constructor
    ->instantiateWith(Instantiator::withoutConstructor())

    // "foo" and "bar" attributes are ignored when instantiating
    ->instantiateWith(Instantiator::withConstructor()->allowExtra(['foo', 'bar']))

    // all extra attributes are ignored when instantiating
    ->instantiateWith(Instantiator::withConstructor()->allowExtra())

    // force set "title" and "body" when instantiating
    ->instantiateWith(Instantiator::withConstructor()->alwaysForce(['title', 'body']))

    // never use setters, always "force set" properties (even private/protected, does not use setter)
    ->instantiateWith(Instantiator::withConstructor()->alwaysForce())

    // can combine the different "modes"
    ->instantiateWith(Instantiator::withoutConstructor()->allowExtra()->alwaysForce())

    // the instantiator is just a callable, you can provide your own
    ->instantiateWith(function(array $attributes, string $class): object {
        return new Post(); // ... your own logic
    })
;

您可以为所有工厂全局自定义实例化器(仍然可以被工厂实例实例化器覆盖)

1
2
3
4
5
6
7
8
9
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        instantiator:
            use_constructor: false # always instantiate objects without calling the constructor
            allow_extra_attributes: true # always ignore extra attributes
            always_force_properties: true # always "force set" properties
            # or
            service: my_instantiator # your own invokable service for complete control

不可变

工厂是不可变的

1
2
3
4
5
6
7
8
use App\Factory\PostFactory;

$factory = PostFactory::new();
$factory1 = $factory->with([]); // returns a new PostFactory object
$factory2 = $factory->instantiateWith(function () {}); // returns a new PostFactory object
$factory3 = $factory->beforeInstantiate(function () {}); // returns a new PostFactory object
$factory4 = $factory->afterInstantiate(function () {}); // returns a new PostFactory object
$factory5 = $factory->afterPersist(function () {}); // returns a new PostFactory object

Doctrine 关系

假设您的实体遵循 Doctrine 关系的良好实践,并且您正在使用 默认实例化器,则 Foundry 可以正常工作于 doctrine 关系。不同关系以及实体创建方式存在一些细微差别。以下内容尝试记录每种关系类型的这些细微差别。

多对一

以下假设 Comment 实体与 Post 实体具有多对一关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
use App\Factory\CommentFactory;
use App\Factory\PostFactory;

// Example 1: pre-create Post and attach to Comment
$post = PostFactory::createOne(); // instance of Proxy

CommentFactory::createOne(['post' => $post]);
CommentFactory::createOne(['post' => $post->object()]); // functionally the same as above

// Example 2: pre-create Posts and choose a random one
PostFactory::createMany(5); // create 5 Posts

CommentFactory::createOne(['post' => PostFactory::random()]);

// or create many, each with a different random Post
CommentFactory::createMany(
    5, // create 5 comments
    function() { // note the callback - this ensures that each of the 5 comments has a different Post
        return ['post' => PostFactory::random()]; // each comment set to a random Post from those already in the database
    }
);

// Example 3: create a separate Post for each Comment
CommentFactory::createMany(5, [
    // this attribute is an instance of PostFactory that is created separately for each Comment created
    'post' => PostFactory::new(),
]);

// Example 4: create multiple Comments with the same Post
CommentFactory::createMany(5, [
    'post' => PostFactory::createOne(), // note the "createOne()" here
]);

提示

建议您在 defaults() 中定义的唯一关系是非空多对一关系。

提示

还建议您的 defaults() 返回 Factory 而不是创建的实体。但是,如果需要在 defaults() 方法中创建实体,则可以使用 惰性值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected function defaults(): array
{
    return [
        // RECOMMENDED
        // The Post will only be created when the factory is instantiated
        'post' => PostFactory::new(),
        'post' => PostFactory::new()->published(),
        // The callback will be called when the factory is instantiated, creating the Post
        'post' => LazyValue::new(fn () => PostFactory::createOne()),
        'post' => lazy(fn () => PostFactory::new()->published()->create()),

        // NOT RECOMMENDED
        // Will potentially result in extra unintended Posts (if you override the value during instantiation)
        'post' => PostFactory::createOne(),
        'post' => PostFactory::new()->published()->create(),
    ];
}

一对多

以下假设 Post 实体与 Comment 实体具有一对多关系

1
2
3
4
5
6
7
8
9
10
11
use App\Factory\CommentFactory;
use App\Factory\PostFactory;

// Example 1: Create a Post with 6 Comments
PostFactory::createOne(['comments' => CommentFactory::new()->many(6)]);

// Example 2: Create 6 Posts each with 4 Comments (24 Comments total)
PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(4)]);

// Example 3: Create 6 Posts each with between 0 and 10 Comments
PostFactory::createMany(6, ['comments' => CommentFactory::new()->many(0, 10)]);

多对多

以下假设 Post 实体与 Tag 实体具有多对多关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
use App\Factory\PostFactory;
use App\Factory\TagFactory;

// Example 1: pre-create Tags and attach to Post
$tags = TagFactory::createMany(3);

PostFactory::createOne(['tags' => $tags]);

// Example 2: pre-create Tags and choose a random set
TagFactory::createMany(10);

PostFactory::new()
    ->many(5) // create 5 posts
    ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random set
        return ['tags' => TagFactory::randomSet(2)]; // each post uses 2 random tags from those already in the database
    })
;

// Example 3: pre-create Tags and choose a random range
TagFactory::createMany(10);

PostFactory::new()
    ->many(5) // create 5 posts
    ->create(function() { // note the callback - this ensures that each of the 5 posts has a different random range
        return ['tags' => TagFactory::randomRange(0, 5)]; // each post uses between 0 and 5 random tags from those already in the database
    })
;

// Example 4: create 3 Posts each with 3 unique Tags
PostFactory::createMany(3, ['tags' => TagFactory::new()->many(3)]);

// Example 5: create 3 Posts each with between 0 and 3 unique Tags
PostFactory::createMany(3, ['tags' => TagFactory::new()->many(0, 3)]);

惰性值

defaults() 方法每次实例化工厂时都会被调用(即使您最终没有创建它)。有时,您可能不希望每次都计算您的值。例如,如果您有一个属性的值

  • 具有副作用(例如,创建文件或从另一个工厂获取随机现有实体)
  • 您只想计算一次(例如,从另一个工厂创建一个实体,以作为值传递到多个其他工厂中)

您可以将值包装在 LazyValue 中,以确保仅在需要时/如果需要时才计算该值。此外,LazyValue 可以 备忘化,以便仅计算一次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use Zenstruck\Foundry\Attributes\LazyValue;

class TaskFactory extends PersistentProxyObjectFactory
{
    // ...

    protected function defaults(): array
    {
        $owner = LazyValue::memoize(fn() => UserFactory::createOne());

        return [
            // Call CategoryFactory::random() everytime this factory is instantiated
            'category' => LazyValue::new(fn() => CategoryFactory::random()),
            // The same User instance will be both added to the Project and set as the Task owner
            'project' => ProjectFactory::new(['users' => [$owner]]),
            'owner'   => $owner,
        ];
    }
}

提示

可以使用 lazy()memoize() 辅助函数来创建 LazyValue,而不是 LazyValue::new()LazyValue::memoize()

工厂作为服务

如果您的工厂需要依赖项,您可以将它们定义为服务。以下示例演示了一个非常常见的用例:使用 UserPasswordHasherInterface 服务编码密码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// src/Factory/UserFactory.php

use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

final class UserFactory extends PersistentProxyObjectFactory
{
    private $passwordHasher;

    public function __construct(UserPasswordHasherInterface $passwordHasher)
    {
        parent::__construct();

        $this->passwordHasher = $passwordHasher;
    }

    public static function class(): string
    {
        return User::class;
    }

    protected function defaults(): array
    {
        return [
            'email' => self::faker()->unique()->safeEmail(),
            'password' => '1234',
        ];
    }

    protected function initialize(): static
    {
        return $this
            ->afterInstantiate(function(User $user) {
                $user->setPassword($this->passwordHasher->hashPassword($user, $user->getPassword()));
            })
        ;
    }
}

如果使用标准的 Symfony Flex 应用程序,这将是自动装配/自动配置的。否则,请注册该服务并标记为 foundry.factory

像往常一样使用工厂

1
2
UserFactory::createOne(['password' => 'mypass'])->getPassword(); // "mypass" encoded
UserFactory::createOne()->getPassword(); // "1234" encoded (because "1234" is set as the default password)

注意

提供的 bundle 是工厂作为服务所必需的。

注意

如果使用 make:factory --test,则工厂将在 tests/Factory 目录中创建,该目录在标准的 Symfony Flex 应用程序中不是自动装配/自动配置的。您将必须手动将它们注册为服务。

匿名工厂

Foundry 可用于为您没有工厂的实体创建工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
use App\Entity\Post;
use function Zenstruck\Foundry\Persistence\proxy_factory;
use function Zenstruck\Foundry\Persistence\persist_proxy;
use function Zenstruck\Foundry\Persistence\repository;

$factory = proxy_factory(Post::class);

// has the same API as non-anonymous factories
$factory->create(['field' => 'value']);
$factory->many(5)->create(['field' => 'value']);
$factory->instantiateWith(function () {});
$factory->beforeInstantiate(function () {});
$factory->afterInstantiate(function () {});
$factory->afterPersist(function () {});

// in order to access stored data, use `repository()` helper:
$repository = repository(Post::class);

$repository->first(); // get the first object (assumes an auto-incremented "id" column)
$repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
$repository->last(); // get the last object (assumes an auto-incremented "id" column)
$repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object

$repository->truncate(); // empty the database table
$repository->count(); // the number of persisted Post's
$repository->all(); // Post[]|Proxy[] all the persisted Post's

$repository->findBy(['author' => 'kevin']); // Post[]|Proxy[] matching the filter

$repository->find(5); // Post|Proxy with the id of 5
$repository->find(['title' => 'My First Post']); // Post|Proxy matching the filter

// get a random object that has been persisted
$repository->random(); // returns Post|Proxy
$repository->random(['author' => 'kevin']); // filter by the passed attributes

// get a random set of objects that have been persisted
$repository->randomSet(4); // array containing 4 "Post|Proxy" objects
$repository->randomSet(4, ['author' => 'kevin']); // filter by the passed attributes

// random range of persisted objects
$repository->randomRange(0, 5); // array containing 0-5 "Post|Proxy" objects
$repository->randomRange(0, 5, ['author' => 'kevin']); // filter by the passed attributes

// convenience functions
$entity = persist_proxy(Post::class, ['field' => 'value']);

注意

如果您的匿名工厂代码变得过于复杂,这可能表明您需要一个显式的工厂类。

延迟刷新

当一次创建/持久化许多工厂时,可以先实例化所有工厂而不保存到数据库,然后一次性刷新它们,这样可以提高性能。为此,请将操作包装在 flush_after() 回调中

1
2
3
4
5
6
use function Zenstruck\Foundry\Persistence\flush_after;

flush_after(function() {
    CategoryFactory::createMany(100); // instantiated/persisted but not flushed
    TagFactory::createMany(200); // instantiated/persisted but not flushed
}); // single flush

非持久化对象工厂

当处理不打算持久化的对象时,您可以使您的工厂从 `ZenstruckFoundryObjectFactory` 继承。这将创建普通对象,这些对象不与数据库交互(这些对象不会被 代理对象包装)。

无需持久化

“持久工厂”也可以创建对象而不持久化它们。这对于单元测试非常有用,在单元测试中,您只想测试实际对象的行为,或者创建非实体的对象。创建后,它们仍然被包装在 Proxy 中,以便稍后选择性地保存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Factory\PostFactory;
use App\Entity\Post;
use function Zenstruck\Foundry\Persistence\proxy_factory;
use function Zenstruck\Foundry\object;

$post = PostFactory::new()->withoutPersisting()->create(); // returns Post|Proxy
$post->setTitle('something else'); // do something with object
$post->save(); // persist the Post (save() is a method on Proxy)

$post = PostFactory::new()->withoutPersisting()->create()->object(); // actual Post object

$posts = PostFactory::new()->withoutPersisting()->many(5)->create(); // returns Post[]|Proxy[]

// anonymous factories:
$factory = proxy_factory(Post::class);

$entity = $factory->withoutPersisting()->create(['field' => 'value']); // returns Post|Proxy

$entity = $factory->withoutPersisting()->create(['field' => 'value'])->object(); // actual Post object

$entities = $factory->withoutPersisting()->many(5)->create(['field' => 'value']); // returns Post[]|Proxy[]

// convenience functions
$entity = object(Post::class, ['field' => 'value']);

如果您希望您的工厂默认不持久化,请覆盖其 initialize() 方法以添加此行为

1
2
3
4
5
6
protected function initialize(): static
{
    return $this
        ->withoutPersisting()
    ;
}

现在,在使用此工厂创建对象后,您必须调用 ->_save() 才能实际将它们持久化到数据库。

提示

如果您想为所有对象工厂默认禁用持久化

  1. 创建一个扩展 PersistentProxyObjectFactory 的抽象工厂。
  2. 如上所示覆盖 initialize() 方法。
  3. 让您的所有工厂都从此扩展。

与 DoctrineFixturesBundle 一起使用

Foundry 可以与 DoctrineFixturesBundle 开箱即用。您可以简单地在您的 fixture 文件中使用您的工厂和故事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// src/DataFixtures/AppFixtures.php
namespace App\DataFixtures;

use App\Factory\CategoryFactory;
use App\Factory\CommentFactory;
use App\Factory\PostFactory;
use App\Factory\TagFactory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;

class AppFixtures extends Fixture
{
    public function load(ObjectManager $manager)
    {
        // create 10 Category's
        CategoryFactory::createMany(10);

        // create 20 Tag's
        TagFactory::createMany(20);

        // create 50 Post's
        PostFactory::createMany(50, function() {
            return [
                // each Post will have a random Category (chosen from those created above)
                'category' => CategoryFactory::random(),

                // each Post will have between 0 and 6 Tag's (chosen from those created above)
                'tags' => TagFactory::randomRange(0, 6),

                // each Post will have between 0 and 10 Comment's that are created new
                'comments' => CommentFactory::new()->many(0, 10),
            ];
        });
    }
}

像往常一样运行 doctrine:fixtures:load 以播种您的数据库。

在您的测试中使用

传统上,数据 fixtures 在您的测试之外的一个或多个文件中定义。当使用这些 fixtures 编写测试时,您的 fixtures 就像一个黑匣子。fixtures 与您正在测试的内容之间没有明确的联系。

Foundry 允许每个单独的测试完全遵循 AAA(“Arrange”、“Act”、“Assert”)测试模式。您在每个测试的开头使用“工厂”创建 fixtures。您仅创建适用于测试的 fixtures。此外,这些 fixtures 仅使用测试所需的属性创建 - 不适用的属性填充随机数据。创建的 fixture 对象被包装在“代理”中,该代理有助于预先和事后断言。

让我们看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public function test_can_post_a_comment(): void
{
    // 1. "Arrange"
    $post = PostFactory::new() // New Post factory
        ->published()          // Make the post in a "published" state
        ->create([             // Instantiate Post object and persist
            'slug' => 'post-a' // This test only requires the slug field - all other fields are random data
        ])
    ;

    // 1a. "Pre-Assertions"
    $this->assertCount(0, $post->getComments());

    // 2. "Act"
    $client = static::createClient();
    $client->request('GET', '/posts/post-a'); // Note the slug from the arrange step
    $client->submitForm('Add', [
        'comment[name]' => 'John',
        'comment[body]' => 'My comment',
    ]);

    // 3. "Assert"
    self::assertResponseRedirects('/posts/post-a');

    $this->assertCount(1, $post->_refresh()->getComments()); // Refresh $post from the database and call ->getComments()

    CommentFactory::assert()->exists([ // Doctrine repository assertions
        'name' => 'John',
        'body' => 'My comment',
    ]);

    CommentFactory::assert()->count(2, ['post' => $post]); // assert given $post has 2 comments
}

在您的 TestCase 中启用 Foundry

为使用工厂的测试添加 Factories trait

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Factory\PostFactory;
use Zenstruck\Foundry\Test\Factories;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class MyTest extends WebTestCase
{
    use Factories;

    public function test_1(): void
    {
        $post = PostFactory::createOne();

        // ...
    }
}

数据库重置

此库要求在每次测试之前重置您的数据库。打包的 ResetDatabase trait 为您处理此问题。

1
2
3
4
5
6
7
8
9
10
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

class MyTest extends WebTestCase
{
    use ResetDatabase, Factories;

    // ...
}

在使用 ResetDatabase trait 的第一个测试之前,它会删除(如果存在)并创建测试数据库。然后,默认情况下,在每次测试之前,它使用 doctrine:schema:drop/doctrine:schema:create 重置 schema。

或者,您可以通过修改配置文件中的 reset_mode 选项,使其运行您的迁移。当使用此模式时,在每次测试之前,数据库将被删除/创建,并且您的迁移将运行(通过 doctrine:migrations:migrate)。此模式可能会使您的测试套件非常缓慢(尤其是在您有大量迁移的情况下)。强烈建议使用 DamaDoctrineTestBundle 来提高速度。当启用此 bundle 时,数据库将仅为套件删除/创建和迁移一次。

提示

为使用工厂的测试创建一个基本 TestCase,以避免将 traits 添加到每个 TestCase。

提示

如果您的测试没有持久化它们创建的对象,则不需要这些测试 traits。

默认情况下,ResetDatabase 重置默认配置的连接的数据库和默认配置的对象管理器的 schema。要自定义要重置的连接和对象管理器(或重置多个连接/管理器),请使用 bundle 的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        database_resetter:
            orm:
                connections:
                    - orm_connection_1
                    - orm_connection_2
                object_managers:
                    - orm_object_manager_1
                    - orm_object_manager_2
                reset_mode: schema
            mongo:
                object_managers:
                    - odm_object_manager_1
                    - odm_object_manager_2

对象代理

由工厂创建的对象被包装在特殊的Proxy对象中。这些对象允许您的 doctrine 实体具有 Active Record like 行为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use App\Factory\PostFactory;

$post = PostFactory::createOne(['title' => 'My Title']); // instance of Zenstruck\Foundry\Proxy

// get the wrapped object
$realPost = $post->_real(); // instance of Post

// call any Post method
$post->getTitle(); // "My Title"

// set property and save to the database
$post->setTitle('New Title');
$post->_save();

// refresh from the database
$post->_refresh();

// delete from the database
$post->_delete();

$post->_repository(); // repository proxy wrapping PostRepository (see Repository Proxy section below)

强制设置

对象代理具有辅助方法来访问它们包装的对象的非公共属性

1
2
3
4
5
// set private/protected properties
$post->_set('createdAt', new \DateTime());

// get private/protected properties
$post->_get('createdAt');

自动刷新

对象代理可以选择启用自动刷新,这样就无需在调用底层对象的方法之前调用 ->_refresh()。启用自动刷新后,大多数对代理对象的调用会首先从数据库刷新包装的对象。这主要在与数据库和 Symfony 内核交互的“集成”测试中非常有用。

1
2
3
4
5
6
7
8
9
10
use App\Factory\PostFactory;

$post = PostFactory::new(['title' => 'Original Title'])
    ->create()
    ->_enableAutoRefresh()
;

// ... logic that changes the $post title to "New Title" (like your functional test)

$post->getTitle(); // "New Title" (equivalent to $post->_refresh()->getTitle())

在未启用自动刷新的情况下,上述对 $post->getTitle() 的调用将返回 “Original Title”。

注意

使用自动刷新时,您需要注意的一种情况是,所有方法都会首先刷新对象。如果通过多个方法(或多个强制设置)更改对象的状态,则会抛出“未保存的更改”异常

1
2
3
4
5
6
7
8
9
use App\Factory\PostFactory;

$post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body'])
    ->create()
    ->_enableAutoRefresh()
;

$post->setTitle('New Title');
$post->setBody('New Body'); // exception thrown because of "unsaved changes" to $post from above

要克服这个问题,您需要先禁用自动刷新,然后在进行/保存更改后重新启用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
use App\Entity\Post;
use App\Factory\PostFactory;

$post = PostFactory::new(['title' => 'Original Title', 'body' => 'Original Body'])
    ->create()
    ->_enableAutoRefresh()
;

$post->_disableAutoRefresh();
$post->setTitle('New Title'); // or using ->forceSet('title', 'New Title')
$post->setBody('New Body'); // or using ->forceSet('body', 'New Body')
$post->_enableAutoRefresh();
$post->save();

$post->getBody(); // "New Body"
$post->getTitle(); // "New Title"

// alternatively, use the ->_withoutAutoRefresh() helper which first disables auto-refreshing, then re-enables after
// executing the callback.
$post->_withoutAutoRefresh(function (Post $post) { // can pass either Post or Proxy to the callback
    $post->setTitle('New Title');
    $post->setBody('New Body');
});
$post->_save();

注意

您可以全局启用/禁用自动刷新,以使每个代理默认情况下都可自动刷新或不自动刷新。启用后,您将不得不选择退出自动刷新。

1
2
3
4
# config/packages/zenstruck_foundry.yaml
when@dev: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        auto_refresh_proxies: true/false

不使用代理的工厂

可以创建不创建“代理”对象的工厂。您可以从 PersistentObjectFactory 继承,而不是从 `PersistentProxyObjectFactory` 继承来创建工厂。然后,您的工厂将直接返回“真实”对象,该对象不会被 `Proxy` 类包装。

警告

请注意,如果您的对象没有被代理包装,它们将不会自动刷新。

仓库代理

此库提供了一个仓库代理,它包装您的对象仓库,以提供有用的断言和方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use App\Entity\Post;
use App\Factory\PostFactory;
use function Zenstruck\Foundry\Persistence\repository;

// instance of RepositoryProxy that wraps PostRepository
$repository = PostFactory::repository();

// alternative to above for proxying repository you haven't created factories for
$repository = repository(Post::class);

// helpful methods - all returned object(s) are proxied
$repository->inner(); // the real "wrapped" repository
$repository->count(); // number of rows in the database table
count($repository); // equivalent to above (RepositoryProxy implements \Countable)
$repository->first(); // get the first object (assumes an auto-incremented "id" column)
$repository->first('createdAt'); // assuming "createdAt" is a datetime column, this will return latest object
$repository->last(); // get the last object (assumes an auto-incremented "id" column)
$repository->last('createdAt'); // assuming "createdAt" is a datetime column, this will return oldest object
$repository->truncate(); // delete all rows in the database table
$repository->random(); // get a random object
$repository->random(['author' => 'kevin']); // get a random object filtered by the passed criteria
$repository->randomSet(5); // get 5 random objects
$repository->randomSet(5, ['author' => 'kevin']); // get 5 random objects filtered by the passed criteria
$repository->randomRange(0, 5); // get 0-5 random objects
$repository->randomRange(0, 5, ['author' => 'kevin']); // get 0-5 random objects filtered by the passed criteria

// instance of ObjectRepository - all returned object(s) are proxied
$repository->find(1); // Proxy|Post|null
$repository->find(['title' => 'My Title']); // Proxy|Post|null
$repository->findOneBy(['title' => 'My Title']); // Proxy|Post|null
$repository->findAll(); // Proxy[]|Post[]
iterator_to_array($repository); // equivalent to above (RepositoryProxy implements \IteratorAggregate)
$repository->findBy(['title' => 'My Title']); // Proxy[]|Post[]

// can call methods on the underlying repository - returned object(s) are proxied
$repository->findOneByTitle('My Title'); // Proxy|Post|null

断言

对象代理和您的 ModelFactory 都具有有用的 PHPUnit 断言

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use App\Factory\PostFactory;

$post = PostFactory::createOne();
$post->_assertPersisted();
$post->_assertNotPersisted();

PostFactory::assert()->empty();
PostFactory::assert()->count(3);
PostFactory::assert()->countGreaterThan(3);
PostFactory::assert()->countGreaterThanOrEqual(3);
PostFactory::assert()->countLessThan(3);
PostFactory::assert()->countLessThanOrEqual(3);
PostFactory::assert()->exists(['title' => 'My Title']);
PostFactory::assert()->notExists(['title' => 'My Title']);

全局状态

如果您希望所有测试都具有初始数据库状态,则可以在 bundle 的配置中设置它。接受的值包括:作为服务的 stories,“全局” stories 和可调用服务。全局状态在使用 ResetDatabase trait 之前加载。如果您正在使用 DamaDoctrineTestBundle,则它在整个测试套件中仅加载一次。

1
2
3
4
5
6
7
8
# config/packages/zenstruck_foundry.yaml
when@test: # see Bundle Configuration section about sharing this in the test environment
    zenstruck_foundry:
        global_state:
            - App\Story\StoryThatIsAService
            - App\Story\GlobalStory
            - invokable.service # just a service with ::invoke()
            - ...

注意

您仍然可以在测试中访问 全局状态 StoriesStory 状态,并且它们仍然只加载一次。

注意

当使用全局状态时,需要 ResetDatabase trait。

警告

请注意,复杂的全局状态可能会减慢您的测试套件的速度。

PHPUnit 数据提供器

可以在 PHPUnit 数据提供器 中使用工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use App\Factory\PostFactory;

/**
 * @dataProvider postDataProvider
 */
public function test_post_via_data_provider(PostFactory $factory): void
{
    $post = $factory->create();

    // ...
}

public static function postDataProvider(): iterable
{
    yield [PostFactory::new()];
    yield [PostFactory::new()->published()];
}

注意

请确保您的数据提供器仅返回 Factory 的实例,并且您不要尝试在其上调用 ->create()。数据提供器在 Foundry 启动之前的 phpunit 进程早期计算。

注意

出于与上述相同的原因,不可能将 作为服务的工厂 与必需的构造函数参数一起使用(容器尚不可用)。

注意

仍然出于相同的原因,如果 Faker 需要与数据提供器中的 ->with() 一起使用,则需要将属性作为可调用对象传递。

给定上一个示例的数据提供器,这是 PostFactory::published()

1
2
3
4
5
6
7
8
9
10
11
12
public function published(): self
{
    // This won't work in a data provider!
    // return $this->with(['published_at' => self::faker()->dateTime()]);

    // use this instead:
    return $this->with(
        static fn() => [
            'published_at' => self::faker()->dateTime()
        ]
    );
}

提示

ObjectFactory::new()->many()ObjectFactory::new()->sequence() 返回一个特殊的 FactoryCollection 对象,该对象可用于生成数据提供器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
use App\Factory\PostFactory;

/**
 * @dataProvider postDataProvider
 */
public function test_post_via_data_provider(PostFactory $factory): void
{
    $factory->create();

    // ...
}

public static function postDataProvider(): iterable
{
    yield from PostFactory::new()->sequence(
        [
            ['title' => 'foo'],
            ['title' => 'bar'],
        ]
    )->asDataProvider();
}

FactoryCollection 也可以直接传递给测试用例,以便在同一测试中可以使用多个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
use App\Factory\PostFactory;

/**
 * @dataProvider postDataProvider
 */
public function test_post_via_data_provider(FactoryCollection $factoryCollection): void
{
    $factoryCollection->create();

    // ...
}

public static function postDataProvider(): iterable
{
    // 3 posts will be created for the first test case
    yield PostFactory::new()->sequence(
        [
            ['title' => 'foo 1'],
            ['title' => 'bar 1'],
            ['title' => 'baz 1'],
        ]
    );

    // 2 posts will be created for the second test case
    yield PostFactory::new()->sequence(
        [
            ['title' => 'foo 2'],
            ['title' => 'bar 2'],
        ]
    );
}

性能

以下是提高测试套件速度的可能选项。

DAMADoctrineTestBundle

此库与 DAMADoctrineTestBundle 无缝集成,以将每个测试包装在事务中,从而显着减少测试时间。启用此 bundle 后,此库的测试套件运行速度提高了 5 倍。

请按照其文档进行安装。Foundry 的 ResetDatabase trait 会检测到何时使用该 bundle 并进行相应调整。您的数据库仍然在运行测试套件之前重置,但 schema 不会在每个测试之前重置(仅在第一次重置)。

注意

如果使用 全局状态,则会在运行测试套件之前将其持久化到数据库(不在事务中)。如果您有复杂的全局状态,这可以进一步提高测试速度。

注意

当使用 DAMADoctrineTestBundle 时,不支持使用 全局状态 创建 ORM 和 ODM 工厂。

paratestphp/paratest

您可以使用 paratestphp/paratest 并行运行测试。这可以显着提高测试速度。需要考虑以下事项

  1. 您的 doctrine 包配置需要在数据库名称中包含 paratest 的 TEST_TOKEN 环境变量。这是为了使每个并行进程都有自己的数据库。例如
  2. 如果使用 DAMADoctrineTestBundleparatestphp/paratest < 7.0,则需要将 --runner 选项设置为 WrapperRunner。这是为了使数据库每个进程重置一次(如果没有此选项,则每个测试类重置一次)。

    1
    vendor/bin/paratest --runner WrapperRunner
  3. 如果在禁用调试模式的情况下运行,则需要调整 禁用调试模式 代码,如下所示

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    // tests/bootstrap.php
    // ...
    if (false === (bool) $_SERVER['APP_DEBUG'] && null === ($_SERVER['TEST_TOKEN'] ?? null)) {
        /*
         * Ensure a fresh cache when debug mode is disabled. When using paratest, this
         * file is required once at the very beginning, and once per process. Checking that
         * TEST_TOKEN is not set ensures this is only run once at the beginning.
         */
        (new Filesystem())->remove(__DIR__.'/../var/cache/test');
    }

禁用调试模式

在您的 .env.test 文件中,您可以设置 APP_DEBUG=0,以便在不使用调试模式的情况下运行测试。这可以大大加快您的测试速度。您需要确保在运行测试套件之前清除缓存。最好的方法是在您的 tests/bootstrap.php 中执行此操作

1
2
3
4
5
6
// tests/bootstrap.php
// ...
if (false === (bool) $_SERVER['APP_DEBUG']) {
    // ensure fresh cache
    (new Symfony\Component\Filesystem\Filesystem())->remove(__DIR__.'/../var/cache/test');
}

降低密码编码器工作因子

如果您有很多处理编码密码的测试,这将导致这些测试不必要地慢。您可以通过降低编码器的工作因子来提高速度

1
2
3
4
5
6
7
8
9
# config/packages/test/security.yaml
encoders:
    # use your user class name here
    App\Entity\User:
        # This should be the same value as in config/packages/security.yaml
        algorithm: auto
        cost: 4 # Lowest possible value for bcrypt
        time_cost: 3 # Lowest possible value for argon
        memory_cost: 10 # Lowest possible value for argon

预编码密码

通过 bin/console security:encode-password 使用已知值预编码用户密码,并在 defaults() 中设置它。将已知值作为工厂上的 const 添加

1
2
3
4
5
6
7
8
9
10
11
12
class UserFactory extends PersistentProxyObjectFactory
{
    public const DEFAULT_PASSWORD = '1234'; // the password used to create the pre-encoded version below

    protected function defaults(): array
    {
        return [
            // ...
            'password' => '$argon2id$v=19$m=65536,t=4,p=1$pLFF3D2gnvDmxMuuqH4BrA$3vKfv0cw+6EaNspq9btVAYc+jCOqrmWRstInB2fRPeQ',
        ];
    }
}

现在,在您的测试中,当您需要访问使用 UserFactory 创建的用户的未编码密码时,请使用 UserFactory::DEFAULT_PASSWORD

非 Kernel 测试

Foundry 可用于标准 PHPUnit 单元测试(仅扩展 PHPUnit\Framework\TestCase 而不是 Symfony\Bundle\FrameworkBundle\Test\KernelTestCase 的 TestCase)。这些测试仍然需要使用 Factories trait 来启动 Foundry,但不会提供 doctrine。在这些测试中创建的工厂将不会被持久化(无需调用 ->withoutPersisting())。由于 bundle 在这些测试中不可用,因此您拥有的任何 bundle 配置都不会被拾取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
use App\Factory\PostFactory;
use PHPUnit\Framework\TestCase;
use Zenstruck\Foundry\Test\Factories;

class MyUnitTest extends TestCase
{
    use Factories;

    public function some_test(): void
    {
        $post = PostFactory::createOne();

        // $post is not persisted to the database
    }
}

您需要手动配置 Foundry。不幸的是,这可能意味着在此处复制您的 bundle 配置。

1
2
3
4
5
6
7
8
9
// tests/bootstrap.php
// ...

Zenstruck\Foundry\Test\UnitTestConfig::configure(
    instantiator: Zenstruck\Foundry\Object\Instantiator::withoutConstructor()
        ->allowExtra()
        ->alwaysForce(),
    faker: Faker\Factory::create('fr_FR')
);

注意

具有必需构造函数参数的 作为服务的工厂作为服务的 Stories 在非 Kernel 测试中不可用。容器不可用于解析它们的依赖项。最简单的解决方法是将测试设为 Symfony\Bundle\FrameworkBundle\Test\KernelTestCase 的实例,以便容器可用。

故事

如果您发现测试的安排步骤变得复杂(加载大量 fixtures)或在测试和/或您的开发 fixtures 之间复制逻辑,则 Stories 很有用。它们用于将特定的数据库状态提取到 story 中。Stories 可以在您的 fixtures 和测试中加载,它们也可以依赖于其他 stories。

使用 maker 命令创建一个 story

1
$ bin/console make:story Post

注意

src/Story 中创建 PostStory.php,添加 --test 标志以在 tests/Story 中创建。

修改 build 方法以设置此 story 的状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// src/Story/PostStory.php

namespace App\Story;

use App\Factory\CategoryFactory;
use App\Factory\PostFactory;
use App\Factory\TagFactory;
use Zenstruck\Foundry\Story;

final class PostStory extends Story
{
    public function build(): void
    {
        // create 10 Category's
        CategoryFactory::createMany(10);

        // create 20 Tag's
        TagFactory::createMany(20);

        // create 50 Post's
        PostFactory::createMany(50, function() {
            return [
                // each Post will have a random Category (created above)
                'category' => CategoryFactory::random(),

                // each Post will between 0 and 6 Tag's (created above)
                'tags' => TagFactory::randomRange(0, 6),
            ];
        });
    }
}

在您的测试、开发 fixtures 甚至其他 stories 中使用新的 story

1
2
3
PostStory::load(); // loads the state defined in PostStory::build()

PostStory::load(); // does nothing - already loaded

注意

在 stories 中持久化的对象在每次测试后都会被清除(除非它是 全局状态 Story)。

故事作为服务

如果您的 stories 需要依赖项,您可以将它们定义为服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// src/Story/PostStory.php

namespace App\Story;

use App\Factory\PostFactory;
use App\Service\ServiceA;
use App\Service\ServiceB;
use Zenstruck\Foundry\Story;

final class PostStory extends Story
{
    private $serviceA;
    private $serviceB;

    public function __construct(ServiceA $serviceA, ServiceB $serviceB)
    {
        $this->serviceA = $serviceA;
        $this->serviceB = $serviceB;
    }

    public function build(): void
    {
        // can use $this->serviceA, $this->serviceB here to help build this story
    }
}

如果使用标准 Symfony Flex 应用程序,这将自动装配/自动配置。如果不是,请注册服务并使用 foundry.story 标记。

故事状态

stories 的另一个功能是它们能够记住它们创建的对象,以便稍后引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Story/CategoryStory.php

namespace App\Story;

use App\Factory\CategoryFactory;
use Zenstruck\Foundry\Story;

final class CategoryStory extends Story
{
    public function build(): void
    {
        $this->addState('php', CategoryFactory::createOne(['name' => 'php']));

        // factories are created when added as state
        $this->addState('symfony', CategoryFactory::new(['name' => 'symfony']));
    }
}

稍后,您可以在创建其他 fixtures 时访问 story 的状态

1
2
3
4
PostFactory::createOne(['category' => CategoryStory::get('php')]);

// or use the magic method (functionally equivalent to above)
PostFactory::createOne(['category' => CategoryStory::php()]);

提示

与工厂不同,stories 不与特定类型绑定,因此它们不能是通用的,但是您可以利用魔术方法和 PHPDoc 来改进自动完成并修复 stories 的静态分析问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/Story/CategoryStory.php

namespace App\Story;

use App\Factory\CategoryFactory;
use Zenstruck\Foundry\Story;

/**
 * @method static Category php()
 */
final class CategoryStory extends Story
{
    public function build(): void
    {
        $this->addState('php', CategoryFactory::createOne(['name' => 'php']));
    }
}

现在您的 IDE 将知道 CategoryStory::php() 返回类型为 Category 的对象。

使用魔术方法也不需要在 story 上预先调用 ::load(),它将自行初始化。

注意

Story 状态在每次测试后都会被清除(除非它是 全局状态 Story)。

故事池

Stories 可以存储(作为状态)对象的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/Story/ProvinceStory.php

namespace App\Story;

use App\Factory\ProvinceFactory;
use Zenstruck\Foundry\Story;

final class ProvinceStory extends Story
{
    public function build(): void
    {
        // add collection to a "pool"
        $this->addToPool('be', ProvinceFactory::createMany(5, ['country' => 'BE']));

        // equivalent to above
        $this->addToPool('be', ProvinceFactory::new(['country' => 'BE'])->many(5));

        // add single object to a pool
        $this->addToPool('be', ProvinceFactory::createOne(['country' => 'BE']));

        // add single object to single pool and make available as "state"
        $this->addState('be-1', ProvinceFactory::createOne(['country' => 'BE']), 'be');
    }
}

可以从测试、fixtures 或其他 stories 中的池中获取对象

1
2
3
4
ProvinceStory::getRandom('be'); // random Province|Proxy from "be" pool
ProvinceStory::getRandomSet('be', 3); // 3 random Province|Proxy's from "be" pool
ProvinceStory::getRandomRange('be', 1, 4); // between 1 and 4 random Province|Proxy's from "be" pool
ProvinceStory::getPool('be'); // all Province|Proxy's from "be" pool

Bundle 配置

由于 bundle 旨在在您的 devtest 环境中使用,因此您希望每个环境的配置都匹配。最简单的方法是将 YAML 锚点when@dev/when@test 一起使用。这样,只有一个地方可以设置您的配置。

1
2
3
4
5
6
7
# config/packages/zenstruck_foundry.yaml

when@dev: &dev
    zenstruck_foundry:
        # ... put all your config here

when@test: *dev # "copies" the config from above

完整默认 Bundle 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
zenstruck_foundry:

    # Whether to auto-refresh proxies by default (https://symfony.ac.cn/bundles/ZenstruckFoundryBundle/current/index.html#auto-refresh)
    auto_refresh_proxies: null

    # Configure faker to be used by your factories.
    faker:

        # Change the default faker locale.
        locale:               null # Example: fr_FR

        # Random number generator seed to produce the same fake values every run
        seed:                 null # Example: '1234'

        # Customize the faker service.
        service:              null # Example: my_faker

    # Configure the default instantiator used by your factories.
    instantiator:

        # Use the constructor to instantiate objects.
        use_constructor:      ~

        # Whether or not to allow extra attributes.
        allow_extra_attributes: false

        # Whether or not to skip setters and force set object properties (public/private/protected) directly.
        always_force_properties: false

        # Customize the instantiator service.
        service:              null # Example: my_instantiator
    orm:
        reset:

            # DBAL connections to reset with ResetDatabase trait
            connections:

                # Default:
                - default

            # Entity Managers to reset with ResetDatabase trait
            entity_managers:

                # Default:
                - default

            # Reset mode to use with ResetDatabase trait
            mode:                 schema # One of "schema"; "migrate"
    mongo:
        reset:

            # Document Managers to reset with ResetDatabase trait
            document_managers:

                # Default:
                - default

    # Array of stories that should be used as global state.
    global_state:         []

    make_factory:

        # Default namespace where factories will be created by maker.
        default_namespace:    Factory
    make_story:

        # Default namespace where stories will be created by maker.
        default_namespace:    Story
本作品,包括代码示例,根据 Creative Commons BY-SA 3.0 许可协议获得许可。
目录
    版本