⚠️ This content has been written a long time ago. As such, it might not reflect my current thoughts anymore. I keep this page online because it might still contain valid information.

DDD with Symfony2: Basic Persistence & Testing

After having described a decent folder structure and made things clear about DDD and this series, I will continue to bootstrap the sample application, focusing on a basic persistence layer and testing. It is not tied to DDD but it is worth writing about these two points.

A basic persistence layer is a layer that is simple. In other words, it does the job, period. It has poor performances, but it does not matter. What matters is that you can develop your application without having to decide whether you will use MySQL rather than a NoSQL database or an API for instance.

The YamlUserRepository Repository

Until now, the application used the InMemoryUserRepository described in the first blog post of this series. In order to introduce new concepts later in this series, you need a real persistence layer that is able to load and store data. That is why you need a YamlUserRepository.

For the record, YAML is a data serialization format that is both simple and human readable. You don’t need to focus on performance right now, so storing data in a file is ok.

Here is the implementation of the YamlUserRepository. That might not be the perfect/best implementation but it actually works:

<?php

namespace Acme\CoreDomainBundle\Repository;

use Acme\CoreDomain\User\User;
use Acme\CoreDomain\User\UserId;
use Acme\CoreDomain\User\UserRepository;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Yaml\Yaml;

class YamlUserRepository implements UserRepository
{
    private $filename;

    public function __construct($filename)
    {
        $this->filename = $filename;

        (new Filesystem())->touch($this->filename);
    }

    /**
     * {@inheritDoc}
     */
    public function find(UserId $userId)
    {
        foreach ($this->findAll() as $user) {
            if ($user->getId()->isEqualTo($userId)) {
                return $user;
            }
        }

        return null;
    }

    /**
     * {@inheritDoc}
     */
    public function findAll()
    {
        $users = array();
        foreach ($this->getRows() as $row) {
            $users[] = new User(
                new UserId($row['id']),
                $row['first_name'],
                $row['last_name']
            );
        }

        return $users;
    }

    /**
     * {@inheritDoc}
     */
    public function add(User $user)
    {
        $rows = array();
        foreach ($this->getRows() as $row) {
            if ($user->getId()->isEqualTo(new UserId($row['id']))) {
                continue;
            }

            $rows[] = $row;
        }

        $rows[] = array(
            'id'         => $user->getId()->getValue(),
            'first_name' => $user->getFirstName(),
            'last_name'  => $user->getLastName(),
        );

        file_put_contents($this->filename, Yaml::dump($rows));
    }

    /**
     * {@inheritDoc}
     */
    public function remove(User $user)
    {
        $rows = array();
        foreach ($this->getRows() as $row) {
            if ($user->getId()->isEqualTo(new UserId($row['id']))) {
                continue;
            }

            $rows[] = $row;
        }

        file_put_contents($this->filename, Yaml::dump($rows));
    }

    private function getRows()
    {
        return Yaml::parse($this->filename) ?: array();
    }
}

This new repository relies on two Symfony2 components: Filesystem and Yaml.

As you might notice, the identity between two users is checked by the isEqualTo() method, part of the UserId value object. The body of this method is pretty straightforward:

public function isEqualTo(UserId $userId)
{
    return $this->getValue() === $userId->getValue();
}

Register the new repository into the Dependency Injection Container (DIC). The YamlUserRepository class takes a filename as first argument, that is why the service definition contains a <argument> tag.

The file will be created into the cache directory (%kernel.cache_dir%) meaning that if you clear the cache, your data will be lost.

<!-- src/Acme/CoreDomainBundle/Resources/config/repositories.xml -->
<?xml version="1.0" ?>
<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <parameters>
        <!-- ... -->
        <parameter key="user_repository.yaml.class">Acme\CoreDomainBundle\Repository\YamlUserRepository</parameter>
    </parameters>

    <services>
        <!-- ... -->

        <!-- Concrete Implementations -->
        <service id="user_repository.yaml" class="%user_repository.yaml.class%" public="false">
            <argument>%kernel.cache_dir%/users.yml</argument>
        </service>
    </services>
</container>

Using the YamlUserRepository rather than the InMemoryUserRepository is just a matter of configuration. Change the alias for the user_repository service and you are done:

<service id="user_repository" alias="user_repository.yaml"></service>

Looks good? No! I told you that the implementation worked fine, did you trust me? You should not, and you should write tests instead.

Unit Testing

Testing the YamlUserRepository implementation looks pretty easy, except that it relies on the filesystem to load and store data. Most of the developers I know would touch a temporary file at the beginning of each test method and delete it at the end. That is not the right way to test such a thing.

vfsStream to the rescue! It is a stream wrapper for a virtual filesystem, which can be used to mock the real filesystem. That is exactly what you need.

Install it as a dev package:

$ composer require mikey179/vfsStream:"1.3.*@dev" --dev

First, you need to setup the virtual filesystem using the vfsStream::setup() method. Then, you can create a virtual filename that will be injected into your YamlUserRepository just like the DIC would do it:

<?php

namespace Acme\CoreDomainBundle\Tests\Repository;

use org\bovigo\vfs\vfsStream;
use Acme\CoreDomainBundle\Repository\YamlUserRepository;
use Acme\CoreDomainBundle\Tests\TestCase;
use Acme\CoreDomain\User\User;
use Acme\CoreDomain\User\UserId;

class YamlUserRepositoryTest extends TestCase
{
    private $cacheDir;

    private $repository;

    protected function setUp()
    {
        $this->cacheDir   = vfsStream::setup('cache');
        $this->repository = new YamlUserRepository(vfsStream::url('cache/users.yml'));
    }
}

The following method creates fixtures that will be useful in your test methods:

<?php

class YamlUserRepositoryTest extends TestCase
{

    // ...

    protected function addUsers()
    {
        $this->repository->add(
            new User(new UserId('62A0CEB4-0403-4AA6-A6CD-1EE808AD4D23'), 'Jean', 'Bon')
        );
        $this->repository->add(
            new User(new UserId('62A0CEB4-0403-4AA6-A6CD-1EE808AD4D44'), 'John', 'Doe')
        );
    }
}

And here are your first tests:

<?php

class YamlUserRepositoryTest extends TestCase
{

    // ...

    public function testFind()
    {
        $this->addUsers();

        $user = $this->repository->find(
            new UserId('62A0CEB4-0403-4AA6-A6CD-1EE808AD4D23')
        );

        $this->assertNotNull($user);
        $this->assertInstanceOf('Sportbook\CoreDomain\User\User', $user);
        $this->assertEquals('Jean', $user->getName()->getFirstName());
    }

    public function testFindReturnsNullIfNotFound()
    {
        $user = $this->repository->find(
            new UserId('62A0CEB4-0403-4AA6-A6CD-1EE808AD4D23')
        );

        $this->assertNull($user);
    }

    public function testAdd()
    {
        $this->addUsers();
        $expected = <<<YAML
-
    id: 62A0CEB4-0403-4AA6-A6CD-1EE808AD4D23
    first_name: Jean
    last_name: Bon
-
    id: 62A0CEB4-0403-4AA6-A6CD-1EE808AD4D44
    first_name: John
    last_name: Doe

YAML;
        $this->assertEquals(
            $expected,
            $this->cacheDir->getChild('users.yml')->getContent()
        );
    }
}

Note that the testAdd() method is just an example on how to use vfsStream. Also, I won’t cover all tests that should be written for this repository, however you must test all its public methods.

Now, let’s setup the functional tests.

Functional Testing

A while ago, I created a bundle named BazingaRestExtraBundle, which contains various tools that are not part of the FOSRestBundle (yet). One of the most useful class is the WebTestCase which extends the Symfony one, and provides methods that I use all the time while testing REST APIs, especially the assertJsonResponse() I already covered.

Require it as dev package:

$ composer require willdurand/rest-extra-bundle:"0.0.*" --dev

Then, create your first functional test class:

<?php

namespace Acme\ApiBundle\Tests\Controller;

use Bazinga\Bundle\RestExtraBundle\Test\WebTestCase;

class UserControllerTest extends WebTestCase
{
    public function testAll()
    {
        $client   = static::createClient();
        $crawler  = $client->request('GET', '/users.json');
        $response = $client->getResponse();

        $this->assertJsonResponse($response);
    }
}

This test should fail because you didn’t provide any fixtures. Use the setUp() method to copy a fixtures file to the cache directory so that you keep control over the data in your tests:

<?php

class UserControllerTest extends WebTestCase
{
    // ...

    protected function setUp()
    {
        $this->client = static::createClient();

        (new Filesystem())->copy(
            __DIR__ . '/../Fixtures/users.yml',
            $this->client->getContainer()->get('kernel')->getCacheDir() . '/users.yml',
            true
        );
    }
}

The Fixtures/users.yml file contains the following data:

- id: 50AAF29D-DB0B-43FE-9DD8-2F1C058416C5
  first_name: Jean
  last_name: Bon
- id: 53E2F088-E0B0-4A21-86A8-67B2FD6A2749
  first_name: John
  last_name: Doe

That’s it! You are able to test your application in a functional way like a boss!

Conclusion

As of now, the sample application looks good. It does not do much things right now, but everything is bootstrapped. You should keep in mind that testing is really important, and that you should use the right tools even in your tests. vfsStream is awesome!

Hopefully you now understand why combining programming to the interface with dependency injection is powerful. Replacing an implementation to another one is just a matter of alias here.

In the next blog post, I will mainly cover factories, stay tuned!

Feel free to fork and edit this post if you find a typo, thank you so much! This post is licensed under the Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Recent articles

Comments

No comments here. You can get in touch with me on Mastodon or send me an email if you prefer.