< BACK TO THE BLOG

Feature and Unit tests, what they are and how to use them in Laravel

Published January 02, 2022

Happy new year everyone!

Writing tests for your web application is a very worthwhile investment, the time spent writing these tests will pay back tenfold as it gets more and more complex, because these tests will alert you once something is broken, due to a change in the business or in the implementation, so you'll always have a good idea of the state of the application.

Types of Tests

With that said, there are multiple types of tests that you can write, although in my experience everyone calls the concept of writing tests "unit testing", but there are other types of tests, and they are:

  1. Unit tests
  2. Feature tests

Unit test

A unit test is responsible for testing a part of your application in isolation, meaning not how it interacts with everything else, but how it processes data and how it handles various scenarios, you can look at it as how a class for example is supposed to work from a developer's point of view.

Let's take an example, during development of a feature, I've created or I'm going to create (in case of TDD) a class to process urls, a service or helper class if you will, and it looks something like this:

namespace App\Helpers;

class UrlHelper
{
    protected string $url;

    public function __construct(string $url) 
    {
        $this->url = $url;
    }

    public function getUrl(): string
    {
        return $this->url;
    }

    public function addSubdomain(): self
    {
        // implementation
    }

    public function addScheme(): self
    {
        // implementation
    }

    // ...
}

regardless of the feature, I want this class to always receive a url, and I want each method to work as expected, so I would create a unit test for it like so

php artisan make:test UrlHelperTest --unit

which will create a new class called UrlHelperTest in the unit directory.

namespace Tests\Unit;

use PHPUnit\Framework\TestCase;

class UrlHelperTest extends TestCase
{
    /** @test */
    public function it_receives_a_string()
    {
        $this->withoutExceptionHandling();
        $helper = new UrlHelper('https://google.com');
        $this->assertTrue($helper instanceof UrlHelper);
    }

    /** @test */
    public function it_adds_subdomain_if_url_has_www()
    {
        $helper = new UrlHelper('https://www.google.com');
        $withSubdomain = $helper->addSubdomain('xyz');
        $this->assertEquals('https://www.xyz.google.com', $withSubdomain->getUrl());
    }

    /** @test */
    public function it_adds_subdomain_if_url_doesnt_have_www()
    {
        $helper = new UrlHelper('https://google.com');
        $withSubdomain = $helper->addSubdomain('xyz');
        $this->assertEquals('https://xyz.google.com', $withSubdomain->getUrl());
    }

    // ...
}

After finishing this test, we'll be able to verify that all cases that are directly related to this class are accounted for, and once we change the class, these tests could break.

Feature tests

Feature tests are black box tests, and they test a part of an application from end to end, for example an API, or a group of actions etc.

Feature tests are more from the users perspective rather than the developer's, let's say we run blog, our blog uses an SPA, so from the backend we just need an API, and we want to create an endpoint to create replies to comments, let's also assume that we already have this implementation of the Comment model.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model
{
    protected $guarded = ['id'];

    public function replies()
    {
        return $this->hasMany(Comment::class, 'parent_id');
    }

    public function author()
    {
        return $this->belongsTo(User::class);
    }
}

so let's create a controller

php artisan make:controller ReplyController

And we'll add a store method to implement creating a reply, and add the route to it in routes/api.php

Route::resource('comments/{comment}/replies', ReplyController::class)->only(['store']);

We're using Dependency Injection (DI) to initialize the ReplyService and the Comment model (using model binding)

namespace App\Http\Controllers;

use App\Models\Comment;
use App\Services\ReplyService;
use use Illuminate\Http\Request;

class ReplyController extends Controller
{
    public function store(Comment $comment, ReplyService $service, Request $request)
    {
        $request->validate([
            'body' => ['required', 'string'],
        ]);

        $reply = $service->createReply($comment, $request->validated());

        return response()->json(['data' => $reply]);
    }
}

In this simple method we're utilizing a service to create a reply to a comment, we created this service to add the reply to the database, and connect it to a given comment, and send an email to the original comment's author.

<?php

namespace App\Services;

use App\Models\Comment;
use App\Notification\ReplyCreatedNotification;

class ReplyService
{
    public function createReply(Comment $comment, $data)
    {
        $reply = $comment->replies()->create($data);
        $reply->author->notify(new ReplyCreatedNotification($reply));

        return $reply;
    }
}

So to feature test this, we need to test every part of the request and their outcomes, so let's create a blueprint for the test.

php artisan make:test ReplyTest

And within it, we'll create a few test cases to test validation, creation and sending emails

namespace Tests\Feature;

use PHPUnit\Framework\TestCase;
use Illuminate\Support\Facades\Notification;
use App\Notification\ReplyCreatedNotification;

class ReplyTest extends TestCase
{
    /** @test */
    public function it_fails_if_body_is_not_provided()
    {
        $comment = Comment::factory()->create();
        $this->postJson("/api/comments/$comment->id/replies")
            ->assertStatus(422)
            ->assertJsonValidationErrors(['body']);

        $this->assertEmpty($comment->refresh()->replies);
    }

    /** @test */
    public function it_fails_if_comment_doesnt_exist()
    {
        $this->postJson("/api/comments/-1/replies", ['body' => 'test reply'])
            ->assertStatus(404);
    }

    /** @test */
    public function it_creates_a_reply_if_data_is_correct()
    {
        Notification::fake();
        $comment = Comment::factory()->create();
        $this->postJson("/api/comments/$comment->id/replies", ['body' => 'test reply'])
            ->assertStatus(200)
            ->assertJsonFragment(['body' => 'test reply']);

        $this->assertCount(1, $comment->refresh()->replies);
    }

    /** @test */
    public function it_creates_a_reply_and_sends_an_email_if_data_is_correct()
    {
        Notification::fake();
        $comment = Comment::factory()->create();
        $this->postJson("/api/comments/$comment->id/replies", ['body' => 'test reply'])
            ->assertStatus(200)
            ->assertJsonFragment(['body' => 'test reply']);

        $this->assertCount(1, $comment->refresh()->replies);
        Notification::assertSentTo([$comment->author], ReplyCreatedNotification::class);
    }

    // ...
}

Feature vs Unit tests

I think there's a very compelling visual explanation for this, credit to this Laracasts post, this gif explains it all.

As you can see the difference between unit and feature tests is that in unit tests, we're testing each component in isolation, so the drier and the trash can are working fine, but this wasn't feature tested, because in feature tests we're testing an entire action and every service it consumes and the response, so how this API interacts with everything and returning the desired outcome, of course in this case we should also unit test the mail class itself to see if its creating the correct test, as well as the ReplyService to see if its sending the email and creating a new record for the reply.