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 asmPDF,FPDF,wkHtmlToPdf, among others. However, today, in my humble opinion, we have one of the best packages for PDF generation on the market, which isBrowsershot. 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.
<?phpdeclare(strict_types=1);namespaceApp\Actions;useIlluminate\Support\Facades\Storage;useSpatie\Browsershot\Browsershot;classGeneratePdf{publicfunctionhandle(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)){returnfalse;}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=(newGeneratePdf())->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 togoogle.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?
withMOCKERY ✨✨✨
Mockery
To simulate the behavior of a class, we can use theMockery
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 Mockerydocumentation, 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 ithere).
it('should generate a pdf',function(){Storage::fake('s3');mock('alias:'.Browsershot::class)->shouldReceive('url->format->noSandbox->pdf');$pdf=(newGeneratePdf())->handle(fileName:'my-file-name.pdf',url:'https://www.google.com');Storage::disk('s3')->assertExists($pdf);});
Alright, but what does this do? When we usealias:
, 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 usingalias:
oroverload:
. 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 theBrowsershot::url
method, we can discover what it does, and it's extremely simple.
publicstaticfunctionurl(string$url):static{return(newstatic())->setUrl($url);}
Great, to avoid usingalias:
oroverload:
, we can simply inject Browsershot into our class. Now, it looks like this:
<?phpdeclare(strict_types=1);namespaceApp\Actions;useIlluminate\Support\Facades\Storage;useSpatie\Browsershot\Browsershot;classGeneratePdf{publicfunction__construct(privateBrowsershot$browsershot){}publicfunctionhandle(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)){returnfalse;}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=(newGeneratePdf($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)
For further actions, you may consider blocking this person and/orreporting abuse