zenstruck / foundry

A model factory library for creating expressive, auto-completable, on-demand dev/test fixtures with Symfony and Doctrine.
https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html
MIT License
609 stars 63 forks source link

support "in-memory" repositories #533

Open nikophil opened 7 months ago

nikophil commented 7 months ago

Some words about the feature:

with a DDD approach, all repositories would be interfaces, which opens the door to have multiple implementations: usually a Doctrine one and a "in-memory" one.

In one of my project, we use two different kernels in test: the common one, and a InMemoryKernel one, which injects in-memory repositories instead of doctrine ones.

Most of the factories have the following method, which will store the new object in a property of the in-memory repository:

// SomeObjectFactory.php

    public static function inMemory(InMemorySomeObjectRepository $inMemorySomeObjectRepository): self
    {
        return self::new()->withoutPersisting()
            ->afterInstantiate(
                static fn (SomeObject $object) => $inMemorySomeObjectRepository->save($object)
            )
        ;
    }

Beside of this, I'm testing all my "doctrine" and "in-memory" repositories with the exact same class, using an abstract test class and two implementations which only have a setUp() method. This way I can be sure both implementations behave the same way and I know I can safely replace my doctrine repositories by the in-memory ones. Resulting in super fast kernel tests!

Another very cool benefit is that we do not have to mock repositories anymore! Once the in-memory factories are initialized, we just need to call $factory->create() and the object is directly available in the tested code.

My current implementation works well, but is very perfectible, mainly because in some cases it is a little bit hard to work with relationships. I really think this is a very nice feature, which should be in Foundry!

About the implementation I have in mind:

First, I'd like this behavior to be globally enabled or disabled with some kind of marker. Not sure which kind of marker: maybe in a first implementation, a simple function like enable_in_memory() will suffice. But IMO the best way would be an attribute on the test method or test class.

We must introduce an interface for the in memory repositories:

/**
 * @template T of object
 */
interface InMemoryRepository
{
    /**
     * @param T $object
     */
    public function _save(object $object): void; 
    // the underscore prefix is needed in order to not conflict 
    // with a potential `save()` method in `SomeObjectRepositoryInterface`
}

Then, when the option is enabled, we need a way to create in-memory factories. We'd need to hook in the factory creation process, in order to expose only in-memory factories, when the feature is enabled.

This will need a little bit of refactoring because currently, when a factory is not a service, we create it with a static call: Factory::new()

My suggestion would be to change how FactoryRegistry behaves and to create the factory inside of it, if we don't find it in the container.

// Zenstruck\Foundry\FactoryRegistry
-    public function get(string $class): ?Factory
+    public function get(string $class): Factory
     {
         foreach ($this->factories as $factory) {
             if ($class === $factory::class) {
                return $factory;
             }
         }

-        return null;
+        return new $class(); // todo: handle `ArgumentCountError`
     }

(it would become a.... FactoryFactory :scream: but this name is really awful, I think we can keep the current name)

Then, we could extract an interface from the FactoryRegistry and decorate it with InMemoryFactoryRegistry:

// Zenstruck\Foundry\InMemory\InMemoryFactoryRegistry
public function get(string $class): Factory
{
    if (/** in memory is not enabled */) {
        return $this->decorated->get($class);           
    }

    return $this->decorated
        ->get($class)
        ->withoutPersisting()
        ->afterInstantiate(
            static fn (object $object) => $this->findInMemoryRepository($class)->_save($object)
        )
    ;
}

We also need a way to guess the in-memory repository from the factory's class name. One of the solutions would be to introduce an new attribute #[AsInMemoryRepository(class: Object::class)].

And voilà! :tada: this is all we need as a first step. Next step would be to to provide a in-memory version of RepositoryDecorator because, I'd really like to be able to use things like ::findOrCreate() within the in-memory tests. And then RepositoryAssertions should also be needed! But let's keep simple for a first iteration 😅

As a bonus, I think we can isolate all the code into a Zenstruck\Foundry\InMemory namespace, which will eventually have its own repo in the future.

Do you have any thoughts about this?

kbond commented 6 months ago

Sure, this all makes sense to me. Once we create the 2.x branch and all the legacy stuff from 1.x is removed, let's experiment! We can at least ensure it will be possible to add to 2.x w/o a BC break if the feature is not quite ready.

nikophil commented 6 months ago

cool :)

Actually all the code is almost ready :smile: (just need to fix phpstan...)