In our journey as developers, we often find ourselves creating applications that need to export reports or pages to PDF format. For a long time, we used various libraries for this task, such as mPDF, FPDF, wkHtmlToPdf, among others. However, today, in my humble opinion, we have one of the best packages for PDF generation on the market, which is Browsershot. It's straightforward to configure and generate PDF files.
But, here's the issue some devs face: How can I write tests for a class that uses Browsershot? Let's dive a bit deeper.
Imagine we have a class called GeneratePdf that takes a file name, a URL to render, and maybe the paper size as parameters. This class will save our PDF to AWS S3.
⚠️ The examples here are written in a Laravel application and using Pest for automated tests.
<?php
declare(strict_types=1);
namespace App\Actions;
use Illuminate\Support\Facades\Storage;
use Spatie\Browsershot\Browsershot;
class GeneratePdf
{
public function handle(
string $fileName,
string $url,
string $paperSize = 'A4'
): string | false {
$path = '/exports/pdf/' . $fileName;
$content = Browsershot::url($url)
->format($paperSize)
->noSandbox()
->pdf();
if (!Storage::disk('s3')->put($path, $content)) {
return false;
}
return $path;
}
}
Fantastic! Our action will save the PDF and return the path so that we can use it in an email, save it in a database, and so on. The only responsibility of this class is to generate the PDF and return the path.
But now, how do we test this little guy?
Writing Our Tests
Okay, in this phase, let's write a simple test to see if everything works as expected.
it('should generate a pdf', function () {
Storage::fake('s3');
$pdf = (new GeneratePdf())->handle(
fileName: 'my-file-name.pdf',
url: 'https://www.google.com'
);
Storage::disk('s3')
->assertExists($pdf);
});
However, you might notice that our test takes a while to execute. But why?
Our test took a while because Browsershot made a request to google.com to fetch its content and create the PDF for you.
Alright, it's just one test, what's the harm in that? Let's think:
- What if there's more than one class using Browsershot?
- What if you have no internet connection? The test fails.
- What if you're using a paid pipeline service? The test will take longer, and you'll pay more for it.
So, how can we write our test more efficiently?
with MOCKERY ✨✨✨
Mockery
To simulate the behavior of a class, we can use the Mockery
library, which is already available in PHPUnit and Pest.
This library provides an interface where we can mimic or spy on our class's behavior to make assertions on the methods that were called.
But there's a problem (there always is), a static call...
BrowserShot::url(...)
The Problem with Static Methods
Static methods are great, especially for helper classes, such as a method that checks whether a CPF (Brazilian social security number) is valid or not. In such cases, since we won't have access to $this
, we can make these methods static without any issues.
However, this comes at a cost...
Writing unit tests for static methods is straightforward. We call the method and make the necessary assertions, simple as that. But what if I need to mock a class that calls a static method and then calls its non-static methods?
According to the Mockery documentation, it doesn't support mocking public static methods. To work around this, there's a kind of hack to bypass this behavior, which involves creating an alias. (You can read more about it here).
it('should generate a pdf', function () {
Storage::fake('s3');
mock('alias:' . Browsershot::class)
->shouldReceive('url->format->noSandbox->pdf');
$pdf = (new GeneratePdf())->handle(
fileName: 'my-file-name.pdf',
url: 'https://www.google.com'
);
Storage::disk('s3')
->assertExists($pdf);
});
Alright, but what does this do? When we use alias:
, we're telling Composer:
"Hey, when I need Browsershot, bring this one here to me, not the original class."
The catch is that even Mockery doesn't recommend using alias:
or overload:
. This can lead to class name collision errors and should be run in separate PHP processes to avoid this.
So, my friend, how do I write this test?
In fact, let's change the approach on how we use Browsershot :)
Dependency Analysis and Dependency Injection
By analyzing the Browsershot::url
method, we can discover what it does, and it's extremely simple.
public static function url(string $url): static
{
return (new static())->setUrl($url);
}
Great, to avoid using alias:
or overload:
, we can simply inject Browsershot into our class. Now, it looks like this:
<?php
declare(strict_types=1);
namespace App\Actions;
use Illuminate\Support\Facades\Storage;
use Spatie\Browsershot\Browsershot;
class GeneratePdf
{
public function __construct(
private Browsershot $browsershot
) {
}
public function handle(
string $fileName,
string $url,
string $paperSize = 'A4'
): string | false {
$path = '/exports/pdf/' . $fileName;
$content = $this->browsershot->setUrl($url)
->format($paperSize)
->noSandbox()
->pdf();
if (!Storage::disk('s3')->put($path, $content)) {
return false;
}
return $path;
}
}
This way, mocking becomes much lighter and efficient:
it('should generate a pdf', function () {
Storage::fake('s3');
$mock = mock(Browsershot::class);
$mock->shouldReceive('setUrl->format->noSandbox->save');
$pdf = (new GeneratePdf($mock))->handle(
fileName: 'my-file-name.pdf',
url: 'https://www.google.com'
);
Storage::disk('s3')
->assertExists($pdf);
});
If you're using Laravel, you can use the $this->mock
method, which interacts directly with the framework's container.
Our test now looks like this:
it('should generate a pdf', function () {
Storage::fake('s3');
Storage::put('pdf/my-file-name.pdf', 'my-fake-file-content');
$this->mock(Browsershot::class)
->shouldReceive('setUrl->format->noSandbox->save');
$pdf = app(GeneratePdf::class)->handle(
fileName: 'my-file-name.pdf',
url: 'https://www.google.com'
);
Storage::disk('s3')
->assertExists($pdf);
});
By doing this, we make our class loosely coupled, allowing us to perform a wide range of tests without much hassle, and we get to use a powerful pattern, which is dependency injection.
Until next time, folks. 😗 🧀
Top comments (0)