CakePHP Fixture Factories

Test DB Cleaner

Juan Pablo Ramirez - Nicolas Masson

Test Driven Developers

Juan Pablo Ramirez
Fullstack developer at webrider.de
Nicolas Masson
Fullstack developer at [B]-projects

Tests and the DB

Two issues

Test Fixtures

DB clean-up

  • Tests overview
  • CakePHP's native ORM
  • CakePHP Fixture Factories
  • Trigger-based Database Cleaner

Tests should be

Readable

Book

Easy to write

Tool

Run fast

Chrono

Why test?

  • Clean code

  • Documentation

  • Code integrity

  • Saves time

Test anatomy

Arrange

Act

Assert

Example
User permissions

Users ↔ UsersGroups ↔ Permissions


                users
                users_users_groups
                users_groups
                users_groups_permissions
                permissions
            

Test permissions Foo

Arrange
Create user, user group, permission Foo, pivot tables

Act
Check permission Foo for user

Assert
Expect user to have permission Foo

CakePHP's native ORM


                namespace App\Model\Table;

                use Cake\ORM\Table;

                class UsersTable extends Table
                {
                    public function initialize(array $config): void
                    {
                        $this->belongsToMany('UsersGroups');
                    }
                }
                


                namespace App\Model\Table;

                use Cake\ORM\Table;

                class UsersGroupsTable extends Table
                {
                    public function initialize(array $config): void
                    {
                        $this
                            ->belongsToMany('Users')
                            ->belongsToMany('Permissions');
                    }
                }
                


                namespace App\Model\Table;

                use Cake\ORM\Table;

                class PermissionsTable extends Table
                {
                    public function initialize(array $config): void
                    {
                        $this->belongsToMany('UsersGroups');
                    }
                }
                


                    // Retrieve all users, join permissions
                    $users = $this->Users->find()
                        ->contain('UsersGroups.Permissions')
                        ->toArray();
                


                    // Filter users by permissions, join permissions
                    $admins = $this->Users->find()
                        ->matching('UsersGroups.Permissions', function ($q) {
                            return $q->where(['Permissions.name' => 'Admin')
                        })
                        ->toArray();
                


                    // With Database access
                    public function hasPermission(int $userId, string $perm): bool
                    {
                      return $this->Users->find()
                        ->innerJoinWith('UsersGroups.Permissions', function ($q) use ($perm) {
                            return $q->where(['Permissions.name' => $perm)
                        })
                        ->where(['Users.id' => $userId])
                        ->count() > 0;
                    }
                


                    // Without Database access
                    public function hasPermission(User $user, string $perm): bool
                    {
                        return in_array(
                            $perm,
                            Hash::extract($user, 'users_groups.{n}.permissions.{n}.name')
                        )
                    }
                

Test fixtures

UsersTable ↔ UsersGroupsTable ↔ PermissionsTable

5 Tables → 5 Fixtures per test


                users
                users_users_groups
                users_groups
                users_groups_permissions
                permissions
                

1 permission Foo - 4 users: Admins, Gurus, Foo, Bar

Static

  • SQL "Dump" with 4 users, user groups and permissions + pivot tables
  • Load the dump before each test
  • Identify test relevant user
  • Easy to create
  • Horrible to maintain
  • Total fixtures loaded: 5 ‧ 4 ‧ 4 = 80

Dynamic / Factories

  • Create before each test 1 user, 1 user groups and 1 permission + pivot tables
  • Within each test
  • Easy to create
  • Easy to maintain
  • Total fixtures loaded: 5 ‧ 4 = 20

CakePHP Fixture Factories

App\Test\Factory\UserFactory.php


                    protected function getRootTableRegistryName(): string
                    {
                      return 'Users';
                    }

                    protected function setDefaultTemplate(): void
                    {
                      $this->setDefaultData(function(Generator $faker) {
                        return [
                          'username' => $faker->username,
                          'email' => $faker->email,
                        ];
                      });

                      $this->withPermission();
                    }
                

                    $user = UserFactory::make()->getEntity();

                    UserFactory::make(4)->getEntities();

                    UserFactory::make(4)->persist();

                    UserFactory::make(['username' => 'Foo'])->persist();

                    UserFactory::make([
                      ['username' => 'Foo'],
                      ['username' => 'Bar'],
                    ])->persist();
                

                    UserFactory::make(3)
                      ->with('UsersGroups.Permissions', ['name' => 'Foo'])
                      ->getEntity();

                    UserFactory::make()
                      ->with('UsersGroups.Permissions', [
                        ['name' => 'Foo'],
                        ['name' => 'Bar'],
                      ])
                      ->getEntity();

                    UserFactory::make()
                      ->withPermission('Foo')
                      ->getEntity();
                

Unit test


							public function testUserPermission()
							{
							  // Arrange
							   $user = UserFactory::make()
								->with('UsersGroups.Permissions', ['name' => 'Foo'])
								->getEntity();

							   // Act
							   $act = $this->Users->hasPermission($user, 'Foo');

							   // Assert
							   $this->assertTrue($act);
							}
						

Book Tool Chrono

Unit tests


						public function dataProviderForTestPermission()
						{
						   return [['Admin',true],['Guru',true],['Foo',true],['Bar',false]];
						}

						/** @dataProvider dataProviderForTestPermission */
						public function testPermission(string $p, bool $exp)
						{
						   $user = UserFactory::make()->withPermission($p)->getEntity();

						   $act = $this->Users->hasPermission($user, 'Foo');

						   $this->assertSame($exp, $act);
						}
						

Behavioral test


						Feature: User permission

						  Background:
						   Given I create a user with id 504

						  Scenario:
							Given I log in with permission 'Users'
							When I call get 'users/view/504'
							Then I shall be granted access.

						  Scenario:
							Given I log in with permission 'Admin'
							When I call get 'users/view/504'
							Then I shall be granted access.

						  Scenario:
							Given I log in with permission 'Guru'
							When I call get 'users/view/504'
							Then I shall be granted access.

						  Scenario:
							Given I log in with permission 'Foo'
							When I call get 'users/view/504'
							Then I shall be redirected.

						

CakePHP Fixture Factories

Default template

Association builder

Persist or not

Random Ids

Persist costs

Time (Y axis) vs Number of tests (X axis)

DB cleaning

https://github.com/vierge-noire/test-database-cleaner

DB cleaning with transactions

Warm-up

Test suite starts

Truncate all tables

Load test dump
High maintenance

Run migrations
~10 seconds

Start test

Start SQL transaction

Run test

Rollback transaction

DB cleaning with triggers

Test suite starts

Warm-up

Run migrations


                        CREATE TABLE IF NOT EXISTS dirty_tables_collector (table_name VARCHAR(128) PRIMARY KEY);

                        CREATE TRIGGER table_users_trigger AFTER INSERT ON `users`
                            FOR EACH ROW
                            INSERT IGNORE INTO dirty_table_collector (table_name)
                            VALUES ('dirty_tables_collector'), ('users');
                    

Truncate dirty tables in 1 procedure

Run test

Chrono

https://github.com/vierge-noire/test-database-cleaner

It's fast: no warm up

MySQL, Postgres, SQL Server, Sqlite + contribution

CakePHP, Laravel + contribution

Feel free to join the project!

Conclusion

Build fast with CakePHP

Grow solid with the Fixture Factories

Join us to improve the test database cleaner

Credits: Juan Pablo Ramirez - Nicolas Masson

https://vierge-noire.github.io