DEV Community

Cover image for A library-less approach to fixtures in PHP tests
Andrea Sprega
Andrea Sprega

Posted on

A library-less approach to fixtures in PHP tests

At Slope, writing tests is a relevant part of our day to day work. Having to maintain many integrations and end to end (functional) tests, we are continuously required to create and manage fixtures.

For those who are not familiar with fixtures: quite simply, they are stable, pre-defined sets of data that you persist to a database and then use to run a specific test.

 State of the art (and why we questioned it)

There are several great open source libraries that can help you doing this job. We tried out some of them, including:

They all work well for a lot of use cases, but we found ourselves always having to write too much code for preparing fixtures. Moreover, most of these libraries have their own unique ways to share, compose and reuse fixtures. In most cases it's reference-based (i.e. based on arbitrary string keys), and this makes reusing and composing these fixtures harder than it has to be.

We often had the impression there was too much over-engineering over this task, which instead could be simpler.
That's why we came up with our own (almost) vanilla approach, based on a few design goals. We will see actual code in a few minutes, but before let's see what that "design wishlist" looked like.

Design goals for our ideal fixture system

In the next few lines, I'm going to enumerate and briefly explain the design goals that we kept in consideration while building our fixture system. Note that these are only based on our experience -- your mileage may vary.

Fixtures should have stable defaults

Fixtures are not seeds! As long as you want stable, reproducible tests, you should rely on static defaults for your fixtures. You don't want your default user name to change pseudo-randomly across your tests.

Simple fixtures should require little code

If you just need some placeholder entities and you don't care what their properties are, creating them should require very little coding effort -- ideally, one line per fixture.

It should be clear which defaults you are overriding

Just by looking at the fixture code, it should be obvious which entity property defaults you are overriding. In other words, if your user email needs to be instead of the default, you should be able to do that without having to re-define all the other properties for the User fixture, like for example first or last name.
While being convenient, this is also great because it highlights that specific property and value as the sole pre-condition of your test. If you had to also define a bunch of other properties just to "make the fixture work", then you would lose this kind of clarity.

Every test should have its own fixture

Even though most libraries suggest to create fixtures as a reusable, pre-defined group of related entities, in most cases we did not actually want that.
We found out that by avoiding shared fixtures between tests, our test suite became simpler, more stable and more maintainable.

Fixture code should be inline with test case

A natural consequence of the previous principle is that code for creating a fixture is written directly in the body of the test case. This is great because you can clearly see the connection between your fixtures, the test actions and the assertions. This means that, at a glance, you can understand what's going on without looking anywhere else in your codebase.

 Fixtures should be easily composable

As we don't want to reuse pre-defined group fixtures, we need a way to compose single entity fixtures that is not cumbersome and can be done "on-the-fly", directly in the test case where we need that.
We figured out we could achieve that simply by using objects returned by the fixture class itself. The price to pay for this is that a fixture class can only create one object at a time (and that's perfectly reasonable in our case, as we don't need a ton of data for our tests).

Fixtures should be able to reuse business logic when needed

In case creating a specific entity involves some business logic that lives outside of the entity class itself, it should be possible to leverage your services while creating a fixture, so that you can enforce your logic without having to duplicate it.

 Our approach to fixtures

Enough talking, let's jump right into some code that shows an example of what we came up with.

We use Symfony and Doctrine, so you will see there are dependencies on these two projects. The important thing, though, is the approach: you can adapt the provided samples to your ORM (and framework) of choice.

The FixtureManager

The starting point of all of this is a FixtureManager: basically a convenience class with a bunch of static methods that provides persistence capabilities and an accessible service container to fixture classes.

I know what you're thinking: we're not fans of hardcoded dependencies via static methods either. However, since this is just testing code, we think it's best to favor convenience over everything else. After all, this is not code we have to test.

 Fixture classes

Then, we create a class for every entity we need fixtures for. Here I'm showing just one of them (others are similar). Actual entity classes are not shown because they are pretty simple and not relevant to this example -- it's just constructor and basic setters and getters.

The important part of it is the create method signature: it should be different for each fixture class, and should get as parameters:

1) All the required ones first: we try to avoid them as much as possible, but sometimes they are needed. For example, a User always requires to specify a Tenant as it's not convenient to just create one if not provided. Reason is we almost always reuse the same tenant for a number of entities in the same test (in this example, User and UserGroup).
2) A $fields parameter, which is just an associative array which shape differs fixture from fixture, and can be used to override the default fixture values.

Most of the convenience of this approach is based on Symfony's OptionsResolver. If you don't know it, I suggest to take a look at the documentation but assuming you don't want to switch right now, let me summarize it for you: OptionsResolver is array_replace on steroids, that allows you to produce an array by using a set of defaults as a basis plus the ones you specify either statically or dynamically (i.e. by executing a closure). It also performs validation of the provided keys, throwing exceptions in case an unknown key is provided.

This is super useful because you'll get an immediate failure when attempting to use, in the $fields array, a key that is not defined in the fixture class. This way, you don't have to curse when trying to override a firstName default by passing it in a firstname key 🤯.

It is also super easy to create dynamic defaults. In case of UserFixture's 'group' property, a UserGroup will be created only in case it's not provided from the outside. This because OptionsResolver only executes the closure if the corresponding value is not already provided.

If you want to take it to the next level, you can also tell OptionResolver to validate types of the provided values. In this case I don't think it's worth the extra code as, if you have strongly typed entities (I hope you do!) you will quickly get TypeErrors anyway.

NOTE: you can install OptionsResolver even if you don't use Symfony as full stack framework.

An example TestCase class

Note: I'm using three fixtures here just to show how we compose them. I chose not to show snippets of TenantFixture and UserGroupFixture as they wouldn't add anything new to the example.

Wrapping up

As you could see from the examples, this is not a library but just a set of classes and conventions that can be applied without many pre-requisites.
This approach is battle-tested: we use it in our codebase for hundreds of test cases and it helped us a lot in keeping the fixtures complexity at a minimum.

Feel free to copy-paste and tailor these snippets to your codebase.

What do you think? Any feedback would be greatly appreciated!

Top comments (0)