VoltelExtraFoundryBundle¶
A wrapper for zenstruck/foundry
with extra speed persisting thousands of entities¶
Contents¶
Seeding the development database with fixture entities¶
The recommended way to seed the database with sample data while developing is to use DoctrineFixturesBundle. To install Doctrine Fixture bundle, follow the instructions given on DoctrineFixturesBundle page of Symfony docs.
Steps to follow to seed the database¶
In general, the steps required to seed the development database with entities
aren’t much different with VoltelExtraFoundryBundle from when you use
vanilla zenstruck/foundry
bundle.
The difference is mostly with the implementation of stories
(classes extending from Zenstruck\Foundry\Story
).
VoltelExtraFoundryBundle offers a EntityProxyPersistService
(described in this chapter)
and SetUpFixtureEntityService
(described in “How to set up custom entities in a test” section)
which make populating a database easy to code, clear to read and fast to execute.
First, create your Doctrine entities.
Then, for each entity, create a corresponding custom factory class extending
Zenstruck\Foundry\ModelFactory
as described in Model Factories.For reasons described elsewhere, it is recommended to extend
Voltel\ExtraFoundryBundle\Foundry\Factory\AbstractFactory
class which, in its turn, extendsZenstruck\Foundry\ModelFactory
class. But it’s not a big deal if you don’t.Use your custom factories inside your custom story classes as described in Stories.
For example, inside the
CustomerStory
class, you can create a batch of customers (Customer
entities) usingCustomerFactory
and, for each customer, create one or several addresses (Address
entities) usingAddressFactory
.To get automatic access to the bundle’s
Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService
service, extend your story class fromVoltel\ExtraFoundryBundle\Foundry\Story\AbstractStory
class.Otherwise, use usual Symfony’s dependency injection tricks to get access to the persist service inside your story class extending the
Zenstruck\Foundry\Story
class (e.g. type-hintVoltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService
in the__construct
method of your story class).Use your custom stories inside Doctrine Fixture classes.
So, your custom Fixture class extending from
Doctrine\Bundle\FixturesBundle\Fixture
may look like this:class DataFixture extends Fixture { public function load(ObjectManager $manager) { ProductStory::load(); // see example of CustomerStory class below CustomerStory::load(); OrderStory::load(); } }
For other examples of Using with DoctrineFixtureBundle read
zenstruck/foundry
docs.
Create entities with delayed persist/flush¶
Create separate entities with delayed persist/flush¶
Use Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService::createOne()
method
to create a single entity.
Pass Zenstruck\Foundry\Factory
object as the first parameter.
In the second optional parameter, you can pass either an array of attributes for the factory,
or a callback function to return an array of attributes
as described in Attributes section of zenstruck/foundry
docs.
The last (third) parameter is optional as well. It may accept an array with names of the factory states (i.e. names of the factory class methods, as described in Reusable Model Factory “States”).
Note
States in the final parameter can be presented as just state names for methods that do not require arguments, or an associative array elements where an array key is the state/method name and an array value is the method argument(s).
If a state method has several arguments, they should be listed in an array (see “state three” in the example below). If a state method has only one argument, it can be presented w/o a wrapping array (see “state_four” in the example below):
// array representing states for "createOne()" and "createMany()" methods
[
'state_one',
'state_two',
'state_three' => ['argument_1', 'argument_2'],
'state_four' => 10,
]
// in CustomerStory.php
use Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService;
use Zenstruck\Foundry\Story;
class CustomerStory extends Story
{
private $persistService;
/**
* If your story class extends
* "Voltel\ExtraFoundryBundle\Foundry\Story\AbstractStory"
* the "EntityProxyPersistService" will be injected automatically
* with standard Symfony configuration
*/
public function __construct(EntityProxyPersistService $persistService)
{
$this->persistService = $persistService;
}
public function build(): void
{
$this->createCustomer();
}
private function createCustomer()
{
// Create a factory that won't immediately persist
$customer_factory = CustomerFactory::new()->withoutPersisting();
//
// Although not explicitly used here, "withoutPersisting()"
// will be automatically invoked for this factory
// in persistService before creating entities
$address_factory = AddressFactory::new();
for ($i = 0; $i < 20; $i++) {
$n_address_count = rand(1, 3);
$this->createCustomerWithAddresses($customer_factory, $address_factory, $n_address_count);
}
// Persist and flush new entities as a batch operation.
// This takes less time than persisting/flushing each entity
$this->persistService->persistAndFlushAll();
}
private function createCustomerWithAddresses(
CustomerFactory $customer_factory,
AddressFactory $address_factory,
int $n_address_count = 1
)
{
/** @var Customer $customer_entity */
$customer_entity = $this->persistService->createOne($customer_factory);
for ($i = 0; $i < $n_address_count; $i++) {
/** @var Address $address_entity */
$address_entity = $this->persistService->createOne($address_factory);
$address_entity->setCustomer($customer_entity);
//
// or use a specialized collection manipulation method
// $customer_entity->addElementToAddressCollection($address_entity);
}
}
}
- The
EntityProxyPersistService:createOne()
method is responsible for several things: Applying some custom factory states to the factory stub, if provided. An array of state names is optional and can be passed as the third (last) argument.
Factory will be cloned to avoid immediate persistence, i.e.
Zenstruck\Foundry\Factory::withoutPersisting()
method is going to be invoked.An array of optional arguments for new entities, if provided, will be directly passed as an argument to
Zenstruck\Foundry\Factory::create()
method.A new
Zenstruck\Foundry\Proxy
object returned byFactory:create()
will be internally put in a “proxy jar” to be later persisted by corresponding entity manager. Persisting entities in batches speeds up the whole process.
Create batches of entities with delayed persist/flush¶
Use Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService::createMany()
method to create several entities at once.
Similar to EntityProxyPersistService::createOne()
,
pass the factory stub as the first argument
and the number of entities to create as the second argument.
This method may conveniently be modified with the third parameter, that can accept either an array of attributes or, more importantly, a callback that returns an array of attributes. This enables random values for every of the created entities.
You can find examples of using a callback with createMany()
in several sections of the zenstruck/foundry
docs,
e.g. in Using with DoctrineFixtureBundle and Many-To-One sections.
Here is an example from test suite of this bundle:
// in OrderStory.php
private function createOrderItemsForOrder(Order $order_entity)
{
$factory_order_item = OrderItemFactory::new()
//->withoutPersisting()
->forOrder($order_entity);
// This will create a batch of OrderItem entities,
// each with its unique "unitCount" value
//
$this->persistService->createMany($factory_order_item, rand(1, 4), function() {
return [
'unitCount' => rand(1, 20)
];
});
}
I refer to the first argument as a “factory stub” because that the factory can be further modified by passing the fourth (last) argument – an array of state names (states) or a callback returning such an array.
States are just method names in the model factory class that will be invoked on the factory with the result that the factory will be cloned with new attributes as described in Reusable Model Factory “States”.
Setting relationships between entities¶
The zenstruck/foundry
library makes it easy to create related entities
of @ORM\ManyToOne
associations right from inside the factory class
(i.e. by providing a factory for a particular entity property,
as described under TIP 2 in Many-To-One section).
// in OrderItemFactory.php
protected function getDefaults(): array
{
$faker = self::faker();
return [
'notes' => $faker->realText(),
// To randomly assign a product for this order item.
// Products must be seeded in the database
// before orders and order items.
'product' => ProductFactory::repository()->random(),
];
}
The same is possible for Many-To-Many relationship.
I prefer to have this logic outlined inside a story, where related entities are created and referenced explicitly:
// in ProductStory.php
private function createProduct(ProductFactory $factory, int $n_entity_count = 20)
{
$repo_category = CategoryFactory::repository();
for ($i = 0; $i < $n_entity_count; $i++) {
/** @var Product $product */
$product = $this->persistService->createOne($factory);
// Add from 1 to 3 categories to each Product
// "Product" and "Category" have a Many-To-Many relationship
$a_category_proxies = $repo_category->randomRange(1, 3);
foreach ($a_category_proxies as $oThisCategoryProxy) {
/** @var Category $oThisCategoryEntity */
$oThisCategoryEntity = $oThisCategoryProxy->object();
$product->addElementToCategoryCollection($oThisCategoryEntity);
}
}
}
The example above could be rewritten to be more succinct, as described in Many-To-Many section:
// in ProductStory.php
private function createProduct(ProductFactory $factory, int $n_entity_count = 20)
{
$repo_category = CategoryFactory::repository();
$this->persistService->createMany($factory, $n_entity_count, function() use ($repo_category) {
return [
'categoryCollection' => $repo_category->randomRange(1, 3),
];
});
}
Note
In the code example above, if there is no “setter” for “categoryCollection” property, the factory should use a custom instantiator to “force-set” it.
This can only be a solution for unidirectional associations like in this example,
where Product
holds a unidirectional @ORM\Many-To-Many association
with Category
entities.
For bidirectional associations,
you will most likely need a more sophisticated setter
that will establish the opposite side of the relationship.
For example, one Customer
can be related to many Address
entities,
and each Address
entity is related to one Customer
(bidirectional @ORM\One-To-Many associations).
In this case, the setter for Customer::addressCollection
property
could look like this:
// in Customer.php
/**
* @param Address[]|null $addresses
* @return Customer
*/
public function setAddressCollection(?array $addresses): Customer
{
$this->addressCollection = new ArrayCollection($addresses);
foreach ($addresses as $address) {
$address->setCustomer($this);
}
return $this;
}
Alternatively, you could use a specialized collection manipulation method
similar to the addElementToCategoryCollection()
method
(usage is shown in the example above):
// in Customer.php
public function addElementToAddressCollection(Address $address)
{
if ($this->addressCollection->contains($address)) return;
$this->addressCollection->add($address);
$address->setCustomer($this);
}
Using factory states to populate arrays and establish relationships between entities¶
While Reusable Model Factory “States” is a great way to set model attributes
in a more explicit way in terms of readability,
with zenstruck/foundry
it is not yet possible to manipulate array values,
particularly, to add individual values to arrays using states.
If you extended your factory class from Voltel\ExtraFoundryBundle\Foundry\Factory\AbstractFactory
class,
you will have a AbstractFactory::addValuesTo()
method at your disposal.
This method can be used to do exactly what it says:
add values to an array stored in a custom model attribute.
Let’s see an example (it can be found in a Voltel\ExtraFoundryBundle\Tests\Setup\Factory\ProductFactory
class):
// in ProductFactory.php
public function car(): self
{
return $this->addState([
// Note: this will add two values to existing values of "categories" attribute
'categories' => $this->addValuesTo('categories', ['car', 'vehicle']),
]);
}
As ModelFactory::addState()
method will create a clone of current factory,
normally, the state that modifies some attribute
will override all previous values,
a behavior that is not sometimes desirable.
So, AbstractFactory::addValuesTo()
method will take
the previous value of the attribute with the name passed in the first argument,
and modify it to be an array holding all previous values and the new values,
passed in the array in the second argument.
Imagine, you modified your ProductFactory
with two states: car
and luxury
:
The luxury
state is similar to the car
state and might look like this:
// in ProductFactory.php
public function luxury(): self
{
return $this->addState([
// Note: this will add a "luxury" value to existing values of "categories" attribute
'categories' => $this->addValuesTo('categories', ['luxury']),
]);
}
With this setup, by the time you instantiate your Product
entity,
the attributes will look like this:
// in ProductFactory.php
protected function initialize()
{
return $this
->instantiateWith((new Instantiator())
->allowExtraAttributes(['categories'])
)
->beforeInstantiate(function($attributes) {
// $attributes['categories'] => ['luxury', 'car', 'vehicle']
return $attributes;
})
Then, in the afterInstantiate
callback,
you can find those specific Category
entities
in the database and assign them to the Product
:
// in ProductFactory.php
protected function initialize()
{
// ...
$this->afterInstantiate(function(Product $product, $attributes) {
// If explicit category names were assigned by factory states,
// find related categories and assign to the product
if (!empty($attributes['categories'])) {
foreach ((array) $attributes['categories'] as $c_this_category) {
$category_proxy = CategoryFactory::findOrCreate([
'categoryName' => $c_this_category
]);
/** @var Category $category_entity */
$category_entity = $category_proxy->object();
$product->addElementToCategoryCollection($category_entity);
}
}
});
// ...
return $this;
}
When your setup will do just fine with random categories
assigned to Product
entities, there are obviously
simpler ways to fetch random Category
entities
and set them on Product::categoryCollection
.
But when you need some specific product categories,
moving this logic from stories into a model factory itself
feels like a better alternative,
and using states for this task makes it even more elegant.
With just one line of code, you can create a batch of entities
and establish some of the relationships “in one go”.
// in story or test class
public function createLuxuryCars()
{
$factory_product_stub = ProductFactory::new();
// create 20 Product entities in categories "luxury", "car" and "vehicle" with random "productName"
$this->persistService->createMany($factory_product_stub, 20, function(Generator $faker) {
return [
'productName' => $faker->randomElement([
'2021 Porsche Boxster', '2021 Genesis G80', '2021 Volvo S90', '2021 BMW 7 Series',
'2021 Chevrolet Corvette', '2021 Audi TT', '2021 Audi A5', '2020 Mercedes-Benz SL',
'2021 Genesis G90', '2020 Kia K900', '2020 Mercedes-Benz E-Class',
'2020 Audi R8', '2020 Mercedes-Benz S-Class',
]),
];
}, ['luxury', 'car', 'recent', 'promoted']);
$this->persistService->persistAndFlushAll();
}
Note
In the example above, states recent
and promoted
will modify model attributes (registeredAt
and inPromotion
, respectively),
and states luxury
and car
will add values to a custom attribute categories
which is used in ProductFactory::afterInstantiate()
callback
to find related Category
entities in the database
and assign them to the products.
Creating entities for tests using VoltelExtraFoundryBundle services¶
One of the ways to set up your testing environment with
zenstruck/foundry
bundle is to use Global State. If you want some initial database state to be used for all tests in the test suite, follow instructions in How to use Global State in your app tests section below.You can also create only those entities that are needed to run a specific test. Read in How to set up custom entities in a test section on how you can do it a little easier with VoltelExtraFoundryBundle.
Those two approaches listed above are not mutually exclusive.
You can have some Global State database entities created for all tests,
and for tests that need some additional entities,
you can create those at the start of the test function
in the “arrange” phase.
Learn about how you can use zenstruck/foundry
for “Arrange”, “Act”, “Assert” testing patterns.
VoltelExtraFoundryBundle can help to do either of the two things with just a couple lines of code.
How to use Global State in your app tests¶
The Global State approach saves time by eliminating the need
to seed your database with the same initial data before every test.
You can load zenstruck/foundry
stories
that will create the initial database state,
using factories and even other stories.
This reduces time needed to run tests in your test suite.
The initial setup of the Global State
in tests/bootstrap.php
of your application can look like this:
// in tests/bootstrap.php //... Zenstruck\Foundry\Test\TestState::addGlobalState(function () { // place all initial state loading logic in one specialized class \App\DataFake\Foundry\Story\GlobalStory::load(); // or just load several stories one by one, similar to fixtures \App\DataFake\Foundry\Story\UserStory::load(); \App\DataFake\Foundry\Story\ProductStory::load(); \App\DataFake\Foundry\Story\OrderStory::load(); });
But you can save even more time while running a test suite by loading the initial state from a MySQL dump file (produced in advance) instead of creating and persisting entities with factories and stories, even if it’s only done once for all tests in a test suite.
Instead of creating entities “on the fly”,
we run a set of MySQL INSERT
commands from the dump file.
To do this, follows these steps:
Using fixtures, seed your development database (in “dev” environment) with sample data you’d like to use as a Global State for your tests.
Use
zenstruck/foundry
stories as described in Steps to follow to seed the database.Place your dump file inside your project (e.g. in
var/mysql_dumps
directory).Configure your VoltelExtraFoundryBundle to locate the dump file with SQL queries to re-create the initial database state in test environment.
Create the
GlobalStory
class withGlobalStory::load()
method to load/import MySQL dump file into a test database before the test suite is run.
Preparation¶
Set up a new MySQL test database. In MySQL, configure the test user (probably the same as for your dev
environment database) with appropriate schema privileges. Then, create the schema.
Configure
DATABASE_URL
e.g. in.env.test
or.env.test.local
:# in ".env.test.local" DATABASE_URL=mysql://my_username:my_password@127.0.0.1:3306/my_database_test?serverVersion=5.7
Create the schema/database for
test
environment. For example, you can do it from your application, with the console command:> php bin/console doctrine:schema:create --env=test
Step 1: Seed the development database¶
// in UserFixture.php
use App\DataFake\Foundry\Story\UserStory;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
class UserFixture extends Fixture
{
public function load(ObjectManager $manager)
{
UserStory::load();
}
}
In this example, UserStory::build()
method will contain all the logic
to create User
entity and other related entities
(e.g. UserGroup
or Address
, etc.).
Conveniently, UserStory
might optionally use
Voltel\ExtraFoundryBundle\Service\FixtureEntity\SetUpFixtureEntityService
to create separate entities or
Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService
to facilitate their persistence into the database,
or use zenstruck/foundry
Model Factories straightforward
to create entities and persist/flush them into the database.
Then, when fixture classes are ready, load fixtures as usual (see Loading Fixtures section of Symfony docs). Run in the terminal:
> php bin/console doctrine:fixtures:load
Step 2: Dump your MySQL dev database into a file¶
I find it easy to export MySQL data with MySQL Workbench graphic user interface, but there are definitely other ways to do it (e.g. with mysqldump
terminal command or phpmyadmin
export). See examples of mysqldump
usage at mysqldump program examples.
> mysqldump --user=my_username --password my_database_dev > my_test_dump.sql
Important
Make sure that MySQL dump should contain DROP TABLE
queries
along with CREATE TABLE
queries:
existing data and indexes might prevent inserting new records
and therefore need to be taken out of the way.
Step 3: Place the dump file inside your project¶
You can place your exported dump file anywhere in your project, since the location of the dump file should be configured (see next step). I place it in var/mysql_dumps
folder.
your_project/
└─ var/
├─ mysql_dumps/
├─ cache/
└─ log/
Step 4: Configure VoltelExtraFoundryBundle¶
In your project create a new configuration file,
e.g. voltel_extra_foundry.yaml
in config/packages/test
directory.
your_project/
└─ config/
└─ packages/
└─ test/
An example of configuration is provided here:
voltel_extra_foundry:
# Database (persistence layer) type: "mysql" is currently the only supported option.
database_type: mysql
# Filesystem path of the directory where database dump files are located.
dump_directory_path: '%kernel.project_dir%/var/mysql_dumps'
# File name (w/o file path) of database dump file that will be loaded in the current database.
dump_file_name: my_test_dump.sql
# Doctrine connection name to use for data loading.
connection_name: default
Step 5. Create the GlobalStory
class¶
// in GlobalStory.php
namespace App\DataFake\Foundry\Story;
use Voltel\ExtraFoundryBundle\Service\FixtureLoad\SqlDumpLoaderService;
use Zenstruck\Foundry\Story;
class GlobalStory extends Story
{
private $sqlDumpLoaderService;
public function __construct(
SqlDumpLoaderService $sqlDumpLoaderService
)
{
$this->sqlDumpLoaderService = $sqlDumpLoaderService;
}
public function build(): void
{
$this->sqlDumpLoaderService->loadSqlDump();
}
}
Voltel\ExtraFoundryBundle\Service\FixtureLoad\SqlDumpLoaderService
service will do two things:
Using the
database_type
bundle configuration option, it will locate an appropriate service implementingVoltel\ExtraFoundryBundle\Service\FixtureLoad\LoadDumpFromDatabaseInterface
.Currently, there is only one service implementing this interface responsible for loading MySQL dumps:
Voltel\ExtraFoundryBundle\Service\FixtureLoad\MySqlDumpFileLoadingService
.The
LoadDupmFromDatabaseInterface::loadSqlDump()
method will do the following:
Check the presence of file configured in
dump_directory_path
anddump_file_name
options;Execute every SQL query in the dump file skipping only empty strings and strings starting with
--
(comments).Note
Name of the Doctrine provided PDO connection can be configured in
connection_name
bundle configuration option and has a value ofdefault
.Note
The SQL queries being executed are one of the following:
DROP TABLE IF EXISTS
;
CREATE TABLE
;
LOCK TABLES
;
INSERT INTO
;
UNLOCK TABLES
;Numerous auxiliary queries that look e.g. like:
/*!40101 SET NAMES utf8 */;
As a result, whenever you run your test suite, a Global State will load configured SQL dump in your test database.
How to set up custom entities in a test¶
In the “arrange” phase of a functional test, you will sometimes rely on some specific entities existing in the database. Moreover, these entities might be different for the same test with each new dataset returned by the data provider.
VoltelExtraFoundryBundle has a convenience service, Voltel\ExtraFoundryBundle\Service\FixtureEntity\SetUpFixtureEntityService
,
with the createEntities()
method
to create batches of entities “on the fly”
using an array with instructions as a blueprint.
The method will take a model factory, an array with instructions for entity “spawning”, and an optional flag whether to immediately persist newly created entities or leave this task to the caller.
“Explicit” syntax of spawning instructions¶
The “spawning” instructions are provided as an array of arrays (a chunk), where keys of the outer array can either be omitted or used as descriptive labels (e.g. for documentation purposes), and values are nested arrays with three optional keys:
// How many entities to create in this spawn.
// An integer. If "0", the instruction entrance will be skipped.
'count' => 5
// What states the factory should be modified with.
// The states are method names on the entity factory class.
// If states take no arguments, just list their names.
'states' => ['stateOne', 'stateTwo']
// If a state takes arguments, pass the state name as a key
// and an array of parameters as a value.
'states' => ['stateOne' => ['param_1', 'param_2'], 'stateTwo' => ['param_1']]
// If a state takes exactly one argument, the value can be passed w/o an array.
// The following two instructions are equivalent:
'states' => ['stateOne' => ['param_1'], 'stateTwo' => [5]]
'states' => ['stateOne' => 'param_1', 'stateTwo' => 5]
// Attributes with which the entities should be created.
// These attributes will override the "defaults" provided by the entity factory.
// The attribute values can be of any type, but most often they are scalar.
'attributes' => ['attributeOne' => 15, 'attributeTwo' => 'some string']
As usual, it is easier to see the usage with an example:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Zenstruck\Foundry\Test\Factories;
use Zenstruck\Foundry\Test\ResetDatabase;
use Voltel\ExtraFoundryBundle\Service\FixtureEntity\SetUpFixtureEntityService;
class MyTest extends KernelTestCase
{
use ResetDatabase, Factories;
private const SETUP_CUSTOMERS = [
'customer_1' =>['states' => ['american', 'human']],
'customer_2' =>['states' => ['ukrainian', 'human'],
'customer_3' =>['states' => ['human'], 'attributes' => ['firstName' => 'John', 'lastName' => 'Doe']],
'customer_4' =>['states' => ['human'], 'attributes' => ['firstName' => 'Богдан', 'lastName' => 'Мірошник']],
];
public function __construct(SetUpFixtureEntityService $entityService)
{
$this->setUpFixtureEntityService = $entityService;
}
protected function setUp(): void
{
$kernel = self::bootKernel();
// ...
}
public function testCreateEntities()
{
$factory_customer = CustomerFactory::new();
$this->setUpFixtureEntityService-> createEntities($factory_customer, self::SETUP_CUSTOMERS, true);
}
}
The factory that is provided in the first argument
of the createEntities()
method
will be used as a stub which will be modified
if “spawning” instructions have any states
listed.
In the second argument you should provide an array of arrays (a chunk) with spawning instructions. Bare this in mind if you use data providers, since a chunk can include only one item (one nested array), which may look a bit confusing.
The last argument to createEntities()
, if set to true
,
will signal to persist and flush all the entities
that Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService
has in its jars.
This is important if your tests use repository assertions
that expect certain entities to exist at some point in time.
Simplified (“implicit”) syntax of spawning instructions¶
If you create entities in “one go” (i.e. all instructions are provided at once),
as in the example above where createEntities()
is passed a chunk of spawning instructions at once in the second parameter,
you might take advantage of a simplified instructions syntax.
The “implicit” (i.e. simplified) syntax,
as opposed to “explicit” syntax described above,
doesn’t rely on reserved array keys (i.e. "count"
, "states"
or "attributes"
)
but is resolved based on the following logic:
if the first array item has key “0” and its value is numeric, the numeric value will be interpreted as equivalent to the “count” key with the “explicit” syntax;
if a method exists on an entity factory with the name from an array item value (when the key in numeric), it will be interpreted as the name of the factory state to apply;
if a method exists on an entity factory with the name from an array item key (when the key in not numeric), it will be interpreted as name of the factory state to apply. In this case, this array item’s value will be interpreted as value(s) for this factory state.
all other key-value pairs will be interpreted as attributes for the factory
create()
method.
Important
With simplified syntax, do not use any custom array keys/values that cannot be interpreted as valid factory states or entity attributes. All unknown states or non-existent attributes will cause an error.
Note
If you want to have some custom data on the dataset array, e.g. for a test data provider, use “explicit” syntax where you can add custom keys.
With “explicit” syntax, you can add any custom keys that are not reserved (i.e. “count”, “states” or “attributes” keys) to an array with spawning instructions in order to pass additional information to data providers.
See the example below: the first set of instructions
defines a custom key “expect_error”
and the last set defines a custom key “exception_class”.
This can be used in tests along with data providers and methods like
expectException()
.
[ 'instructions set 1' => [ 'count' => 5, 'states' => ['withStaffCount' => 3], 'expect_error' => false ], 'instructions set 2' => [ 'count' => 5, 'states' => ['withStaffCount'], 'expect_error' => true ], 'instructions set 3' => [ 'count' => 5, 'states' => ['withStaffCount' => 'many'], 'exception_class' => BadMethodCallException::class ], ]
Using SetUpFixtureEntityService
with data providers¶
The same entity setup instructions that are used to create batches of entities can be used by data providers as data sets for test methods.
With PhpUnit Data Providers specification in mind,
remember that createEntities()
method takes
a chunk (an array of arrays) of instructions as the second parameter,
whether it has one set of instructions or more:
class MyOtherTest extends KernelTestCase
{
public const SETUP_PRODUCTS = [
'product_1' => ['count' => 10, 'states' => ['luxury', 'car'] ] ,
'product_2' => ['count' => 10, 'states' => ['ordinary', 'car'] ] ,
'product_3' => ['count' => 20, 'states' => ['jewelry'] ],
'product_4' => ['count' => 20, 'states' => ['furniture'] ],
'product_5' => ['count' => 10, 'states' => ['house'] ],
'product_6' => ['count' => 10, 'states' => ['luxury', 'apartment'] ],
'product_7' => ['count' => 10, 'states' => ['ordinary', 'apartment'] ],
];
//...
/**
* Asserts that count of created entities is as expected.
*
* @dataProvider productDataProviderOneSpawnInAChunk
*/
public function testCreateEntitiesWithDataProvider(
array $a_spawn_instructions,
int $n_expected_entity_count
)
{
// ...
$factory_product = ProductFactory::new();
$setUpFixtureEntityService->createEntities($factory_product, $a_spawn_instructions, true);
$repo = ProductFactory::repository();
$repo->assert()->count($n_expected_entity_count);
}
public function productDataProviderOneSpawnInAChunk()
{
foreach (self::SETUP_PRODUCTS as $c_label => $a_instructions_for_one_spawn) {
$n_expected_entity_count = (int) $a_instructions_for_one_spawn['count'] ?? 1;
// PhpUnit expects an array of arguments, so yield an array item
yield $c_label => [
// argument one - an array of arrays (a chunk) that holds instructions
// for one "spawn" in this case
[$a_instructions_for_one_spawn],
// argument two - an integer with expected entity count
$n_expected_entity_count
];
}
}
With “explicit” style of “spawning” instructions, you can configure the expected outcome of the test (i.e. whether to expect error/exception or not) and even specify the class name of the expected exception. For this, you need to add some logic both to the data provider and the test method itself:
// in MyTest class
private const STATES = SetUpFixtureEntityService::KEY_STATES;
private const ATTRS = SetUpFixtureEntityService::KEY_ATTRIBUTES;
private const COUNT = SetUpFixtureEntityService::KEY_COUNT;
// custom keys
private const ERROR = 'expect_error';
private const EXCEPTION = 'exception_fqcn';
private const SETUP_CUSTOMERS_WITH_ERRORS = [
'1. state w/o an obligatory parameter' => [self::ERROR => true, self::STATES => ['company', 'withStaffCount'], self::ATTRS => ['firstName' => 'Полтавські ковбаси']],
'2. state w/o an obligatory parameter, exception FQCN specified' => [
self::EXCEPTION => \ArgumentCountError::class,
self::STATES => ['company', 'withStaffCount'], self::ATTRS => ['firstName' => 'Полтавські ковбаси']
],
'3. unknown state' => [self::ERROR => true, self::STATES => ['company', 'unknownStateName'], self::ATTRS => ['firstName' => 'Полтавські ковбаси']],
'4. unknown state, exception FQCN specified' => [
self::EXCEPTION => \BadMethodCallException::class,
self::STATES => ['company', 'unknownStateName'], self::ATTRS => ['firstName' => 'Полтавські ковбаси']
],
'5. state w/ bad parameter' => [self::ERROR => true, self::STATES => ['company', 'withStaffCount' => 'this must be an integer'], self::ATTRS => ['firstName' => 'Texas Roadhouse']],
'6. state w/ bad parameter, exception FQCN specified' => [
self::EXCEPTION => \TypeError::class,
self::STATES => ['company', 'withStaffCount' => 'this must be an integer'],
],
'7. state w/ unknown attribute' => [self::ERROR => true, self::STATES => ['company', 'withStaffCount' => 5], self::ATTRS => ['unknownAttribute' => 'Texas Roadhouse']],
'8. state w/ unknown attribute, exception FQCN specified' => [
self::EXCEPTION => \InvalidArgumentException::class,
self::STATES => ['company'], self::ATTRS => ['unknownAttribute' => 'Texas Roadhouse'],
],
];
/**
* @dataProvider customerDataProviderForExplicitSetUpDefinition
*/
public function testCreateEntitiesWithParameterizedState(
array $a_spawn_instructions,
int $n_expected_entity_count,
bool $l_expect_error = false,
string $c_expect_exception_fqcn = null
)
{
// "Arrange" phase
/** @var SetUpFixtureEntityService $setUpFixtureEntityService */
$setUpFixtureEntityService = self::$container->get('test.voltel_extra_foundry.entity_setup');
$factory_customer = CustomerFactory::new(); // ->withoutPersisting();
if ($l_expect_error) {
$this->expectException($c_expect_exception_fqcn ?? \Throwable::class);
}//endif
// Try and create/persist a spawn of Customer entities with provided instructions
$setUpFixtureEntityService->createEntities($factory_customer, [$a_spawn_instructions], true);
$repo = CustomerFactory::repository();
$repo->assert()->count($n_expected_entity_count);
// ...
}
public function customerDataProviderForExplicitSetUpDefinition()
{
foreach (self::SETUP_CUSTOMERS_WITH_ERRORS as $c_label => $a_one_spawn_instructions) {
// This dataset should generate an exception
// 1) if there is a custom key "expect_error" set to "true", or
// 2) if there is a custom key "exception_fqcn" with FQCN of the expected exception,
$c_expect_exception_fqcn = $a_one_spawn_instructions[self::EXCEPTION] ?? null;
$l_expect_error = $a_one_spawn_instructions[self::ERROR] ?? is_null($c_expect_exception_fqcn);
// Count of entities to create can be found in a special key "count"
$n_expected_entity_count = $a_one_spawn_instructions[self::COUNT] ?? 1;
yield $c_label => [
$a_one_spawn_instructions, $n_expected_entity_count, $l_expect_error, $c_expect_exception_fqcn
];
}
}
To see more detailed examples of the SetUpFixtureEntityService
use
in the testing environment, look in Voltel\ExtraFoundryBundle\Tests\Service\FixtureEntity\SetUpFixtureEntityServiceTest
class source code.
On the whole, the arrange
phase of your tests may look neat
with only a couple of lines of code,
when all the instructions for creation (spawning) of tested entities
are given elsewhere (e.g., in class constants or data providers).
Testing VoltelExtraFoundryBundle¶
If you want to see examples of using VoltelExtraFoundryBundle and/or test it,
you should look into the tests
directory.
Two files in the root of the bundle are also important for testing set-up:
phpunit.xml.dist
cli-config.php
Bundle’s tests
directory structure¶
your_project/
└─ tests/
├─ Service/
│ ├─ FixtureEntity/
│ └─ FixtureLoad/
├─ Setup/
│ ├─ Entity/
│ ├─ Factory/
│ ├─ Kernel/
│ ├─ MySqlDump/
│ ├─ Service/
│ └─ Story/
└─ bootstrap.php
All tests are located in
tests/Service
directory.In
tests/Setup/Entity
directory, you will find entities that are going to be created during the tests.In
tests/Setup/Factory
directory, you will findzendstruck/foundry
model factories describing the related entities (one factory per entity).In
tests/Setup/Story
directory, you shall definitely look at the way entities are created and persisted using services from VoltelExtraFoundryBundle.Note
Inspect classes in
tests/Setup/Story
directory to see examples of suggestedVoltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService
usage.In
tests/Setup/Service
directory, you will find a faux service that is used inafterPersist
callback inProductFactory
class. It is needed in order to to change theslug
property onProduct
entity and assert during the test that theafterPersist
callback was indeed invoked.The
tests/Setup/Kernel
directory contains the only file withVoltel\ExtraFoundryBundle\Tests\Setup\Kernel\VoltelExtraFoundryTestingKernel
class where all services used during the tests are configured, including the services provided by VoltelExtraFoundryBundle and Doctrine.In
tests/Setup/MySqlDump
directory,mysql_dump_for_tests.sql
file contains a MySQL dump that is asserted during the tests to be properly loaded/imported into the database.File
bootstrap.php
was modified to retrieve the value ofDATABASE_URL
fromphpunit.xml.dist
and set it in the super global$_ENV
array to be later used when running Doctrine CLI commands (read below).
How to set up bundle tests¶
Overview¶
To set up testing with PhpUnit using a MySQL test database, several steps need to be done:
Configure the kernel class that is used by the testing suite;
Create a test MySQL database (e.g. “voltel_extra_foundry_test”);
Step 1: Configure the kernel class¶
The testing kernel is configured in
Voltel\ExtraFoundryBundle\Tests\Setup\Kernel\VoltelExtraFoundryTestingKernel
class.// in VoltelExtraFoundryTestingKernel.php class VoltelExtraFoundryTestingKernel extends Kernel { // ... public function registerContainerConfiguration(LoaderInterface $loader) { $loader->load(function (ContainerBuilder $container) use ($loader) { // Services that are used in tests // ... // Configure Doctrine $container->loadFromExtension('doctrine', [ 'dbal' => [ 'default_connection' => 'default', 'connections' => [ 'default' => [ 'url' => $_ENV['DATABASE_URL'], 'logging' => false, 'override_url' => true, ] ], ], 'orm' => [/* ... */] ]); }); } }//end of classThe connection URL in the code snippet above depends on the environmental variable DATABASE_URL which must be configured in
phpunit.xml.dist
:<!-- in phpunit.xml.dist --> <php> <!-- ... --> <env name="DATABASE_URL" value="mysql://testuser:password@127.0.0.1:3306/voltel_extra_foundry_test?serverVersion=5.7" /> <env name="KERNEL_CLASS" value="Voltel\ExtraFoundryBundle\Tests\Setup\Kernel\VoltelExtraFoundryTestingKernel" /> </php>Important
Change
DATABASE_URL
definition inphpunit.xml.dist
to reflect your MySQL test user’s credentials and test database/schema name.Note
The configuration in
phpunit.xml.dist
also contains a definition for another environmental variable,KERNEL_CLASS
, which is internally used bySymfony\Bundle\FrameworkBundle\Test\KernelTestCase
.
Step 2: Create a test MySQL database¶
In MySQL, create a new database, e.g. “voltel_extra_foundry_test”. Configure your test user to have appropriate privileges for this database.
> mysql --user=root --password
mysql> CREATE USER IF NOT EXISTS 'testuser'@'localhost' IDENTIFIED BY 'password';
mysql> CREATE DATABASE IF NOT EXISTS voltel_extra_foundry_test;
mysql> GRANT ALL ON voltel_extra_foundry_test.* TO 'testuser'@'localhost';
mysql> quit;
Note
Database name, host URL, username, user password must match those
configured in phpunit.xml.dist
for DATABASE_URL
environmental variable (see above).
Step 3: Set up MySQL schema¶
The cli-config.php
in the root of the project is required for Doctrine bundle CLI tool. The file is quite short; it has only a few things to do:
boot our custom kernel (
Voltel\ExtraFoundryBundle\Tests\Setup\Kernel\VoltelExtraFoundryTestingKernel
);retrieve entity manager from the kernel;
return an instance of
Symfony\Component\Console\Helper\HelperSet
for the provided entity manager.
With Doctrine CLI configured, run in the terminal:
$ vendor/bin/doctrine orm:schema-tool:create
or, in Windows command prompt:
> "vendor/bin/doctrine" orm:schema-tool:create
Run bundle tests¶
Run the tests with this command:
> "vendor/bin/simple-phpunit"
Note
The following exact command was run under Windows
to obtain the MySQL test database state and produce the dump
that is located in tests/Setup/MySqlDump/mysql_dump_for_tests.sql
.
> "vendor/bin/simple-phpunit" tests/Service/FixtureEntity/EntityProxyPersistServiceTest.php --filter=testStories
VoltelExtraFoundryBundle¶
- delayed persistence¶
- delay persistence¶
The practice of creating new entities with Doctrine when one or many entities are not immediately registered with Doctrine for persistence. When batches of entities under the same entity manager are finally persisted, the entity manager is then flushed to create entities in a database.
Bundle quirks¶
This bundle came into existence as an attempt to solve the problem
of zenstruck/foundry
taking way too much time persisting
its newly created entities when the numbers of entities reached
a thousand and above.
The reason for that slow speed was the fact
that every entity was immediately persisted and flushed,
which is done with Doctrine and is a very resource engaging process.
So, the the first approach to solve this problem
was to create factories that wouldn’t immediately persist
(i.e. to invoke a Zenstruck\Foundry\Factory::withoutPersisting()
method),
then collect the products of their labor
(i.e. entities wrapped in objects of \Zenstruck\Foundry\Proxy
class)
and persist/flush them in batches.
There is a certain balance between the workload (count of entities to save), time that a persist operation takes and time that it takes to flush the batch. When the batch is too big, it takes a lot of computing resource to perform a persist operation with Doctrine calculating a large unit of work. But when the batches are small and the flush operation is frequent, we end up with what we started from – low performance due to the overall overhead of connecting to the database and executing many small queries.
I found out in my experiments, that by dealing in batches that do not exceed approximately 10,000 entities, there is a good chance that time that it takes to perform both persist and flush operations is going to be acceptably balanced.
So, I started doing just this until I realized that
merely to delay persistence might not be enough –
I still needed to invoke a related afterPersist()
callback
that could have been configured for a model factory
in its initialize()
method.
And while the delayed persistence approach
didn’t interfere with instantiateWith()
,
beforeInstantiate()
or afterInstantiate()
callbacks,
it did prevent the invocation of the afterPersist()
callback
for factories modified with withoutPersisting()
.
Was this a quirk or a feature of a zenstruck/foundry
I do not know.
Here is a permalink to source code of Zenstruck\Foundry\Factory::create()
method
where you can see that if the isPersisting()
returns false
,
then the code responsible for executing the afterPersist
callbacks is not run.
And there is no available interface (i.e. public
method)
to execute those callbacks afterwards from your code.
99 100 101 102 103 104 105 106 107 108 109 110 111 | // in Zenstruck\Foundry\Factory
$proxy = new Proxy($object);
if (!$this->isPersisting()) {
return $proxy;
}
return $proxy->save()->withoutAutoRefresh(function(Proxy $proxy) use ($attributes) {
foreach ($this->afterPersist as $callback) {
$proxy->executeCallback($callback, $attributes);
}
});
|
With this new challenge, the first approach was to try and identify the factory class that produces entities of the class that has just been persisted and flushed, then get the callback from this class and execute it passing a newly persisted/flushed entity.
See the bundle’s EntityProxyPersistService::getFactoryForEntityOfClass()
method
for implementation.
You will see that to identify this factory,
Symfony\Component\DependencyInjection\ServiceLocator
service is injected,
and it is configured in the bundle’s services.xml
to collect services tagged with foundry.factory
tag
and store them with the key provided by
Voltel\ExtraFoundryBundle\Foundry\Factory\AbstractFactory::getClassName()
method,
which is public.
Had Zenstruck\Foundry\ModelFactory::getClass()
been declared public
,
it could have been used instead in the ServiceLocator
declaration.
But it is declared protected
and cannot be used for the task.
<!-- in bundle's "config/services.xml" -->
<services>
<!-- ... -->
<service id="voltel_extra_foundry.persist_service" class="Voltel\ExtraFoundryBundle\Service\FixtureEntity\EntityProxyPersistService">
<argument type="service" id="doctrine" />
<argument type="tagged_locator" tag="foundry.factory" default-index-method="getClassName" />
</service>
<!-- ... -->
</services>
If you extend your factory classes from Voltel\ExtraFoundryBundle\Foundry\Factory\AbstractFactory
,
you can “help” service locator to identify the proper factory
a little bit faster.
Otherwise, the algorithm will iterate every factory in scope
(i.e. services tagged with foundry.factory
tag),
try to “force-invoke” the protected getClass()
method
to see if it matches the class name of the entity in question.
Not a big deal really, as it’s all done blazingly fast.
But to get access to the afterPersist()
callbacks,
it invokes the initialize
method
(which is expected to set an array of callbacks),
and then invoke each of the callbacks in the loop.
It is not an elegant solution at all, especially considering the fact
that with this approach there is no way
we can identify attributes that were used
during the Factory::create()
invocation.
If any of the afterPersist()
callbacks depended on the attributes array,
the results were going to be unpredictable.
So, this is when the need to get away from
addProxy
and addProxyBatch
became obvious
in favor of createOne
and createMany
to not only save in the internals entities for delayed persistence,
but to save a factory that produced those entities as well,
and invoke afterPersist
callbacks that could exist on the factory
after the flush operation.
Right now, the bundle has in its guts the addProxy
and addProxyBatch
methods, which once were public
, now are declared protected
but may be removed in future versions.
On the whole, the trickery described above wouldn’t even be needed
if there were a legitimate interface to execute the afterPersist()
callbacks
from inside the created \Zenstruck\Foundry\Proxy
object,
or at least from inside the factory that “spawned” the entity in question.
Extra¶
Installation¶
Make sure Composer is installed globally, as explained in the Installation chapter of the Composer documentation.
Open a command console, enter your project directory and execute:
$ composer require voltel/extra-foundry-bundle
Applications that don’t use Symfony Flex¶
Step 1: Download the Bundle¶
Open a command console, enter your project directory and execute the following command to download the latest stable version of this bundle:
$ composer require voltel/extra-foundry-bundle --dev
Step 2: Enable the Bundle¶
Then, enable the bundle by adding it to the list of registered bundles
in the config/bundles.php
file of your project:
// config/bundles.php
return [
// ...
Voltel\ExtraFoundryBundle\VoltelExtraFoundryBundle::class => ['dev' => true, 'test' => true],
];
Next step¶
Start by reading how to seed your development database using VoltelExtraFoundryBundle and its services.