Creating entities for tests using VoltelExtraFoundryBundle services

  1. 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.

  2. 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:

  1. 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.

  2. Export your data into a MySQL dump file.

  3. Place your dump file inside your project (e.g. in var/mysql_dumps directory).

  4. Configure your VoltelExtraFoundryBundle to locate the dump file with SQL queries to re-create the initial database state in test environment.

  5. Create the GlobalStory class with GlobalStory::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.

  1. 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
    
  2. 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:

  1. Using the database_type bundle configuration option, it will locate an appropriate service implementing Voltel\ExtraFoundryBundle\Service\FixtureLoad\LoadDumpFromDatabaseInterface.

    Currently, there is only one service implementing this interface responsible for loading MySQL dumps: Voltel\ExtraFoundryBundle\Service\FixtureLoad\MySqlDumpFileLoadingService.

  2. The LoadDupmFromDatabaseInterface::loadSqlDump() method will do the following:

    • Check the presence of file configured in dump_directory_path and dump_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 of default.

      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).