Writing testable code in Laravel with Pest

In this post we are going to see how Pest works in Laravel. We will also take a look at how to write testable code and properly mock objects. To write the tests we can use the Pest package (build on top of PHP Unit) to write readable and clean tests.

Testing?

First a little introduction of testing in Laravel and what the differences are between unit and feature tests.

Unit Tests

Unit tests are used to test small pieces of code, something like a method from a class. For example:

<?php

it('can multiply two numbers', function () {
    $result = Calculator::multiply(2, 2);
    expect($result)->toBe(4);
});

Feature Tests

Feature tests are used to test multiple parts at once or better said scenarios. For example a HTTP request to a web page and see if the result is what you expect it to be.

<?php

it('renders the homepage with a 200 status code', function () {
    $response = get('/'));

    $response->assertOk();
    $response->assertSee('Hello World');
});

The test code seems to be very simple but the underlying code is much bigger then what we did in the unit test above.

Setup the project & dependencies

Lets setup a new Laravel project composer create-project laravel/laravel test-app (test-app is the name of the project). And CD into the project.

Beside Laravel we also need Pest (The elegant PHP testing framework). Run the composer command composer require pestphp/pest --dev --with-all-dependencies. This will install a new dev dependency with the Pest project.

Now init pest so it creates some files that are needed by the framework ./vendor/bin/pest --init.

Open up the tests/Pest.php file and add 'Unit' to the array of classes that use the TestCase class.

uses(
    Tests\TestCase::class,
    // Illuminate\Foundation\Testing\RefreshDatabase::class,
)->in('Feature', 'Unit');

This is needed so we can access Laravel facade classes and other framework parts from our unit tests.

Create a service class

For the testing purpose we will create a service class that we can call from a controller. Create a new file inside app/Services/PublicApiService.php. This file serve as a wrapper around the open Api Public API

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;

class PublicApiService
{
    protected string $baseUrl = 'https://api.publicapis.org';

    public function getEntriesCount(): int
    {
        return Http::get("{$this->baseUrl}/entries")->json('count');
    }
}

And create the controller php artisan make:controller HomeController.

<?php

namespace App\Http\Controllers;

use App\Services\PublicApiService;

class HomeController
{
    public function index(): int
    {
        $service = new PublicApiService;
        
        return $service->getEntriesCount();
    }
}

Writing the tests

The code above may or not be wrong at the current state. Ignore any errors you see for now and lets start writing a test first.

Writeing a test for our service

Create a new test file inside the tests folder: tests/Unit/PublicApiServiceTest.php.

<?php

use App\Services\PublicApiService;

it('can get all entries from the public api', function () {
    $service = new PublicApiService;
    $entries = $service->getEntriesCount();
});

Now this would result in an actual API call to the public api. In some cases you might not want this. For example when the api is paid per request, or when the api is not available the test would fail. Luckily Laravel provides an easy way to fake the HTTP call in tests through the facade fake method.

<?php

use App\Services\PublicApiService;
use Illuminate\Support\Facades\Http;

it('can get all entries from the public api', function () {
    Http::fake([
        'https://api.publicapis.org/*' => Http::response(['count' => 123])
    ]);

    $service = new PublicApiService;
    $count = $service->getEntriesCount();

    expect($count)->toBe(123);
});

And run the test with php artisan test. This will result in a green test with 1 assertion to be valid.

Writing a test for our controller

Next up is the test for our controller. Create a new test file inside tests/Unit/HomeControllerTest.php.

<?php

use App\Http\Controllers\HomeController;

it('returns a valid entries count', function () {
    $controller = new HomeController;
    $result = $controller->index();

    expect($result)->toBe(123);
});

And run the test through php artisan test. This time the test will fail since the result is expected to be 123 but is not 123. This happens because we are making an actual HTTP call to the public api since we did not implement the fake method, right?

We could fix this by adding the fake part to the test but that would result in fixing something that is outside of our controller. And since we are writing a unit test for our index method of the controller this should not be the case.

Fixing our controller

Instead lets fix our controller so we properly make use of dependency injection and are able to mock the service.

<?php

namespace App\Http\Controllers;

use App\Services\PublicApiService;

class HomeController
{
    public function index(PublicApiService $service): int
    {
        return $service->getEntriesCount();
    }
}

Here we make use of method injection that is allowed by Laravel on controller methods. Laravel automatically resolves the service in the dependency container and injects it as a paramter.

Mock the service

Now that we make use of the dependency container we are able to mock the class. Lets update the test:

<?php

use App\Http\Controllers\HomeController;
use App\Services\PublicApiService;
use Mockery;

it('returns a valid entries count', function () {
    $mock = Mockery::mock(PublicApiService::class);
    $mock->shouldReceive('getEntriesCount')->andReturn(123);

    $controller = new HomeController;
    $result = $controller->index($mock);

    expect($result)->toBe(123);
});

And run the test through php artisan test. This time the test will be succesful.

Happy testing

I hope you learned something about writing tests in Pest and seperate what you are testing.

To advance further you could also let the controller return a view that renders the count and create a feature test for that part.