Reading List
The most recent articles from a list of feeds I subscribe to.
Chris Coyier on personal websites
From Manuel Moreale's lovely People & Blogs series interview with Chris Coyier:
Redesigning your personal website is one of life’s great pleasures.
Isn't it? My fingers are itching.
Cleverness
From The Tao of Pooh:
Remember when Kanga and Roo came to the Forest? Immediately, Rabbit decided that he didn't like them, because they were Different. Then he began thinking of a way to make them leave. Fortunately for everyone, the plan failed, as Clever Plans do, sooner or later. Cleverness, after all, has its limitations. Its mechanical judgments and clever remarks tend to prove inaccurate with passing time, because it doesn't look very deeply into things to begin with. The thing that makes someone truly different — unique in fact — is something that Cleverness cannot really understand.
Think of this next time you write Clever Code.
Laravel export v1
Earlier this week, we tagged spatie/laravel-export
v1. I wrote the bulk of this package 5 years ago. (Wow, I was surprised by this, time really does fly sometimes!) But I never tagged a stable version because I wanted to add more features. Instead, I chose the way of Arrakis and decided it was ready for a v1.
Laravel Export was inspired by Next.js. Next allows you to write your React app and access data on the server, to export it to a static site after. Next does this by crawling your routes. I built exactly this for Laravel using our crawler package. After configuring, you can run an artisan command to export your static site to a folder.
php artisan export
This is a great fit for websites you don't want full blown hosting for but just want to drop on something like Vercel or Netlify. Docs & details in the repository!
Sven Luijten: Using interfaces in third-party packages
A post on enums & interfaces. I didn't realize you could implement an interface on an enum!
This way you get the best of both worlds: the default implementations are neatly grouped in an enum, but others can extend using their own class implementing the interface.
enum ColorOption: string implements Color{ case Red = 'red'; case Blue = 'blue'; case Green = 'green'; public function name(): string { return $this->value; }}
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.