TDD API for Project management app with Laravel

Getting started with TDD can be intimidating at first since there are many new terms and jargons which makes difficult to even get started for a new commerce. In this post let’s take a very practical approach by building a Project Management app API with TDD on Laravel.

You might ask what is TDD? TDD stands for Test Driven Development, the idea is to write the first test then make it pass. You probably already doing it, for example, you make any changes in code and open browser to make sure it works, in TDD these manual checks are performed using our tests, so it’s only one time you write test and for the lifetime of your project you don’t have to open browser again to confirm the functionalities are working correctly.

Benefits of test-driven development (TDD)

There are plenty of benefits of TDD but the best one is that It gives you the confidence to do refactoring and add new features to your app. You can get away with without test for small projects. But for any medium to large project TDD give your peace of mind and it will force you to become a better developer in long run.

What will we be creating?

We will be creating a Project management app which enables users to create projects, invite users to join a project for collaboration, add tasks in a project, comment on task etc. At the end of this series, we will have a usable app to manage your project.

In this post, we will be TDD REST API for our application.

Create laravel app

Create a new laravel app, I am going to use Laravel 5.6 and calling this app.

laravel new app-name

Setup testing

Laravel comes pre-setup with testing, so we can get going with just some minor configuration. Open the phpunit.xml in the root of your project and add the following:

<php>
   <env name="APP_ENV" value="testing"/>
   <env name="BCRYPT_ROUNDS" value="4"/>
   <env name="CACHE_DRIVER" value="array"/>
   <env name="SESSION_DRIVER" value="array"/>
   <env name="QUEUE_DRIVER" value="sync"/>
   <env name="MAIL_DRIVER" value="array"/>
   <env name="DB_CONNECTION" value="sqlite"/>
   <env name="DB_DATABASE" value=":memory:"/>
</php>

We are setting test environment database to SQLite and database name :memory: which tell SQLite to use an in-memory database instead of looking for a file on disk.

TDD Project listings

Now configuration out of the way let’s start writing our first test. This is where most of the beginner get stuck that what should I write as first test. In this app we need some a way to create projects, that’s the meat of this app. Let’s start by creating the first test which lists all the projects owned by a user.

Now in order to create a test, you can use artisan:

php artisan make:test ProjectTest

It will create a test stub in tests/Feature folder. Open the ProjectTest.php and write the first test as follows:

/**
    * it can list project owned by user
    *
    * @test
    */
    function it_can_list_project_owned_by_user()
    {
        $owner = factory(User::class)->create();
        $project = $owner->projects()->create(
            factory(Project::class)->raw()
        );

        $response = $this->be($owner, 'api')
            ->getJson('/api/projects');

        $response->assertStatus(200)
            ->assertJson([
                'data' => [
                    $project->toArray()
                ]
            ]);
    }

You can use prefix test_it_can_list_project_owned_by_user name or use annotation @test  in doc blocks like I did to make your test class methods as tests. Any other method in test class will be ignored by PHPUnit.

Let’s dive deep into our first test, whats happening here.

All the test will follow this pattern, Setup, Act and Assert.

In Setup part we set the initial world required to run this test, in this case, we need an owner user with at least one project to list.

$owner = factory(User::class)->create();
$project = $owner->projects()->create(
     factory(Project::class)->raw()
);

Next Step is to Act, in this part, we are making a getJson request to our API endpoint which will return a list of projects owned by the owner since this will be protected route we are passing the logged in user in $this->be($owner, 'api').

$response = $this->be($owner, 'api')
            ->getJson('/api/projects');

And in last Assert part we performing our assertions, like we got the expected status code and JSON back from a request.

$response->assertStatus(200)->assertJson([
            'data' => [
                $project->toArray()
            ]
       ]);

Now if you run this test by vendor/bin/phpunit of course it will give an error.

Since we will be running test a lot you should create alias alias pt="vendor/bin/phpunit" so you can run the test quickly.

The first error you got is SQLSTATE[HY000]: General error: 1 no such table: users

We have the migration but our test is not running it before running tests. Laravel provides RefreshDatabase trait which we can use in our test class to migrate the database before and after each test.

namespace Tests\Feature;

use App\User;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class ProjectTest extends TestCase
{
    use RefreshDatabase;
    // ...
}

Now run the test again this time we get BadMethodCallException: Method Illuminate\Database\Query\Builder::projects does not exist.

You might have guessed why is so. Yes, we need the has many projects relation on the User model. Let’s create this relation and Project model with its migration and factory by running php artisan make:model Project -mf.

class User extends Authenticatable
{
    ...

    public function projects()
    {
        return $this->hasMany(Project::class);
    }
}

Now let’s run our test again. this time its giving Unable to locate factory with name [default] [Tests\Feature\Project].

Its because we haven’t imported App\Project model, import it and next error we get General error: 1 table projects has no column named user_id.

Go ahead and add the columns in project migration and its factory.

// database/migrations/create_projects_table.php

public function up()
    {
        Schema::create('projects', function (Blueprint $table) {
            $table->increments('id');
            $table->string('name', 60)->index();
            $table->string('description');
            $table->text('body')->nullable();
            $table->string('color', 10)->nullable();
            $table->dateTime('archived_at')->nullable();
            $table->unsignedInteger('owner_id');
            $table->foreign('owner_id')->references('id')->on('users');
            $table->timestamps();
        });
    }

// in App/User.php change the fk to owner_id
public function projects()
{
    return $this->hasMany(Project::class, 'owner_id');
}

Also completed the factory for Project model.

// database/factories/ProjectFactory.php
use Faker\Generator as Faker;

$factory->define(App\Project::class, function (Faker $faker) {
    return [
        'name' => $faker->sentence(2),
        'description' => $faker->sentence(20),
        'body' => $faker->realText(),
        'color' => $faker->hexColor,
        'archived_at' => null,
        'owner_id' => function() {
            return factory(\App\User::class)->create()->id;
        }
    ];
});

Now try to run the test again. This time its giving Add [name] to fillable property to allow mass assignment on [App\Project]. So let’s fix it by setting protected $guarded = []; on Project model.

When you run again you see we are now passed the setup part of the test, now the test is failing since it can’t find the route.

1) Tests\Feature\ProjectTest::it_can_list_project_owned_by_user
Expected status code 200 but received 404.
Failed asserting that false is true.

Next step is to create an API endpoint for it. Open the routes/api.php and add the following route:

// Protected
Route::group(['middleware' => ['auth:api'], 'namespace' => 'API'], function () {
    Route::apiResource('projects', 'ProjectController');
});

Again run the test but now you get Expected status code 200 but received 500. That’s not the very helpful message here. Luckily there is a helper method which you can call before making a request in the test to turn off laravel default error handling which is turning the exception into this 500 status code error. You will be needing this quite a lot in tests if some error is not very clear. Modify your test to add this.

$this->withoutExceptionHandling();
$response = $this->be($owner, 'api')
     ->getJson('/api/projects');

Now if you run you will get full exception ReflectionException: Class App\Http\Controllers\API\ProjectController does not exist with stack trace, it turns out we now need our API resource controller for the project. Create it by running artisan command.

php artisan make:controller API/ProjectController --api

This will create our controller as Rest API controller stub. Now run the test again. It says Invalid JSON was returned from the route. That means we have successfully hit the controller index method, since it’s not returning anything it’s our assertion fails for JSON. Let’s implement our index method.

class ProjectController extends Controller
{
    public function index()
    {
        return auth()->user()->projects()->paginate();
    }
}

Now run the test. Hurrah! 🎉🎊Your first tests passed.

phpunit test passed

It might feel lots of work at first but now you have a test to back up that visiting api/projects you will get the list of projects as JSON.

Next part is to refactor our code. Let’s separate our auth()->user() in a BaseAPIController so we can use it by extending this controller. Later will be needed to add some more logic in this so its good idea to refactor it now.

Create a BaseAPIController in App\Http\Controllers\API namespace.

namespace App\Http\Controllers\API;

use App\Http\Controllers\Controller;

class BaseAPIController extends Controller
{
    public function me()
    {
        return auth()->user();
    }
}

Now update our Project controller to use it.

class ProjectController extends BaseAPIController
{
    
    public function index()
    {
        return $this->me()->projects()->paginate();
    }
}

Now run the test again it should be green. If it is? then you have successfully refactored your code.

It all comes under a TDD loop, your complete app will be build by following these rules of Test Driven Development.

TDD diagram

We started our test with a failing test, then we got to pass and last we refactored our code. Now let’s see how a test will look like for creating a project.

TDD create new Project

function user_can_create_a_new_project()
{
    $owner = factory(User::class)->create();
    $payload = factory(Project::class)->raw(['name' => 'Big App', 'owner_id' => 1]);

    $this->be($owner, 'api')
        ->postJson('/api/projects', $payload)
        ->assertStatus(201);

    $this->assertDatabaseHas('projects', $payload);
    $this->assertCount(1, $owner->projects);
}

This one also has same Setup, Act and Assert structure with an assertDatabaseHas assertion which checks if the database has passed row same as $payload array. Lets implement store method on ProjectController it will be something like this.

public function store(Request $request)
{
    $project = $this->me()->projects()->create($request->all());

    return response()->json($project->toArray(), 201);
}

With this our test passed. But if you see your Test, they both has $owner created first. If you find yourself declaring some variable again n again in your test, its best to move them as a class field. Then provides setUp() method which is called before running all tests on file. We can use this to initialize member variable.

Refactoring PHPUnit Test

class ProjectTest extends TestCase
{
    use RefreshDatabase;

    protected $owner;

    protected function setUp()
    {
        parent::setUp();
        $this->owner = factory(User::class)->create();
    }

    ...
}

Now update all the reference of $owner variable to $this->owner in your test and run it again, it should be passing.

/**
 * it can list project owned by user
 *
 * @test
 */
function it_can_list_project_owned_by_user()
{
    $project = $this->owner->projects()->create(
        factory(Project::class)->raw()
    );

    $response = $this->be($this->owner, 'api')
        ->getJson('/api/projects');

    $response->assertStatus(200)
        ->assertJson([
            'data' => [
                $project->toArray()
            ]
        ]);
}

/**
 * user can create a new project
 *
 * @test
 */
function user_can_create_a_new_project()
{
    $payload = factory(Project::class)->raw([
        'name' => 'Big App',
        'owner_id' => $this->owner->id
    ]);

    $this->be($this->owner, 'api')
        ->postJson('/api/projects', $payload)
        ->assertStatus(201);

    $this->assertDatabaseHas('projects', $payload);
    $this->assertCount(1, $this->owner->projects);
}

TDD Request Validation

Currently, a project can be created with an empty array as payload, but we have to make sure that a project name and description has been sent in post request otherwise it will return 422 with an error. Lets TDD it.

function a_project_must_have_name_and_description()
{
    $payload = ['body' => 'without name and description'];

    $this->be($this->owner, 'api')
        ->postJson('/api/projects', $payload)
        ->assertStatus(422)
        ->assertSee('The name field is required')
        ->assertSee('The description field is required');

    $this->assertDatabaseMissing('projects', $payload);
    $this->assertCount(0, $this->owner->projects);
}

Now let’s update our store() method to use a Request class for validation. Run php artisan make:request StoreProjectRequest and add following code in it.

// Http/Requests/StoreProjectRequest.php
class StoreProjectRequest extends FormRequest
{
	public function authorize()
	{
	    return auth()->check();
	}

	public function rules()
	{
	    return [
	        'name' => 'required|max:60',
	        'description' => 'required|max:200',
	    ];
	}
}

// Http/Controllers/API/ProjectController.php
public function store(StoreProjectRequest $request)
{
    $project = $this->me()->projects()->create($request->all());
    return response()->json($project->toArray(), 201);
}

Now your test should be passing. Lets quickly add another test to complete testing of Project resource.

TDD view Project details

Project details should contain the project details and all the task in it. You will find most of the time one test depends on other Model of your app, key it to just create the model and move on without distracting yourself and focusing on Project test in this case.

So lets first create our test then we will make add bare bone Task model so our test can pass.

/**
    * it can view a single project with tasks in it
    *
    * @test
    */
    function it_can_view_a_single_project_with_tasks_in_it()
    {
        $project = $this->owner->projects()->create(
            factory(Project::class)->raw()
        );

        $project->addTask([
            'title' => 'Need to prepay hosting',
            'body' => 'something more info can go here about this task',
            'priority' => 1,
            'completed_at' => null,
            'user_id' => $this->owner->id
        ]);

        $this->be($this->owner, 'api')
            ->getJson('/api/projects/'.$project->id)
            ->assertStatus(200)
            ->assertJson($project->load('tasks')->toArray());
    }

If you look closely you see $project->addTask() method which adds a task in the project. I add it intently to show you how we can write a Unit test for this type methods.

TDD Unit level

Create a new Test, this time it will be the Unit test. Run php artisan make:test ProjectTest --unit. You should now get a new test in Unit Folder as  tests/Unit/ProjectTest.php open and add the following test.

class ProjectTest extends TestCase
{
    use RefreshDatabase;

    /**
    * task can be added to project
    *
    * @test
    */
    function task_can_be_added_to_project()
    {
        $project = factory(Project::class)->create();
        $this->assertCount(0, $project->tasks);

        $project->addTask([
            'title' => 'Hello task',
            'body' => 'description is going here',
            'user_id' => 1
        ]);

        $this->assertCount(1, $project->fresh()->tasks);
    }
}

Update Project model to add this addTask() method.

class Project extends Model
{
    protected $guarded = [];

    public function tasks()
    {
        return $this->hasMany(Task::class)
            ->orderBy('order');
    }

    public function addTask($task)
    {
        // add user id if not present
        if( array_has($task, 'user_id') == false && auth()->check()) {
            $task['user_id'] = auth()->id();
        }

        return $this->tasks()->create($task);
    }
}

This tasks() relation needs Task model, let’s create it with migration and factory by running php artisan make:model Task -mf.

You can run only unit test by using phpunit --testsuite Unit. You can also use filter to ignore any test or Class which doesn’t contain some pattern text by phpunit --filter {TestMethodName} {FilePath}.

Run the test and it should give you General error: 1 no such column: tasks.project_id this means you need to add fields in our Task migration.

public function up()
{
    Schema::create('tasks', function (Blueprint $table) {
        $table->increments('id');
        $table->string('title');
        $table->text('body')->nullable();
        $table->tinyInteger('priority')->default(0);
        $table->dateTime('completed_at')->nullable();
        $table->dateTime('due_at')->nullable();
        $table->integer('order')->default(0);

        $table->unsignedInteger('project_id');
        $table->foreign('project_id')
            ->references('id')->on('projects')
            ->onDelete('cascade');

        $table->unsignedInteger('user_id');
        $table->foreign('user_id')
            ->references('id')
            ->on('users')
            ->onDelete('cascade');

        $table->timestamps();
    });
}

If you now try to run the test you will get MassAssignmentException. You can fix it by adding protected $guarded = []; in Task model. That should make tests pass.

Now lets implement project controllers show method to show the details of a project by id with all the tasks it has.

public function show($id)
{
    return $this->me()->projects()
        ->with('tasks')
        ->findOrFail($id);
}

Thats it, it will pass your test.

TDD Update a Project

Let’s now write our test to update a project. By now you should start seeing the TDD loop with setup, act and assert structure.

/**
* owner can update project
*
* @test
*/
function owner_can_update_project()
{
    $project = $this->owner->projects()->create(
        factory(Project::class)->raw([
            'body' => 'body is cool',
            'name' => 'Project title'
        ])
    );

    $payload = [
        'body' => 'updated body with new option',
        'name' => 'Updated Project title'
    ];

    $this->be($this->owner, 'api')
        ->putJson('/api/projects/'.$project->id, $payload)
        ->assertStatus(200)
        ->assertJson($payload);

    $this->assertEquals($payload['name'], $project->fresh()->name);
    $this->assertEquals($payload['body'], $project->fresh()->body);
}

In this case we create a project with body and name filed, then we are making put request to API with new name and body, lastly, we are asserting that indeed our project changed.

Make sure to call $project->fresh() on model so you get the updated attributes from db, by default $project will be not refreshed with changes you made.

Write the update method to make the test pass.

// app/Http/Controllers/API/ProjectController.php
public function update(UpdateProjectRequest $request, $id)
{
    $project = $this->me()->projects()->findOrFail($id);
    $project->update($request->except('archived_at'));

    return $project;
}

// app/Http/Requests/UpdateProjectRequest.php
...
public function rules()
{
    return [
        'name' => 'max:60',
        'description' => 'max:200',
    ];
}

TDD delete a Project

Deleting something is always easier than creating a new one. Let’s write our test to delete a project, it’s very similar to update project, we are just going to change our put to delete request and in assert section, we will check the DB that project is deleted.

/**
* owner can delete a project with tasks in it
*
* @test
*/
function owner_can_delete_a_project_with_tasks_in_it()
{
    $project = $this->owner->projects()->create(
        factory(Project::class)->raw()
    );

    $taskPayload = [
        'title' => 'Need to prepay hosting',
        'body' => 'something more info can go here about this task',
        'priority' => 1,
        'completed_at' => null,
        'user_id' => $this->owner->id
    ];

    $project->addTask($taskPayload);

    $this->be($this->owner, 'api')
        ->deleteJson('/api/projects/'.$project->id)
        ->assertStatus(204);

    $this->assertDatabaseMissing('projects', $project->toArray());
    $this->assertDatabaseMissing('tasks', $taskPayload);
}

Now let’s write the implementation right away. Open the project controller and add following in destroy method.

public function destroy($id)
{
    $this->me()->projects()->delete($id);
    return response('', 204);
}

Run the test and you will see that our project has been deleted but its related task is not deleted. If you check the task migration schema we have our foreign key constraint setup to cascade on delete.

1) Tests\Feature\ProjectTest::owner_can_delete_a_project_with_tasks_in_it
Failed asserting that a row in the table [tasks] does not match the attributes {
    "title": "Need to prepay hosting",
    "body": "something more info can go here about this not task",
    "priority": 1,
    "completed_at": null,
    "user_id": 1
}.

It turns out that foreign key support for SQLite databases is not enabled by default. We can turn this on by adding this conditional DB statement. The best place to do it in our base tests/TestCase.php setUp() method.

use Illuminate\Support\Facades\DB;
use Illuminate\Database\SQLiteConnection;

abstract class TestCase extends BaseTestCase
{
    use CreatesApplication;

    protected function setUp()
    {
        parent::setUp();

        // Enable foreign key support for SQLITE databases
        if (DB::connection() instanceof SQLiteConnection) {
            DB::statement(DB::raw('PRAGMA foreign_keys=on'));
        }
    }
}

Now run test back, with any luck your test should be green.

Wrapping up

Covering complete API development with TDD in one post is not possible, it can take up to 4-5 posts for this project. I am not stopping here but It will be very similar to develop all other Comment, Task, Invite functionality for this API.  I will complete all endpoint and as always you can find the complete source code on Github once the app is completed. And very soon I will be showing how to create the front end for this Project Management App using React. Follow me on twitter to get notified as next post is published. Let me know in the comments if you have any questions.Demo