Image upload test journey

Janez Cergolj

For my open-source project, I needed an option for the user to upload an image. Furthermore, this image needed to be resized. For resizing, I used a well-know package called Intervention. The testing journey wasn't as smooth as I imagined, so let's find out why.

First, let's look at my first a bit naive approach.

Naive approach

// routes/web.php
<?php
Route::post('/images', [\App\Http\Controllers\ImagesController::class, 'store']);
// app/Http/Controllers/ImagesController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Intervention\Image\Facades\Image;

class ImagesController extends Controller
{
    public function store(Request $request)
    {
        if (!file_exists(storage_path('app/images'))) {
            mkdir(storage_path('app/images'));
        }

        $image = Image::make($request->file('image'));

        $image->resize(250,250)
            ->save(storage_path('app/images/') . $request->file('image')->getClientOriginalName());
    }
}
// tests/Features/ImagesControllerTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Http\UploadedFile;

class ImagesControllerTest extends TestCase
{
    /** @test */
    function user_can_upload_image_and_image_is_resized()
    {
        @unlink(storage_path('app/images/image.jpg'));

        $response = $this->post('/images', [
            'image' => UploadedFile::fake()->image('image.jpg', 1000, 1000)
        ]);

        $this->assertTrue(file_exists(storage_path('app/images/image.jpg')));

        $size = getimagesize(storage_path('app/images/image.jpg'));

        $this->assertSame(250, $size[0]);
        $this->assertSame(250, $size[1]);
    }
}

Before you read the rest of the article, please pause and let me know on Twitter if there is anything that you don't like about this approach. I let you know my reservations here :)

Have you posted your thoughts on Twitter yet? No!? You have still the time to do it now :)

My thoughts

While coding and testing, I found out that there is one main issue with a cascading effect on all the code. Because I resized image directly in the controller, I couldn't use storage fake disk (Storage::fake()). Due to this fact, I first need to explicitly check if the folder exists in the controller. Furthermore, in the test, I need to delete the file manually. Last but not least, the controller shouldn't be responsible for resizing images in the first place. Granted in this oversimplified example this isn't an issue, but let's imagine how would code looks like with more feature reach real-life example.

Is this better?

// routes/web.php
<?php
Route::post('/images', [\App\Http\Controllers\ImagesController::class, 'store']);
// app/Http/Controllers/ImagesController.php
<?php

namespace App\Http\Controllers;

use App\Events\ImageUploaded;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;

class ImagesController extends Controller
{
    public function store(Request $request)
    {
        Storage::putFileAs(
            'images',
            $request->file('image'),
            $request->file('image')->getClientOriginalName()
        );

        ImageUploaded::dispatch($request->file('image')->getClientOriginalName());
    }
}
// app/Providers/EventServiceProvider.php
<?php

namespace App\Providers\EventServiceProvider;

use App\Events\ImageUploaded;
use App\Listeners\ResizeImage;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    /**
     * The event listener mappings for the application.
     *
     * @var array
     */
    protected $listen = [
        ImageUploaded::class => [
            ResizeImage::class,
        ],
    ];

    /**
     * Register any events for your application.
     *
     * @return void
     */
    public function boot()
    {
        parent::boot();
    }
}
// app/Events/ImageUploaded.php
<?php

namespace App\Events\;

use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ImageUploaded
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $imageName;

    public function __construct($imageName)
    {
        $this->imageName = $imageName;
    }
}
// tests/Feature/ImagesControllerTest.php;
<?php

namespace Tests\Feature;

use App\Events\ImageUploaded;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

class ImagesControllerTest extends TestCase
{

    /** @test */
    public function user_can_upload_image_and_image_is_resized()
    {
        Storage::fake();
        Event::fake();

        $this->post('/images', [
            'image' => UploadedFile::fake()->image('image.jpg', 1000, 1000),
        ]);

        Storage::assertExists('images/image.jpg');

        Event::assertDispatched(ImageUploaded::class, 1);

        Event::assertDispatched(ImageUploaded::class, function ($event) {
            return $event->imageName === 'image.jpg';
        });
    }
}
// tests/Unit/ResizeImageTest.php;
<?php

namespace Tests\Unit;

use App\Events\ImageUploaded;
use App\Listeners\ResizeImage;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;

/** @see \App\Listeners\ResizeImage */
class ResizeImageTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function profile_image_is_resized()
    {
        Storage::putFileAs(
            'images',
            UploadedFile::fake()->image('image.jpg', 1000, 1000),
            'image.jpg'
        );

        $event = new ImageUploaded('image.jpg');
        $listener = new ResizeImage();
        $listener->handle($event);


        $imageProperties = getimagesize(config('filesystems.disks.local.root').'/images/image.jpg');

        $this->assertSame(100, $imageProperties[0]);
        $this->assertSame(100, $imageProperties[1]);

        Storage::delete("/images/image.jpg");
    }
}

At first glance, there is a lot of additional code, and I can't argue this isn't the case. However, whenever you gain something, you lose something else. With refactoring code to the last example we: 1. nicely utilize Laravel's Storage::fake() testing feature; 2. simplified ImagesController; 3. moved resizing image feature to the listener; 4. made code easier to extend;

Verdict

Is there something wrong with so-called naive approach? It depends. I would go with it for small/demo projects with short lifespan. For larger ones where we expect business requirements to change and the code to evolve, I would choose the second one all the time.

And finally, don't fight the framework, embrace it and its features.