DEV Community

MacDonald Chika
MacDonald Chika

Posted on

Test-Driven Development (TDD) in Laravel 8

Before we delve into Test-Driven Development, let's first define the term TDD.

**

What is Test-Driven Development(TDD)?

**
According to Wikipedia, "Test-Driven Development is a software development process relying on software requirements being converted to test cases before the software is fully developed, and tracking all software development by repeatedly testing the software against all test cases".

It is a software development approach that emphasizes writing bug-free codes by developing test cases to specify and validate what the code will do.

Here is what Test-Driven Development (TDD) cycle looks like;

  1. Write a test
  2. Run the test -- which will fail
  3. Write some code
  4. Run the test again
  5. Make changes to code to make the test pass(refactor)
  6. Repeat

How To Write Tests in Laravel

For the purpose of this article, we will create a simple CRUD API to create, read , update and delete blog posts. Let's begin by creating a fresh Laravel project by using Composer.

Step 1: Create and initialize the project
composer create-project laravel/laravel blog

cd blog

You can run the command below to be sure that everything works fine
php artisan serve

Step 2: Set up your test suite
Update your phpunit.xml file in your root directory. Uncomment these lines of codes.

<server name="DB_CONNECTION" value="sqlite"/>
<server name="DB_DATABASE" value=":memory:"/>

The updated file should look like this:

<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"     xsi:noNamespaceSchemaLocation="./vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true">
    <testsuites>
        <testsuite name="Unit">
            <directory suffix="Test.php">./tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory suffix="Test.php">./tests/Feature</directory>
        </testsuite>
    </testsuites>
    <coverage processUncoveredFiles="true">
        <include>
            <directory suffix=".php">./app</directory>
        </include>
    </coverage>
    <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>
</phpunit>
Enter fullscreen mode Exit fullscreen mode

We uncommented or added those lines of code to ensure that PHPUnit uses :memory: database so that tests run faster. Now that our test suite is set up, let’s set up our base test file TestCase.php

<?php

namespace Tests;

use Faker\Factory as Faker;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\Artisan;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication, RefreshDatabase;

    protected $faker;

    /**
     * Sets up the tests
     */
    public function setUp(): void
    {
        parent::setUp();

        $this->faker = Faker::create();

        Artisan::call('migrate'); // runs the migration
    }


    /**
     * Rolls back migrations
     */
    public function tearDown(): void
    {
        Artisan::call('migrate:rollback');

        parent::tearDown();
    }
}
Enter fullscreen mode Exit fullscreen mode

You would notice that we use the RefreshDatabase trait because it is often useful to reset our database after each test so that data from a previous test does not interfere with subsequent tests. You may also notice that we added two methods setUp() and tearDown(). The former is run by the test runner prior to each test and the latter after each test. They help keep your test code clean and flexible.

Step 3: Create and write your test
To create a test file in laravel 8, simple run this artisan command

php artisan make:test PostTest

The command above creates a PostTest.php file in tests/Feature directory.

The next thing we need to do is write our actual test.

Note: You will need to either prefix your methods name with test i.e testCanCreateAPost or use the /** @test / annotation. That way **PhpUnit* knows that it needs to run your test.

<?php

namespace Tests\Feature;

use Tests\TestCase;

class PostTest extends TestCase
{
    /** @test*/
    public function canCreateAPost()
    {
        $data = [
            'title' => $this->faker->sentence,
            'description' => $this->faker->paragraph
        ];

        $response = $this->json('POST', '/api/v1/posts', $data);

        $response->assertStatus(201)
             ->assertJson(compact('data'));

        $this->assertDatabaseHas('posts', [
          'title' => $data['title'],
          'description' => $data['description']
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Run your test
Now we need to run our test with phpunit as thus:

./vendor/bin/phpunit

or laravel artisan command which is pretty cool as it provides verbose test reports in order to ease development and debugging:

php artisan test

TDD in Laravel 8

Oops! Our test FAILED! is this good? Yes, it is in fact fine for our test to fail as it follows the second rule of TDD that it should Fail after creating the test.

So why did our test fail?

The error says the expected response code is 201 — as we have asserted in our test — but received** 404 (Not found). This means that the endpoint [POST]‘/api/v1/posts**’ does not exist and we need to create one. This brings us to the next step.

Step 5: Make Test Pass
What we will be doing in this step is to make our test pass. First to get rid of the 404 error, let create an endpoint.

CREATE THE ENDPOINT IN YOUR ROUTE FILE
let’s go to our route file located at ‘routes/api.php’ and create that endpoint. All routes created in this file are automatically prefixed with ‘/api’.

php artisan make:controller --resource

You can either run this command to create a RESTful controller in the /app/Http/Controllers/Api directory or you can create it manually.

Now let’s debug our controller and validate our request

In the store method, where the POST request goes, return a response as thus:

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class PostController extends Controller
{

    public function index()
    {
        //
    }

    public function create()
    {
        //
    }

    public function store(Request $request)
    {
        return response()
            ->json([
                'message' => 'Post created'
            ]);
    }


    public function show($id)
    {
        //
    }


    public function edit($id)
    {
        //
    }


    public function update(Request $request, $id)
    {
       //
    }

    public function destroy($id)
    {
        //
    }
}
Enter fullscreen mode Exit fullscreen mode

We will create a request file containing some validation rules to validate the data going into our database.

php artisan make:request CreatePostRequest

Remember to change false to true in the authorize() method

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreatePostRequest extends FormRequest
{
    /**
     * Determine if the user is authorized to make this request.
     *
     * @return bool
     */
    public function authorize()
    {
        return true;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array
     */
    public function rules()
    {
        return [
            'title' => ['required'],
            'description' => ['required']
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

We are going to import and pass our CreatePostRequest to the store method of our controller. The store() method in our controller should now look like this:

public function store(CreatePostRequest $request)
{
    return response()
        ->json([
            'message' => 'Post created'
        ]);
}
Enter fullscreen mode Exit fullscreen mode

Note: Remember to import the CreatePostRequest class.

CREATE YOUR MODEL AND MIGRATIONphp artisan make:model Post -m

php artisan make:model Post -m

This artisan command creates our model and migration. The -m flag creates a migration file under database/migrations directory. We will add title and description columns in our migration as thus:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('description');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}
Enter fullscreen mode Exit fullscreen mode

And in our Model, we have to define the hidden and fillable fields.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;


class Post extends Model
{
    use HasFactory;

    protected $fillable = [
        'title',
        'description'
    ];
}
Enter fullscreen mode Exit fullscreen mode

Once that is done, we will edit our store method as thus:

public function store(CreatePostRequest $request)
{
    $data = $request->validated();  // only validated request

    $post = Post::create($data);   // creates and return post

    return response()
      ->json([ 
        'message' => 'Post created'
      ]);
}
Enter fullscreen mode Exit fullscreen mode

Note: remember to import the Post class in your controller

RE-RUN TESTS

php artisan test

Failed asserting 201 is identical to 200 - Test-Driven development in Laravel 8

Our test Failed again but this time it says that it can’t assert that 201 is identical to 200. This means that our endpoint returns a 200 response code instead of a 201. So let’s add a 201 response code — which means that the resource was successfully created.

public function store(CreatePostRequest $request)
{
    $data = $request->validated();  // only validated request

    $post = Post::create($data);   // creates and return post

    return response()
        ->json([ 
            'message' => 'Post created'
        ], 201);
}
Enter fullscreen mode Exit fullscreen mode

let's re-run our test again.

Note: if you get a 500 response code, you can take a look at the log file at /storage/logs/laravel.log.

Unable to find Json - Test-Driven development in laravel 8

Now we get another error. It can’t assert that a certain JSON exists, which was defined in our test here

$data = [
    'title' => $this->faker->sentence,
    'description' => $this->faker->paragraph
];
$response = $this->json('POST', '/api/v1/posts', $data);
$response->assertStatus(201)
      ->dump() // use this to know what data is being returned 
     ->assertJson(compact('data')); // this line here
Enter fullscreen mode Exit fullscreen mode

To solve this, we need to return the created resource to assert that it matches the data that is being sent to the endpoint. So, within our store() method will add a ‘data’ object that returns the newly created resource to our payload.

public function store(CreatePostRequest $request)
{
    $data = $request->validated();  // only validated request

    $post = Post::create($data);   // creates and return post

    return response()
        ->json([
            'data' =>  $post,
            'message' => 'Post created'
        ], 201);
}
Enter fullscreen mode Exit fullscreen mode

Once that is done, we will re-run our test.

Test passed - Test-driven development in laravel 8

WELL DONE! You have made the test pass.

Top comments (0)