Tabular assertions with Pest
With tabular assertions, you describe the expected outcome in a markdown-like table format. Here's why there useful, and how to use tabular assertions with Pest.
This is a tabular test in Pest:
test('it logs order activity', function () { $order = OrderFactory::create() ->withPrice(100_00) ->withTaxRates([5_00, 10_00]) ->withShipping(5_00) ->paid(); expect($order->activity)->toMatchTable(' | type | reason | #product_id | #tax_id | #shipping_id | #payment_id | price | paid | | product | created | #1 | | | | 100_00 | 0_00 | | tax | created | | #1 | | | 5_00 | 0_00 | | tax | created | | #2 | | | 10_00 | 0_00 | | shipping | created | | | #1 | | 5_00 | 0_00 | | product | paid | #1 | | | #1 | 0_00 | 100_00 | | tax | paid | | #1 | | #1 | 0_00 | 5_00 | | tax | paid | | #2 | | #1 | 0_00 | 10_00 | | shipping | paid | | | #1 | #1 | 0_00 | 5_00 | ');});
Before we dive into the how-to, lets review why tabular assertions are useful.
Lots of data at a glance
Text-based tables are compact. If we want to assert the same data with assert
methods, we need a lot of vertical space.
expect($order->activity[0]) ->type->toBe('product') ->reason->toBe('created') ->price->toBe(100_00) ->paid->toBe(0_00); expect($order->activity[1]) ->type->toBe('tax') ->reason->toBe('created') ->price->toBe(5_00) ->paid->toBe(0_00); // …and 24 more assertions
Regular assertions require you to assert each property individually. In this example, we're asserting 4 properties across 8 rows. 8 * 4 = 32
so that would require 32 separate assertions, and won't scale well. This makes it hard to see all data at a glance, and is less readable in general.
Alternatively, we could use associative arrays or tuples to assert data in bulk.
expect($order->activity)->toEqual([ ['type' => 'product', 'reason' => 'created', 'price' => 100_00, 'paid' => 0_00], ['type' => 'tax', 'reason' => 'created', 'price' => 5_00, 'paid' => 0_00], // …and 6 more lines]);
Associative arrays are more verbose but require a lot of repetition.
expect($order->activity)->toEqual([ ['product', 'created', 100_00, 0_00], ['tax', 'created', 5_00, 0_00], // …and 6 more lines]);
Tuples are more compact and readable at a glance once you understand the shape. However, both suffer from whitespace issues: the columns are not aligned. We could add spaced to align them, but code style fixers don't always like this.
With tabular assertions, we get a compact, readable overview of the data, and because it's stored in a single string code style fixers won't reformat it.
expect($order->activity)->toMatchTable(' | type | reason | price | paid | | product | created | 100_00 | 0_00 | | tax | created | 5_00 | 0_00 | | tax | created | 10_00 | 0_00 | | shipping | created | 5_00 | 0_00 | | product | paid | 0_00 | 100_00 | | tax | paid | 0_00 | 5_00 | | tax | paid | 0_00 | 10_00 | | shipping | paid | 0_00 | 5_00 |');
Failures display multiple problems
With separate expectations, tests fail on the first failed assertion which means you don't have the full picture.
expect($order->activity[0]) ->type->toBe('product') ->reason->toBe('created') ->price->toBe(100_00) ->paid->toBe(0_00);
Back to our first example of assertions, when the reason
is wrong the test will fail. Did reason fail for a single row, or are the reasons wrong everywhere? This can be valuable information when debugging which is lost in classic tests.
It could also be that an activity row is missing, which causes all other rows to fail. This doesn't mean they contain wrong data, the assertions are tied to a different index. Tabular testing makes this clear with a diff.
Dynamic placeholders
Sometimes you want to compare data without actually comparing the exact value. For example, you want to assert that each person is in the same team, but don't know the team ID because the data is randomly seeded on every run.
With tabular assertions, you can mark a column as dynamic by prefixing a #
. This will assign each unique value to a placeholder ID, so similar values can be compared.
In our initial example, we don't care about the exact product_id
of the rows as it's a randomly seeded ID we can't assert. We do however care that the created
and paid
activity are tied to the same product, which becomes clear with the placeholders.
expect($order->activity)->toMatcheTable(' | type | reason | #product_id | #tax_id | #shipping_id | #payment_id | price | paid | | product | created | #1 | | | | 100_00 | 0_00 | | tax | created | | #1 | | | 5_00 | 0_00 | | tax | created | | #2 | | | 10_00 | 0_00 | | shipping | created | | | #1 | | 5_00 | 0_00 | | product | paid | #1 | | | #1 | 0_00 | 100_00 | | tax | paid | | #1 | | #1 | 0_00 | 5_00 | | tax | paid | | #2 | | #1 | 0_00 | 10_00 | | shipping | paid | | | #1 | #1 | 0_00 | 5_00 |', $order->activity);
Setting up tabular assertions with Pest
Tabular assertions can be installed via composer.
composer require spatie/tabular-assertions --dev
The Pest plugin will be autoloaded, you can now use the toMatchTable
expectation in your tests.
test('it logs order activity', function () { $order = OrderFactory::create() ->withPrice(100_00) ->withTaxRates([5_00, 10_00]) ->withShipping(5_00) ->paid(); expect($order->activity)->toMatchTable(' | type | reason | #product_id | #tax_id | #shipping_id | #payment_id | price | paid | | product | created | #1 | | | | 100_00 | 0_00 | | tax | created | | #1 | | | 5_00 | 0_00 | | tax | created | | #2 | | | 10_00 | 0_00 | | shipping | created | | | #1 | | 5_00 | 0_00 | | product | paid | #1 | | | #1 | 0_00 | 100_00 | | tax | paid | | #1 | | #1 | 0_00 | 5_00 | | tax | paid | | #2 | | #1 | 0_00 | 10_00 | | shipping | paid | | | #1 | #1 | 0_00 | 5_00 | ');});
Custom assertions
Tabular assertions will cast the actual values to strings. We're often dealing with data more complex than stringables, in those cases it's worth creating a custom assertion method that prepares the data.
Consider the following example with a User
model that has an id
, name
, and date_of_birth
which will be cast to a Carbon
object.
expect(User::all())->toMatchTable(' | name | date_of_birth | | Sebastian | 1992-02-01 00:00:00 |');
Because Carbon
objects automatically append seconds when stringified, our table becomes noisy. Instead, we'll create a custom assertMatchesUsers
assertion to prepare our data before asserting.
expect()->extend('toMatchUsers', function (string $expected) { $users = $this->value->map(function (User $user) { return [ 'name' => $user->name, 'date_of_birth' => $user->date_of_birth->format('Y-m-d'), ]; }); expect($users)->toBe($expected);}); expect(User::all())->toMatchUsers(' | name | date_of_birth | | Sebastian | 1992-02-01 |');
This can also useful for any data transformations or truncations you want to do before asserting. Another example: first_name
and last_name
might be separate columns in the database, but in assertions they can be combined to reduce unnecessary whitespace in the table.
```phpexpect()->extend('toMatchUsers', function (string $expected) { $users = $this->value->map(function (User $user) { return [ 'name' => $user->first_name . ' ' . $user->last_name, 'date_of_birth' => $user->date_of_birth->format('Y-m-d'), ]; }); expect($users)->toBe($expected);}); expect(User::all())->toMatchUsers(' | name | date_of_birth | | Sebastian De Deyne | 1992-02-01 |');
The full source & documentation for spatie/tabular-assertions
is available on GitHub.