DEV Community

Cover image for Testing Fat Laravel Controllers - Pt. 1
Geni Jaho
Geni Jaho

Posted on • Edited on • Originally published at genijaho.dev

1

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:

<?php
namespace App\Http\Controllers;
use ...;
class PhotosController extends Controller
{
use CheckLocations;
/**
* Apply middleware to all of these routes
*/
public function __construct()
{
$this->middleware('auth');
}
/**
* Move photo to AWS S3 in production || local in development
* then persist new record to photos table
*/
public function store(Request $request)
{
$this->validate($request, [
'file' => 'required|mimes:jpg,png,jpeg'
]);
$user = Auth::user();
if ($user->has_uploaded == 0) $user->has_uploaded = 1;
if ($user->images_remaining == 0) {
abort(500, "Sorry, your max upload limit has been reached.");
}
$file = $request->file('file');
$image = Image::make($file);
// Resize the image
$image->resize(500, 500);
$image->resize(500, 500, function ($constraint) {
$constraint->aspectRatio();
});
$exif = $image->exif();
// Check if the EXIF has GPS data
if (!array_key_exists("GPSLatitudeRef", $exif)) {
abort(500, "Sorry, no GPS on this one");
}
// Extract the datetime from the EXIF
$dateTime = '';
if (array_key_exists('DateTimeOriginal', $exif)) {
$dateTime = $exif["DateTimeOriginal"];
}
if (!$dateTime) {
if (array_key_exists('DateTime', $exif)) {
$dateTime = $exif["DateTime"];
}
}
if (!$dateTime) {
if (array_key_exists('FileDateTime', $exif)) {
$dateTime = $exif["FileDateTime"];
$dateTime = Carbon::createFromTimestamp($dateTime);
}
}
// Convert to YYYY-MM-DD hh:mm:ss format
$dateTime = Carbon::parse($dateTime);
// Check if the user has already uploaded this image
if (app()->environment() === 'production') {
if (Photo::where(['user_id' => $user->id, 'datetime' => $dateTime])->first()) {
abort(500, "You have already uploaded this file!");
}
}
// Create dir/filename and move to AWS S3
$explode = explode('-', $dateTime);
$y = $explode[0];
$m = $explode[1];
$d = substr($explode[2], 0, 2);
$filename = $file->hashName();
$filepath = $y . '/' . $m . '/' . $d . '/' . $filename;
// Upload the image to AWS
if (app()->environment('production')) {
$s3 = Storage::disk('s3');
$s3->put($filepath, $image->stream(), 'public');
$imageName = $s3->url($filepath);
} else {
$public_path = public_path('local-uploads/' . $y . '/' . $m . '/' . $d);
// public/local-uploads/y/m/d
if (!file_exists($public_path)) {
mkdir($public_path, 666, true);
}
$image->save($public_path . '/' . $filename);
$imageName = config('app.url') . '/local-uploads/' . $y . '/' . $m . '/' . $d . '/' . $filename;
}
// Get phone model
$model = (array_key_exists('Model', $exif))
? $exif["Model"]
: 'Unknown';
// Get coordinates
$lat_ref = $exif["GPSLatitudeRef"];
$lat = $exif["GPSLatitude"];
$long_ref = $exif["GPSLongitudeRef"];
$long = $exif["GPSLongitude"];
// Convert Degrees, Minutes and Seconds to Lat, Long
$latlong = self::dmsToDec($lat, $long, $lat_ref, $long_ref);
$latitude = $latlong[0];
$longitude = $latlong[1];
// Reverse Geocode the location
$apiKey = config('services.location.secret');
$url = "https://locationiq.org/v1/reverse.php?format=json&key=" . $apiKey . "&lat=" . $latitude . "&lon=" . $longitude . "&zoom=20";
// The entire reverse geocoded result
$revGeoCode = json_decode(file_get_contents($url), true);
// The entire address as a string
$display_name = $revGeoCode["display_name"];
// Extract the address array
$addressArray = $revGeoCode["address"];
$location = array_values($addressArray)[0];
$road = array_values($addressArray)[1];
// These methods extract and set the values as class properties
$this->checkCountry($addressArray);
$this->checkState($addressArray);
$this->checkDistrict($addressArray);
$this->checkCity($addressArray);
$this->checkSuburb($addressArray);
$countryId = Country::where('country', $this->country)
->orWhere('countrynameb', $this->country)
->orWhere('countrynamec', $this->country)->first()->id;
$stateId = State::where('state', $this->state)
->orWhere('statenameb', $this->state)->first()->id;
$cityId = City::where('city', $this->city)->first()->id;
$geohash = GeoHash::encode($latlong[0], $latlong[1]);
$user->photos()->create([
'filename' => $imageName,
'datetime' => $dateTime,
'lat' => $latlong[0],
'lon' => $latlong[1],
'display_name' => $display_name,
'location' => $location,
'road' => $road,
'suburb' => $this->suburb,
'city' => $this->city,
'county' => $this->state,
'state_district' => $this->district,
'country' => $this->country,
'country_code' => $this->countryCode,
'model' => $model,
'country_id' => $countryId,
'state_id' => $stateId,
'city_id' => $cityId,
'platform' => 'web',
'geohash' => $geohash,
'team_id' => $user->active_team,
'five_hundred_square_filepath' => $imageName // new 10th April 2021
]);
$user->xp++;
$user->total_images++;
$user->save();
$teamName = null;
if ($user->team) $teamName = $user->team->name;
// Broadcast this event to anyone viewing the global map
// This will also update country, state, and city.total_contributors_redis
event(new ImageUploaded(
$this->city,
$this->state,
$this->country,
$this->countryCode,
$imageName,
$teamName,
$user->id,
$countryId,
$stateId,
$cityId
));
// Increment the { Month-Year: int } value for each location
event(new IncrementPhotoMonth($countryId, $stateId, $cityId, $dateTime));
return ['msg' => 'success'];
}
/**
* Convert Degrees, Minutes and Seconds to Lat, Long
* Cheers to Hassan for this!
*
* "GPSLatitude" => array:3 [ might be an array
* 0 => "51/1"
* 1 => "50/1"
* 2 => "888061/1000000"
* ]
*/
private function dmsToDec($lat, $long, $lat_ref, $long_ref)
{
$lat[0] = explode("/", $lat[0]);
$lat[1] = explode("/", $lat[1]);
$lat[2] = explode("/", $lat[2]);
$long[0] = explode("/", $long[0]);
$long[1] = explode("/", $long[1]);
$long[2] = explode("/", $long[2]);
$lat[0] = (int)$lat[0][0] / (int)$lat[0][1];
$long[0] = (int)$long[0][0] / (int)$long[0][1];
$lat[1] = (int)$lat[1][0] / (int)$lat[1][1];
$long[1] = (int)$long[1][0] / (int)$long[1][1];
$lat[2] = (int)$lat[2][0] / (int)$lat[2][1];
$long[2] = (int)$long[2][0] / (int)$long[2][1];
$lat = $lat[0] + ((($lat[1] * 60) + ($lat[2])) / 3600);
$long = $long[0] + ((($long[1] * 60) + ($long[2])) / 3600);
if ($lat_ref === "S") $lat = $lat * -1;
if ($long_ref === "W") $long = $long * -1;
return [$lat, $long];
}
}

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 Photo model 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.

Setting up

<?php
// imports
class UploadPhotoTest extends TestCase
{
private $imagePath;
protected function setUp(): void
{
parent::setUp();
$this->imagePath = storage_path('framework/testing/1x1.jpg');
}
public function test_a_user_can_upload_a_photo()
{
Storage::fake();
Event::fake([ImageUploaded::class, IncrementPhotoMonth::class]);
Carbon::setTestNow();
$user = User::factory()->create([
'active_team' => Team::factory()
]);
$this->actingAs($user);
}
}

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

<?php
$this->actingAs($user);
// Test image attributes ----------------------------
$exifImage = file_get_contents($this->imagePath);
$file = UploadedFile::fake()->createWithContent(
'image.jpg',
$exifImage
);
$latitude = 40.053030045789;
$longitude = -77.15449870066;
$geoHash = 'dr15u73vccgyzbs9w4uj';
$displayName = '10735, Carlisle Pike, Latimore Township,' .
' Adams County, Pennsylvania, 17324, USA';
$address = [
"house_number" => "10735",
"road" => "Carlisle Pike",
"city" => "Latimore Township",
"county" => "Adams County",
"state" => "Pennsylvania",
"postcode" => "17324",
"country" => "United States of America",
"country_code" => "us",
"suburb" => "unknown"
];
// Since these models are created on runtime
// and we haven't uploaded any images before
// their ids should be 1
$countryId = 1;
$stateId = 1;
$cityId = 1;
$dateTime = now();
$year = $dateTime->year;
$month = $dateTime->month < 10 ? "0$dateTime->month" : $dateTime->month;
$day = $dateTime->day < 10 ? "0$dateTime->day" : $dateTime->day;
$localUploadsPath = "/local-uploads/$year/$month/$day/{$file->hashName()}";
$filepath = public_path($localUploadsPath);
$imageName = config('app.url') . $localUploadsPath;
// Test image attributes ----------------------------
view raw variables.php hosted with ❤ by GitHub

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 $latitude, $longitude, $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.

The test

<?php
$this->assertEquals(0, $user->has_uploaded);
$this->assertEquals(0, $user->xp);
$this->assertEquals(0, $user->total_images);
$response = $this->post('/submit', [
'file' => $file,
]);
$response->assertOk()->assertJson(['msg' => 'success']);
// Image is uploaded
$this->assertFileExists($filepath);
// Image has the right dimensions
$image = Image::make(file_get_contents($filepath));
$this->assertEquals(500, $image->width());
$this->assertEquals(500, $image->height());
view raw test.php hosted with ❤ by GitHub

Before we post a request to the controller, we do a small sanity check with those three assertions about the user's XP, has_uploaded, and 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 height and width.

<?php
// User info gets updated
$user->refresh();
$this->assertEquals(1, $user->has_uploaded);
$this->assertEquals(1, $user->xp);
$this->assertEquals(1, $user->total_images);
// The Photo is persisted correctly
$this->assertCount(1, $user->photos);
/** @var Photo $photo */
$photo = $user->photos->first();
$this->assertEquals($imageName, $photo->filename);
$this->assertEquals($dateTime, $photo->datetime);
$this->assertEquals($latitude, $photo->lat);
$this->assertEquals($longitude, $photo->lon);
$this->assertEquals($displayName, $photo->display_name);
$this->assertEquals($address['house_number'], $photo->location);
$this->assertEquals($address['road'], $photo->road);
$this->assertEquals($address['suburb'], $photo->suburb);
$this->assertEquals($address['city'], $photo->city);
$this->assertEquals($address['state'], $photo->county);
$this->assertEquals($address['postcode'], $photo->state_district);
$this->assertEquals($address['country'], $photo->country);
$this->assertEquals($address['country_code'], $photo->country_code);
$this->assertEquals('Unknown', $photo->model);
$this->assertEquals($countryId, $photo->country_id);
$this->assertEquals($stateId, $photo->state_id);
$this->assertEquals($cityId, $photo->city_id);
$this->assertEquals('web', $photo->platform);
$this->assertEquals($geoHash, $photo->geohash);
$this->assertEquals($user->active_team, $photo->team_id);
$this->assertEquals($imageName, $photo->five_hundred_square_filepath);

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 XP, 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.

<?php
// The right events are fired
Event::assertDispatched(
ImageUploaded::class,
function (ImageUploaded $e) use ($user, $address, $imageName, $countryId, $stateId, $cityId) {
return $e->city === $address['city'] &&
$e->state === $address['state'] &&
$e->country === $address['country'] &&
$e->countryCode === $address['country_code'] &&
$e->imageName === $imageName &&
$e->userId === $user->id &&
$e->countryId === $countryId &&
$e->stateId === $stateId &&
$e->cityId === $cityId;
}
);
Event::assertDispatched(
IncrementPhotoMonth::class,
function (IncrementPhotoMonth $e) use ($countryId, $stateId, $cityId, $dateTime) {
return $e->country_id === $countryId &&
$e->state_id === $stateId &&
$e->city_id === $cityId &&
$dateTime->is($e->created_at);
}
);
// Tear down
File::delete($filepath);
view raw events.php hosted with ❤ by GitHub

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.

Postmark Image

Speedy emails, satisfied customers

Are delayed transactional emails costing you user satisfaction? Postmark delivers your emails almost instantly, keeping your customers happy and connected.

Sign up

Top comments (0)

Cloudinary image

Optimize, customize, deliver, manage and analyze your images.

Remove background in all your web images at the same time, use outpainting to expand images with matching content, remove objects via open-set object detection and fill, recolor, crop, resize... Discover these and hundreds more ways to manage your web images and videos on a scale.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay