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()
才能实际将它们持久化到数据库。
提示
如果您想为所有对象工厂默认禁用持久化
- 创建一个扩展
PersistentProxyObjectFactory
的抽象工厂。 - 如上所示覆盖
initialize()
方法。 - 让您的所有工厂都从此扩展。
与 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()
- ...
注意
您仍然可以在测试中访问 全局状态 Stories 的 Story 状态,并且它们仍然只加载一次。
注意
当使用全局状态时,需要 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 并行运行测试。这可以显着提高测试速度。需要考虑以下事项
- 您的 doctrine 包配置需要在数据库名称中包含 paratest 的
TEST_TOKEN
环境变量。这是为了使每个并行进程都有自己的数据库。例如 如果使用 DAMADoctrineTestBundle 和
paratestphp/paratest
< 7.0,则需要将--runner
选项设置为WrapperRunner
。这是为了使数据库每个进程重置一次(如果没有此选项,则每个测试类重置一次)。1
vendor/bin/paratest --runner WrapperRunner
如果在禁用调试模式的情况下运行,则需要调整 禁用调试模式 代码,如下所示
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
的 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
的实例,以便容器可用。
故事
如果您发现测试的安排步骤变得复杂(加载大量 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 旨在在您的 dev 和 test 环境中使用,因此您希望每个环境的配置都匹配。最简单的方法是将 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