Laravel: Testing a Pipeline, one pipe at a time
Not a novel tactic but not obvious either
Pipelines are a great way to run logic in a serialized way. It’s what powers the Routing Middleware and Job Middleware in Laravel, so it’s not just a tool that is there just to exist for pure convenience. I even wrote about its magic extensively before, if you want to check how a Pipeline works.
The problem with a Pipeline is testing. At first glance, someone would want to know how the passable object is compared between the initial and finished state. While this is still a valid approach, the problem starts when you have multiple permutations that makes testing it a bother rather than a diligence.
Well, there is another approach, and it's just testing each pipe in isolation.
First thing first: ensure the pipe order
To avoid testing a Pipeline that may change the order, just create a separate test that ensures the Pipeline always has the expected order of pipes.
Becase the $pipes
property is protected, we can use the ReflectionProperty
class to retrieve the property, make it accessible, and get the value, which in this case is the array of pipes. Then it’s just a matter of comparing what we expect with what we received.
use App\Pipelines\MyCustomPipeline;
use ReflectionProperty;
public function test_ensure_pipes_order(): void
{
$pipeline = new MyCustomPipeline;
$property = new ReflectionProperty($pipeline, 'pipes');
$property->setAccessible(true);
static::assertSame([
// ... your expected pipes order
], $property->getValue($pipeline));
]);
By checking the pipes order stays always the same, we can avoid the rest of the test breaking because someone decided to move or remove a pipe.
From there, we can ensure each pipe can get its own test.
Setting the test for each pipe
When pipes are callables like Closures, function names, or objects implementing __invoke()
, the Pipeline will directly execute it.
If it’s a class string, it will be resolved through the Service Container, and you will get an exception if its dependencies are unresolvable.
Having pipes being resolved by the Service Container to allows us to make better tests since we can mock the services that the class requires in isolation. In other words, we can test if the Pipe actually works as intended, without setting up the rest of pipes each time.
To test the pipe, first, we need to mock the its dependencies, if necessary.
use Illuminate\Filesystem\Filesystem;
public function test_first_pipe()
{
$this->mock(Filesystem::class, function ($mock) {
$mock->expects('exists')->with('file.php')->andReturnTrue();
});
}
The second step is to prepare our passable object. In this case, it’s the Illuminate\Support\Fluent
object with a file path that should be checked by the pipeline.
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Fluent;
public function test_first_pipe()
{
$this->mock(Filesystem::class, function ($mock) {
$mock->expects('exists')->with('file.php')->andReturnTrue();
});
$passable = new Fluent(['file' => 'file.php');
}
The third and final step is to execute the Pipe. Here we can use two tricks:
- We let the Service Container resolve the pipe.
- Since the pipe instance usually uses the
handle()
method with both the passable object and a Closure that receives that object, we can make our assertions inside that.
Our test would end up something like this:
use App\Pipelines\CreateStubFile\Pipes\EnsureFileMissing;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Fluent;
public function test_first_pipe()
{
// Prepare the mocked services.
$this->mock(Filesystem::class, function ($mock) {
$mock->expects('exists')->with('file.php')->andReturnTrue();
});
// Prepare the passable.
$passable = new Fluent(['file' => 'file.php');
// Run the pipe
$this->app
->make(EnsureFileMissing::class)
->handle($passable, function ($passable) {
// Assert the pipe works
static::assertTrue($passable->file_exists);
});
}
Rinse and repeat for each pipe and expected outcome, and you can easily slim down your tests for the whole pipeline, especially setting up each mocked Service.