Testing Fat Laravel Controllers — Pt. 1
Many new products launching out there don’t embrace a TDD approach. It’s the norm that code is pushed to production for months until the MVP is ready or launch is due. That’s when the Project Managers decide, it is time, for testing, only to realize that their codebase has controllers that look like this bad boy:
And that’s not the whole controller :D. It’s pretty hard to refactor code like this or add/change features without breaking things. It’s even harder to test it, but that’s just how things are.
Breaking down the feature we’re testing into steps, we have:
- A logged-in user uploads a photo
- This photo needs to be taken from a mobile device and contain geolocation data
- Store the photo into a filesystem according to its creation date
- Extract the state, city, road, and other geographic details from it
- Store the
Photomodel in the database with the extracted data
- Update the users’ XP and fire a few events
Pretty long for a controller public method, right? Right. Let’s start by testing the happy path first.
From this initial set up we notice that strange
$this->imagePath property. Why do we need it? Here's what's happening: the feature requires geo-tagged images, they need to contain location data, which we found was difficult to create using Laravel
UploadedFile fakes. So we used an image for which we know the location attributes, and we can test against them.
Another thing that’s happening is that, in local/development environments, the controller does not store the images in the storage directory, it stores them directly in public. That makes it hard (impossible?) to test according to the usual Laravel way with the
Storage facade. So we mimic the same approach. Keep a small testing image, less than 1 KB, run the tests, and then delete it manually at the end.
Onto the first test method, we do the usual world-building: fake the Storage, fake the Events, fix the Carbon test time for stable tests and create and log in a user.
Variables to test against
The next step is to create some “input” values that we’ll assert later. We use the test image and make an
UploadedFile fake out of it. We know the value of the other variables like
$address, etc. beforehand, and that is what our controller should save after parsing the image.
Notice the strange assignments of city, state, and country. Those are needed because the calls to
$this->checkCountry($addressArray); on the
PhotosController will persist a new
Country object if it doesn't find one.
The other variables are used to determine the directory where the image will be stored, as well as the name used to reference it elsewhere in the app. The way we calculate the directory using
$datetime is not quite the same as the one used in the
PhotosController, and it doesn't have to be. We want to test the logic behind it, not every single detail we encounter so that we allow the implementation to change, but the logic can remain the same.
Before we post a request to the controller, we do a small sanity check with those three assertions about the user’s
total_images. Those could be omitted, but more often than not it happens that these values are wrong from the start, and we're missing something in our world-building phase. When we assert them later we'll get passing tests, but we might not be testing anything, so it's nice to have them.
After posting a request, we immediately assert that it returns with a 200 status code, and the correct response message. Then, we assert that the image exists in the desired location, and it has the right properties, like
Sigh. That was long, but much-needed :D. It’s appropriate that we update the
$user model by calling
$user->refresh(), because we're making a request, and the framework has no way of knowing that this
$user is being updated during that request lifecycle.
After testing that those values about user
total_images, etc. are incremented properly, we assert that the
Photo model has been persisted, and retrieve it to do some drilling. We assert every single property that is assigned to this model from the controller, and while that may feel too much, it offers a great deal of peace of mind.
Testing fired events and closing
Event testing is super simple. There are two events fired, and we test them in detail. Every argument they receive should match our expected values. And that’s it.
Finally, we delete the image we uploaded during our test to the public directory, to clean up.
And the other not-so-happy paths?
Yes, we haven’t tested the other aspects of this post request. What if an unauthenticated user can wreak havoc on the app? What if they upload a PDF file? Are we asserting that all those exceptions are thrown when they should? Does it upload the photo to AWS S3 on production environments? We need a whole new post about them. Till then, bye.
The code used for illustration is taken from the OpenLitterMap project. They’re doing a great job creating the world’s most advanced open database on litter, brands & plastic pollution. The project is open-sourced and would love your contributions, both as users and developers.
Originally published at https://genijaho.dev on June 17, 2021.