< BACK TO BLOG

Test driven development (TDD) in Laravel

Published November 16, 2021

What is TDD

Test Driven Development or TDD is a software development process, instead of immediately starting development of a feature, in TDD, you would convert your requirements to tests cases and use these test cases as you develop.

Why should I use it?

I would say, the 2 major benefits of TDD are:

  1. Creating test cases first makes the requirements concrete, and gives you a guideline in your implementation
  2. Creating test cases first changes your perspective, while writing tests for non-existent items, you'll be imagining how to consume them

Keep in mind that you would do good to write tests anyway, TDD is just about when to do it in the development process.

How to do it

Generally, TDD is often described in a cycle of Red, Green, Refactor Let's look at this cycle closely

  • Red: creating tests for non-existent or non-functioning items, then running them which will fail, and thus the color red
  • Green: developing the minimum code for a feature, and running the tests and as a result they will pass, and then we get the color green
  • Refactor: refactoring your code, making it more readible, spreading the functionality to classes, traits etc, and running the tests again

At the surface, it's not such a major difference, but in my experience dev teams either forego tests all together or write them in the end as an after thought, I can't say that that is objectively wrong, however in my experience writing tests in the beggining brings all the focus to the functionality and architecture.

Laravel

Laravel comes pre-configured with PHPUNIT, PHPUNIT is a unit testing framework for PHP, and one can say the most popular, there are definitely other frameworks like Pest Laravel also adds a lot of laravel specific utilities to PHPUNIT, so you'll find a lot of useful functions to write tests, $this->actingAs($user) is a great example of this. All you need to do before getting your hands dirty is to configure the database for testing, you can either create a separate database for testing, or use an sqlite in-memory database

We'll be using the sqlite option, it's quicker, but you can definitely configure a separate database, check this.

In the phpunit.xml file, you'll find the following chunk

<php>
    <server name="APP_ENV" value="testing"/>
    <server name="BCRYPT_ROUNDS" value="4"/>
    <server name="CACHE_DRIVER" value="array"/>
    <!-- <server name="DB_CONNECTION" value="sqlite"/> -->
    <!-- <server name="DB_DATABASE" value=":memory:"/> -->
    <server name="MAIL_MAILER" value="array"/>
    <server name="QUEUE_CONNECTION" value="sync"/>
    <server name="SESSION_DRIVER" value="array"/>
    <server name="TELESCOPE_ENABLED" value="false"/>
</php>

Uncomment the commented tags to enable sqlite, if you opted for a separate database you'd fill these fields with your new testing database connection.

TDD in action

I want to do something different than the tried and true posts example, I want to create a more realistic scenario, so let's assume we have a running sporting events platform and want to create an API that receives applications for new sporting events from another platform, it goes without saying that the parameters in the other platforms are different than ours, so the challenge is to map these values to our platform.

And we have a table already for that called sporting_events that looks like this

Schema::create('sporting_events', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->unsignedInteger('max_people');
    $table->dateTime('start_date');
    $table->dateTime('end_date');
    $table->timestamps();
});

But the platform we're dealing with sends this data

{
    "event": "Football match #12",
    "max_head_count": 22,
    "on": "2010-10-10 16:00:00",
    "duration": 7200, # in seconds
}

Let's begin by thinking about this, should we accept max_head_count values that are 0 or less? should we accept event values that are empty? Putting our thoughts together, I think we should create a mapper class, it's a separate class that our controller can consume, and our controller should validate the data before calling the mapper Let's write the test php artisan make:test PlatformSportingEventsTest, and we'll get this file

<?php

namespace Tests\Feature;

use Tests\TestCase;

class PlatformSportingEventsTest extends TestCase
{
    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function test_example()
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }
}

Let's write some test cases, I want to test the happy scenario and data validation, here are some sample tests

<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\DB;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Tests\TestCase;

class PlatformSportingEventsTest extends TestCase
{
    use DatabaseMigrations;

    /** @test */
    public function it_fails_if_event_is_empty()
    {
        $this->postJson('/api/sporting-events', [
            'event' => null,
        ])
        ->assertStatus(422)
        ->assertJsonValidationErrors(['event']);
    }

    /** @test */
    public function it_fails_if_max_head_count_is_0_or_less()
    {
        $this->postJson('/api/sporting-events', [
            'max_head_count' => -1,
        ])
        ->assertStatus(422)
        ->assertJsonValidationErrors(['max_head_count']);
    }

    /** @test */
    public function it_fails_if_duration_is_0_or_less()
    {
        $this->postJson('/api/sporting-events', [
            'duration' => -1,
        ])
        ->assertStatus(422)
        ->assertJsonValidationErrors(['duration']);
    }

    /** @test */
    public function it_can_insert_sporting_events()
    {
        $this->postJson('/api/sporting-events', [
            'event' => 'event 1',
            'on' => now()->addDay()->format('Y-m-d H:i:s'),
            'duration' => 3600,
            'max_head_count' => 10,
        ])
        ->assertCreated();

        $tomorrow = now()->addDay();
        $tomorrowOneHourAhead = (clone $tomorrow)->addSeconds(3600);
        $this->assertEquals(1, DB::table('sporting_events')->count());
        $sportingEvent = DB::table('sporting_events')->first();
        $this->assertEquals($tomorrow->format('Y-m-d H:i:s'), $sportingEvent->start_date);
        $this->assertEquals($tomorrowOneHourAhead->format('Y-m-d H:i:s'), $sportingEvent->end_date);
        $this->assertEquals('event 1', $sportingEvent->name);
        $this->assertEquals(10, $sportingEvent->max_people);
    }
}

We run these tests and obviously they will fail, so now let's create a minimal implementation, php artisan make:controller SportingEventsController, and implement a store() method

public function store(Request $request)
{
    $request->validate([
        'event' => ['required'],
        'max_head_count' => ['required', 'numeric', 'gt:0'],
        'on' => ['required'],
        'duration' => ['required', 'numeric', 'gt:0']
    ]);

    $on = Carbon::parse($request->on);
    $sportingData = [
        'name' => $request->event,
        'max_people' => $request->max_head_count,
        'start_date' => $on->format('Y-m-d H:i:s'),
        'end_date' => $on->addSeconds($request->duration)->format('Y-m-d H:i:s'),
    ];

    DB::table('sporting_events')->insert($sportingData);

    return response([
        'message' => 'success'
    ], 201);
}

And in api.php we add this Route::resource('sporting-events', SportingEventsController::class)->only(['store']); and when we run the tests, we get green!

 PASS  Tests\Feature\PlatformSportingEventsTest
  ✓ it fails if event is empty
  ✓ it fails if max head count is 0 or less
  ✓ it fails if duration is 0 or less
  ✓ it can insert sporting events

  Tests:  6 passed
  Time:   0.16s

And now, we can create our mapper class and refactor the code

<?php

namespace App\Mappers;

use Carbon\Carbon;

class SportingEventMapper
{
    protected array $data;
    public function __construct($data)
    {
        $this->data = $data;
    }

    public function map()
    {
        return [
            'name' => $this->resolveName(),
            'max_people' => $this->resolveMaxPeople(),
            'start_date' => $this->resolveStartDate(),
            'end_date' => $this->resolveEndDate(),
        ];
    }

    protected function resolveName()
    {
        return $this->data['event'];
    }

    protected function resolveMaxPeople()
    {
        return $this->data['max_head_count'];
    }

    protected function resolveStartDate()
    {
        $on = Carbon::parse($this->data['on']);
        return $on->format('Y-m-d H:i:s');
    }

    protected function resolveEndDate()
    {
        $on = Carbon::parse($this->data['on']);
        return $on->addSeconds($this->data['duration'])->format('Y-m-d H:i:s');
    }
}

and our controller

<?php

namespace App\Http\Controllers;

use App\Mappers\SportingEventMapper;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class SportingEventsController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'event' => ['required'],
            'max_head_count' => ['required', 'numeric', 'gt:0'],
            'on' => ['required'],
            'duration' => ['required', 'numeric', 'gt:0']
        ]);

        $mapper = new SportingEventMapper($request->all());

        DB::table('sporting_events')->insert($mapper->map());

        return response([
            'message' => 'success'
        ], 201);
    }
}

And we run the tests again and voila, green again, we can take the next step to create a FormRequest class to keep our controller clean and minimal and keep the cycle going until we're done.