Tabular assertions with Pest

2024-01-09 #php #testing #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.

Screenshot of a tabular assertion diff in PhpStorm

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.

```php
expect()->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.