Let's continue developing our news module.

Image Upload

We've finished the basic news creation, but as you remember a news article can also have an associated image. Let's write a new test method to test image upload.

/** @test */
public function authenticated_users_can_create_news_with_images()
{
    //
}

I understand if you have questions like "Where do we get an image to upload during our tests?" and "Do we have to manually delete uploaded image files after the test?". Fortunately, Laravel provides us with a simple solution.

The first concept we need to learn is called mocking. Certain Laravel classes have special methods to "fake" or "mock" certain object, actions, and conditions. As we're going to work with file upload, we will be using Storage Fake, which generates a fake disk to upload to. The disk is going to be located within the storage/framework/testing directory, which is different from where your actual uploaded files are stored. Add this line to the top of our method to fake the public disk:

Storage::fake('public');

Now all the Storage operations will affect that fake public disk instead of the real thing. Then we need to prepare our form data, which will now include a file to upload. And where do we get a file to upload? We also fake it! The UploadedFile class has a fake() method which generates a fake document or a fake image.

$data = [
    'title' => 'Some Title',
    'body' => 'News Article.',
    // An image file called 'image.jpg' with width = 1px and height = 1px
    'image' => $file = UploadedFile::fake()->image('image.jpg', 1, 1)
];

We can now perform the request and get the newly added news item from the database.

$this->actingAs($this->user)
    ->postJson(route('news.store'), $data)
    ->assertStatus(201);

$this->assertEquals(1, News::count());

$news = News::first();

This time we won't be asserting that the text fields were set correctly or that the news database table has exactly one record - we did all that in a separate test method. Instead, we're going to check that the file was uploaded and the path to the file was set to the image_path field of the news. We'll be expecting our files to be uploaded into the news directory. Note that the Storage class has its own test assertions we can use.

$imagePath = 'news/' . $file->hashName();

$this->assertEquals($imagePath, $news->image_path);

Storage::disk('public')->assertExists($imagePath);

And this is the whole test method:

/** @test */
public function authenticated_users_can_create_news_with_images()
{
    Storage::fake('public');

    $data = [
        'title' => 'Some Title',
        'body' => 'News Article.',
        // An image called 'image.jpg' with width = 1px and height = 1px
        'image' => $file = UploadedFile::fake()->image('image.jpg', 1, 1)
    ];

    $this->actingAs($this->user)
        ->postJson(route('news.store'), $data)
        ->assertStatus(201);

    $news = News::first();

    $imagePath = 'news/' . $file->hashName();

    $this->assertEquals($imagePath, $news->image_path);

    Storage::assertExists($imagePath);
}

And as we expect, the test will fail as we don't handle the image upload in the back-end yet. Move on to the NewsController. We need to make some changes. First, let's extract the validated data from the request into an array. Simply assign the validation method's results to a $data variable. The validation method returns the list of valid fields.

$data = request()->validate([
    'title' => 'required|string|max:255',
    'body' => 'required|string',
    'image' => 'mimes:jpeg,png,gif|nullable'
]);

Then pass that variable to the News::create() method:

$news = News::create($data);

Let's process our image right before this line of code. What we need to do is upload the image if it was passed with the request, and then set the image_path field on the $data array to the path where this image has been uploaded to.

if (isset($data['image'])) {
    $data['image_path'] = $data['image']->store('news');
}

Ok, now you'll get an SQL error telling you that the image field doesn't exist on the news table. Obviously, the validate() method has returned the image field. It will also return any request fields that don't have a validation rule, considering them valid. If you don't want to manually set each field in News::create() (like what we had before), and that does get annoying with bigger forms, you should do the following.

Open the News.php model and remove the protected $guarded = []; line we added before. Instead, add this:

protected $fillable = [
    'title',
    'body',
    'image_path'
];

This is like telling Laravel "When inserting into the database, only take these fields from the accepted data array and ignore the rest". If some fields should not be fillable by a user (for example a news view count), you should remove them from this list or write a corresponding check in your code and handle the field manually.

Anyway, the test should pass now. Congratulations! At this point you have the back-end code to create new news which is well tested. The only thing left is to create a page with a form and send the form data to the existing endpoint. But that's not what this tutorial is about. Now we're going to move on to editing the existing news.

Updating News

Create a new feature test class:

php artisan make:test EditNewsTest

Go ahead and use the RefreshDatabase trait as we did with the CreateNewsTest class. You should remove the testExample method as well. Now, before we start, I just realized we're also going to need a user to perform the actions under in this test class as well. We created a dummy user inside the setUp method in the CreateNewsTest class. Let's cut the $this->user = factory('App\User')->create(); line and move it to the end of the setUp method in the TestCase class. Then remove the setUp method from the CreateNewsTest class. Just to make sure, and this is a good practice, run your whole test suite and check that everything is still good.

Alright, now to our new tests. Some of the concepts won't be new here, so I'll move a bit faster. The first test we're going to write is going to assure unauthenticated users cannot update existing news. So we need to create a news before trying to update it, and to make this operation simple we're going to create a factory class.

php artisan make:factory NewsFactory

Open the factory file at database/factories/NewsFactory.php. Change Model::class to our News model class, i.e. App\News::class. Then populate the data array with fake data using Faker. We're going to skip the image.

$factory->define(App\News::class, function (Faker $faker) {
    return [
        // 3 = an array of 3 random words
        // true = implode the array into a string with words separated by space
        'title' => $faker->words(3, true),
        'body' => $faker->paragraph,
    ];
});

Now back to our test. It is going to be very similar to a test form CreateNewsTest.

/** @test */
public function unauthenticated_users_cant_update_existing_news()
{
    $this->withExceptionHandling();

    $news = factory('App\News')->create();

    $this->postJson(route('news.update', $news))
        ->assertStatus(401);
}

Run the test... Route [news.update] not defined.. Go to the routes file and define the route under the auth middleware group.

Route::post('/news/{news}', 'NewsController@update')->name('news.update');

Run the test again and it should pass, even though we don't have a controller method yet - the middleware stops the execution before a controller method call. The next test is going to assure that authenticated users can update the text data of the news. Note that the data we provide is going to be always different from the data generated by Faker.

/** @test */
public function authenticated_users_can_update_existing_news()
{
    $news = factory('App\News')->create();

    $data = [
        'title' => 'Updated Title',
        'body' => 'Updated body.',
    ];

    $this->actingAs($this->user)
        ->postJson(route('news.update', $news), $data)
        ->assertStatus(200);

    $news = $news->fresh();

    $this->assertEquals($data['title'], $news->title);
    $this->assertEquals($data['body'], $news->body);
}

Here we're just creating a dummy news using the NewsFactory and then updating this existing news. Nothing new here except for the $news = $news->fresh(); line. We'll be updating the database record in the update method of the NewsController. The fresh() methods just fetches the "fresh" data, or in other words, re-fetches the updated data from the database.

Let's go ahead and write the test related to image upload right away. Our users should be able to replace an image when updating a news. At the same time we should explicitly check that the old image file was deleted. I've explained what we're doing in the comments.

/** @test */
public function authenticated_users_can_replace_the_image_when_updating_existing_news()
{
    Storage::fake('public');

    // Perform an actual request to have a news with an uploaded file
    // The factory generated news don't have real files attached
    $data = [
        'title' => 'Some Title',
        'body' => 'News Article.',
        // An image called 'image.jpg' with width = 1px and height = 1px
        'image' => UploadedFile::fake()->image('image.jpg', 1, 1)
    ];

    // Remember we're returning a json response with the newly created news
    // in the 'create' method? We're going to get that json response here.
    // Note that the returned data is an array
    $news = $this->actingAs($this->user)
        ->postJson(route('news.store'), $data)
        ->assertStatus(201)
        ->json();

    // Replace the image
    $data['image'] = $newFile = UploadedFile::fake()->image('new_image.jpg', 1, 1);

    // Update the news
    $this->actingAs($this->user)
        ->postJson(route('news.update', $news['id']), $data)
        ->assertStatus(200);

    // Make sure the old file was deleted
    Storage::assertMissing($news['image_path']);

    // We can't call 'fresh()' on an array. Instead, we have to re-fetch the
    // data manually
    $news = News::find($news['id']);

    // Assert the new image file was uploaded and the 'image_path' field was set
    // correctly
    $newImagePath = 'news/' . $newFile->hashName();

    $this->assertEquals($newImagePath, $news->image_path);

    Storage::assertExists($newImagePath);
}

Run both new tests - they'll fail. Now let's implement the controller method.

public function update(News $news)
{
    $data = request()->validate([
        'title' => 'required|string|max:255',
        'body' => 'required|string',
        'image' => 'mimes:jpeg,png,gif|nullable'
    ]);

    if (isset($data['image'])) {
        $data['image_path'] = $data['image']->store('news');

        // Remove the old image if any
        if ($news->image_path && Storage::disk('local')->exists($news->image_path)) {
            Storage::disk('local')->delete($news->image_path);
        }
    }

    $news->update($data);

    return request()->wantsJson()
        ? response()->json($news, 200)
        : null;
}

Run the tests again and everything should pass. You've probably noticed that create and update methods are almost the same. This means we could and should refactor them by extracting the repetitive code. Below is the full code of the NewsController after refactoring. It's all the usual Laravel stuff and hopefully doesn't require explanations.

<?php

namespace App\Http\Controllers;

use App\News;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\Storage;

class NewsController extends Controller
{
    public function store()
    {
        $this->validator(request()->all())->validate();

        $news = $this->createOrUpdate(request()->all());

        return request()->wantsJson()
            ? response()->json($news, 201)
            : null;
    }

    public function update(News $news)
    {
        $this->validator(request()->all())->validate();

        $news = $this->createOrUpdate(request()->all(), $news);

        return request()->wantsJson()
            ? response()->json($news, 200)
            : null;
    }

    public function validator($data)
    {
        return Validator::make($data, [
            'title' => 'required|string|max:255',
            'body' => 'required|string',
            'image' => 'mimes:jpeg,png,gif|nullable'
        ]);
    }

    public function createOrUpdate($data, $news = null)
    {
        if (isset($data['image'])) {
            // Upload the new image
            $data['image_path'] = $data['image']->store('news');

            // Remove the old image if any
            if (optional($news)->image_path && Storage::disk('local')->exists($news->image_path)) {
                Storage::disk('local')->delete($news->image_path);
            }
        }

        if ($news) {
            $news->update($data);
        } else {
            $news = News::create($data);
        }

        return $news;
    }
}

If you run your whole test suite again nothing should break.

Final Words

I'll wrap this tutorial up on this note. We've covered the basic workflow of Test-Driven Development and now you're good to go on your own. I hope you will be using the TDD approach in your real projects from now on.

Although, there's more out there in terms of testing. I highly recommend you checking out the full list of PHPUnit assertions and the Laravel documentation (you should remember that Laravel brings more to the standard PHPUnit).