Working with fixtures
Over my past couple of full time gigs - and in side projects - I've had cause to work with fixture data in my test suites.
Fixtures are incredibly useful when you want to validate your application takes the correct approach when it comes to known-good inputs into the application.
In addition, using test fixtures allows you to work with this known-good data without having to hit external APIs such as creating customers in Stripe, or accepting webhooks from GitHub.
This keeps your test suite fast, whilst still ensuring your code does what it should.
Static fixtures are good
Within the thenping.me test suite, we have several tests that deal with different scenarios based on webhooks we receive from Stripe. These might be creating a customer, creating a Stripe Checkout session, processing subscriptions, cancellations, and more.
We keep each of these payloads in a single JSON file, keyed with a description of the payload.
// tests/fixtures/stripe-responses.json{ "create-customer": {}, "checkout-session": {}, "checkout-trial-session": {}, "retrieve-checkout-session": {}, "subscriptions": {}, "subscriptions-trial": {}, "subscription": {}, "canceled": {}, "cancel": {}, "abort-cancellation": {}, "invoices": {}}
We can then load these fixtures from the tests/fixtures/
directory and reference a specific payload using the loadFixture
helper.
There's nothing special about this little helper; take the $file
variable, get the contents of the file, decode the JSON (as an array - more on this later) and return the data contained within the specified key.
// tests/Pest.phpfunction loadFixture($file, $key){ return json_decode(file_get_contents(__DIR__."/fixtures/{$file}.json"), true)[$key];} $fixture = loadFixture('stripe-responses', 'create-customer'); $customerId = $fixture->id;
From this, we can use loadFixture
in a test like so:
it('can create a stripe customer for team', function () { $team = Team::factory()->noStripeId()->create(); $user = User::factory()->create(); $user->joinTeamAndSwitch($team); Billing::shouldReceive('createCustomer') ->once() ->andReturn(loadFixture('stripe-responses', 'create-customer')); (new CreateStripeCustomerForTeam($team))->handle(); expect($team->refresh()) ->stripe_customer_id)->toBe('cus_HsRmHiwsyfT8le');});
Now that's all well and good, assuming that we don't ever care about dynamic - or factory - data in our tests. It also means we need to be aware of static values from the fixture in our tests, like the stripe_customer_id
.
Dynamic fixtures are better, but...
As our fixture data comes from a static JSON file, there's a bit of wrangling involved in manipulating the data if we want to work with factory models in a more meaningful way.
it('creates a subscription record for personal team', function () { $team = Team::factory()->create(); $fixture = loadFixture('stripe-responses', 'subscriptions'); $fixture['data'][0] = json_decode(json_encode($fixture['data'][0])); $fixture['data'][0]->plan->product = 'prod_HtPt4mpl3FhMOV'; // Personal Plan Billing::shouldReceive('getSubscriptionsForCustomer') ->once() ->andReturn($fixture); (new CreateOrUpdateSubscriptionsForTeam($team))->handle(); expect($team->refresh()) ->hasActiveSubscription()->toBeTrue(); ->personal_team->toBeTrue(); expect(cache($team->active_subscription_cache_key))->toBeTrue();});
This all becomes a bit tedious - and repetitive - particularly because we need to muck about with re-encoding the JSON to ensure objects are where we expect them to be.
Artisanal touch
Fortunately Laravel has some tooling available to make working with this data a little more easy.
function loadFixture($file, $key) function loadFixture($file, $key, $override = null, $value = null) { return json_decode(file_get_contents(__DIR__."/fixtures/{$file}.json"), true)[$key] ?? null; $fixture = json_decode(file_get_contents(__DIR__."/Fixtures/{$file}.json"), true)[$key]; if (! is_null($override)) { $replacement = is_array($override) ? $override : [$override => $value]; foreach ($replacement as $override => $value) { Arr::set($fixture, $override, $value); } } return $fixture; }
We can now tweak our test to use the expanded functionality of loadFixture
.
it('creates a subscription record for personal team', function () { $team = Team::factory()->create(); $fixture = loadFixture('stripe-responses', 'subscriptions'); $fixture['data'][0] = json_decode(json_encode($fixture['data'][0])); $fixture['data'][0]->plan->product = 'prod_HtPt4mpl3FhMOV'; // Personal Plan // $fixture = loadFixture('stripe-responses', 'subscriptions', 'data.0.plan.product', 'prod_HtPt4mpl3FhMOV'); Billing::shouldReceive('getSubscriptionsForCustomer') ->once() ->andReturn($fixture); (new CreateOrUpdateSubscriptionsForTeam($team))->handle(); expect($team->refresh()) ->hasActiveSubscription()->toBeTrue(); ->personal_team->toBeTrue(); expect(cache($team->active_subscription_cache_key))->toBeTrue();});
Using the Arr::set()
functionality under the hood of the loadFixture
helper means we can use Laravel's familiar dot-notation to handle setting values in our tests.
We can even pass a key/value array to manipulate multiple fixture keys at once.
it('creates a trial subscription record', function () { $team = Team::factory()->impersonal()->create(); $fixture = loadFixture('stripe-responses', 'subscriptions-trial', [ 'data.0.plan.product' => 'prod_HtPwpDYc1xg3yE', 'data.0.trial_end' => Date::now()->addDay()->timestamp, ]); Billing::shouldReceive('getSubscriptionsForCustomer') ->once() ->andReturn($fixture); expect($team->hasActiveSubscription())->toBeFalse(); (new CreateOrUpdateSubscriptionsForTeam($team))->handle(); expect($team->refresh()) ->hasTrialSubscription()->toBeTrue() ->personal_team->toBeFalse() ->subscription->pricing_id->toBe(data_get($fixture, 'data.0.plan.id')) ->subscription->status->isTrialing()->toBeTrue() ->subscription->trial_ends_at->timestamp->toBe(data_get($fixture, 'data.0.trial_end')); expect(cache($team->active_subscription_cache_key))->toBeTrue();});
To be noted
One thing to be mindful of this approach is that we are now manipulating the JSON data structure. Where previously we had an array of objects, we now have an array of (associative) arrays.
// Before$fixture = loadFixture('stripe-responses', 'subscriptions-trial');$fixture = json_decode(json_encode($fixture));$product = $fixture['data'][0]->plan->product; // After$fixture = loadFixture('stripe-responses', 'subscriptions-trial', 'data.0.plan.product', 'prod_HtPwpDYc1xg3yE');$product = $fixture['data'][0]['plan']['product'];
I've found in these particular scenarios that Laravel's data_get
helper abstracts away concerns with the data structure and lets you focus on the thing you actually care about: the data itself.
$product = data_get($fixture, 'data.0.plan.product');
As a result, we also updated our application code to use data_get
rather than concrete expectations for the data structure.
I think that the benefit of a more streamlined test experience, coupled with not having to deal with mixing arrays and objects (and knowing when to use which) in data access, is worth the trade off in this scenario.