Simple Image Testing in Laravel with Pest

Renato Dehnhardt
5 min read5 days ago

--

I’ve been working with Laravel lately, and testing image-related features has presented some interesting challenges. While PHPUnit is a solid choice, I’ve found Pest Test to be more enjoyable and productive, so we’ll be using it for these examples. To illustrate the issues, I’ll be using a fictional scenario involving registering a photo and associating its path with a database record. The focus here is on the testing process itself, not the specific functionality.

At first, I confess that I also felt a bit lost when testing file uploads. But after I discovered Illuminate\Http\UploadedFile, everything became easier. This class is a lifesaver to simulate the file and test sending without needing a real file. Take a look at the structure of our scenario and see how we solved this problem:

Here’s the simple Photo model we’ll be working with:

<?php

declare(strict_types=1);

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

final class Photo extends Model
{
use HasFactory;

/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = ['path', 'variants'];

/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = ['variants' => 'array'];
}

The Actions:

<?php

declare(strict_types=1);

namespace App\Actions;

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use App\Models\Photo;
use App\Jobs\ImageResize;

final class AttachPhotoAction
{
/**
* @var array|array[]
*/
private array $scales = [
'small' => ['scale' => 480, 'watermark' => false],
'medium' => ['scale' => 720, 'watermark' => false],
'large' => ['scale' => 1080, 'watermark' => true],
];

/**
* Handles the creation and processing of a photo.
*
* @param UploadedFile $file The uploaded file to be stored and processed.
* @return Photo The created photo.
*/
public function handle(UploadedFile $file): Photo
{
return DB::transaction(function () use ($file) {
$photo = Photo::create([
'path' => $file->store('photos'),
'variants' => [
'small' => $file->storeAs('photos', sprintf('small-%s', $file->hashName())),
'medium' => $file->storeAs('photos', sprintf('medium-%s', $file->hashName())),
'large' => $file->storeAs('photos', sprintf('large-%s', $file->hashName())),
],
]);

collect($photo->variants)->each(fn ($variant, $key) => ImageResize::dispatch(
path: $variant,
scale: $this->scales[$key]['scale'],
watermark: $this->scales[$key]['watermark']
));

return $photo;
});
}
}
<?php

declare(strict_types=1);

namespace App\Actions;

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use App\Models\Photo;

final class DetachPhotoAction
{
/**
* Handles the deletion of a photo and its associated variants.
*
* @param Photo $photo The photo instance to be deleted.
* @return bool Returns true if the deletion was successful, otherwise false.
*/
public function handle(Photo $photo): bool
{
return DB::transaction(function () use ($photo) {
Storage::delete(array_merge([$photo->path], array_values($photo->variants)));

return $photo->delete();
});
}
}

And the test for this is basically how the Laravel documentation shows us.

<?php

declare(strict_types=1);

use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use App\Actions\AttachPhotoAction;
use App\Models\Photo;
use App\Jobs\ImageResize;

it('can attach a photo', function () {
Queue::fake();
Storage::fake();
$file = UploadedFile::fake()->image('photo.jpg');

$photo = app(AttachPhotoAction::class)->handle($file);

expect($photo)->toBeInstanceOf(Photo::class);
$this->assertEquals($file->hashName('photos')), $model->path);
Storage::assertExists($model->path);
Queue::assertPushed(ImageResize::class, 3);
});

So far, so good! As you can see, we’re following the steps in the Laravel documentation. Simple as that!

So, here’s where things got a bit tricky. I needed to test something that was supposed to have an image already in place. Like, how do you test deleting a photo record and its image from storage when you’re not even sure if the image is there in the first place? It was a bit of a head-scratcher.

So, I started with a photo removal test that looked a little something like this:

<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Storage;
use App\Actions\DetachPhotoAction;
use App\Models\Photo;

it('can detach a photo', function () {
Storage::fake();
$photo = Photo::factory()->create([
'path' => 'test.jpg',
'variants' => [
'small' => 'small-test.jpg',
'medium' => 'medium-test.jpg',
'large' => 'large-test.jpg'
]
]);

$model = app(DetachPhotoAction::class)->handle($photo);

expect($model)->toBeBool();
$this->assertDatabaseMissing('photos', $photo->toArray());
Storage::assertMissing([$photo->path, $photo->variants['small'], $photo->variants['medium'], $photo->variants['large']]);
});

And guess what? It didn’t work! Turns out, just creating a database record doesn’t magically create an image file in your storage. Who knew? (Well, you probably did.) The thing is, Faker is great and all, but it doesn’t play well with images in the way we need for these tests.

At that moment, I stopped and thought: how can I solve this in a way that’s both simple and comprehensive? I needed a solution that would work for most scenarios, without making things overly complicated.

This approach obviously didn’t work. Just creating database entries doesn’t magically create image files. That’s when I realized the solution was staring me in the face: custom functions in Pest.php!

<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Package\Tests\TestCase;

pest()->extend(TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature', 'Unit');
/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
*/
expect()->extend('toBeOne', fn () => $this->toBeOne(1));

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
*/
function image(?string $name = null, int $width = 1920, int $height = 1080): string
{
Storage::put($name ?? sprintf('%s.jpg', now()->timestamp), Http::get(sprintf('https://picsum.photos/%s/%s', $width, $height))->body());

return $name;
}

And just like that, we’ve solved the puzzle! Our image function, which fetches an image from Lorem Picsum and saves it to Storage, has made our test incredibly simple, clean, and readable. This makes testing a breeze! Just look at how our test turned out:

<?php

declare(strict_types=1);

use Illuminate\Support\Facades\Storage;
use App\Actions\DetachPhotoAction;
use App\Models\Photo;

it('can detach a photo', function () {
Storage::fake();
$photo = Photo::factory()->create([
'path' => image('test.jpg'),
'variants' => [
'small' => image('small-test.jpg'),
'medium' => image('medium-test.jpg'),
'large' => image('large-test.jpg')
]
]);

$model = app(DetachPhotoAction::class)->handle($photo);

expect($model)->toBeBool();
$this->assertDatabaseMissing('photos', $photo->toArray());
Storage::assertMissing([$photo->path, $photo->variants['small'], $photo->variants['medium'], $photo->variants['large']]);
});

And the best part is that if, by any chance, Lorem Picsum stops working — as Placeholder.com did — we just need to change this function and we’ll solve all the image problems in the application. Simple and efficient!

With this approach, it’s evident how creating custom functions in the Pest.php file can drastically simplify testing functionalities. By centralizing the logic and facilitating file simulation, we ensure that our tests are more efficient, readable, and reliable. How do you usually handle the challenges of testing functionalities? Share your tips and experiences in the comments below!

--

--

Renato Dehnhardt
Renato Dehnhardt

Responses (1)