Building an API using TDD in Laravel
Hi Artisans, my name is Alberto Rosas, I've been enjoying Laravel for many years and one of the most useful and rewarding things I've learned is how to build proper test suites for my applications. It is awesome to see Testing being practiced more often among the Laravel community so in this blog post we'll start with the Basics on TDD in Laravel and continue with advance topics in other articles.
This is what we'll cover:
- Create an API from scratch focusing on basic CRUD features
- Implement TDD from the start to help illustrate how to build testable Laravel applications.
Introduction
The purpose of this article is to show that TDD doesn't have to be hard or be a pain to include it as part of your workflow or your team's, you just need to understand where to start and what to test.
It didn't take me much time to adopt TDD as a habit and find the right workflow for me. Having a standard workflow is important; knowing what you'll do first in a typical project can help you create your own structure, organize things in a way that you know where everything lives in your app and standarize your personal/client projects a bit.
In other words, TDD helps you code faster and with confidence. I mentioned earlier that you need to understand what to test first and the reality is that it doesn't matter, tests we'll guide you towards the next so the only thing you need to truly undestand is the feature you want to test.
Requirements
- Basic knowledge of the framework
- Fresh Laravel project
Anyways, let's just start.
Context
Throughout this article and probably a series of articles I'll create an API for a Real estate app.
properties
table:
- id: Primary Key
- type: string (we'll probably create a relationship with 'property_types' later.)
- price: unsigned integer
- description: text
When working with Laravel we usually follow a standard/convention which is to organize Controller actions in the 5 API methods:
- index
- store
- update
- delete
We'll work our way one by one and explain what are the things we're most interested in testing.
Testing Index
Index method is usually used to return a specific Collection for a Model.
We will test:
- We have a named API endpoint for retrieving a collection of resources.
- Test that the response comes in the form of a collection.
Let's start by creating a test, open a terminal inside your Laravel project and run:
php artisan make:test Api/PropertiesTest
This will create a test file in /tests/Features/Api/PropertiesTest.php
Inside this file we'll add our first test which will be testing that we hit the index
API route and get back a collection of Properties which at this point we don't have but I'll let the test take the wheel.
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties()
{
// Create Property so that the response returns it.
$property = Property::factory()->create();
$response = $this->getJson(route('api.properties.index'));
// We will only assert that the response returns a 200 status for now.
$response->assertOk();
}
}
Of course there will be an error here, we haven't created the Property
model yet so after running ./vendor/bin/phpunit --testdox
inside our project's directory we get:
Tests\Feature\Api\PropertiesTest::can_get_all_properties
Error: Class 'Tests\Feature\Api\Property' not found
Let's do that:
php artisan make:model Property -mf
The above command will:
- Create a model located in
app/Models/Property.php
-
-m
creates the Migration in/database/migrations/
-
-f
creates a Factory class in/database/factories/PropertyFactory
that we'll used for mocking a "Property" and its attributes.
Following the TDD approach we go step by step, after creating our model, migration and factory let's run the test again (you'll get the same error if you don't import the Property model definition on top of your test):
Tests\Feature\Api\PropertiesTest::can_get_all_properties
Symfony\Component\Routing\Exception\RouteNotFoundException:
Route [api.properties.index] not defined.
Let's go ahead and create the required endpoint in /routes/api.php
file:
use App\Http\Controllers\Api\PropertyController;
Route::get(
'properties',
[PropertyController::class, 'index']
)->name('api.properties.index');
Next we'll run it and receive an error; the PropertyController
does not exist:
ReflectionException: Class PropertyController does not exist
Let's open our terminal and create our controller via artisan:
php artisan make:controller Api/PropertyController
The controller will be located in /app/Http/Controllers/Api/PropertyController.php
.
Open the recently created file and run the test again:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
}
We receive yet another error indicating that the method index
doesn't exist, let's created it to finally achieve a passing test:
<?php
namespace App\Http\Controllers\Api;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index()
{
}
}
Run the test and it passes but...we haven't done the actual logic to test that it returns a collection, let's do that by updating our test to assert that we get a JSON.
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties()
{
// Create Property so that the response returns it.
$property = Property::factory()->create();
$response = $this->getJson(route('api.properties.index'));
// We will only assert that the response returns a 200
// status for now.
$response->assertOk();
// Add the assertion that will prove that we receive what we need
// from the response.
$response->assertJson([
'data' => [
[
'id' => $property->id,
'type' => $property->type,
'price' => $property->price,
'description' => $property->description,
]
]
]);
}
}
And of course we get:
Invalid JSON was returned from the route.
, well...we're not actually returning anything from the index
method so let's do that:
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index()
{
return response()->json([
'data' => Property::all()
]);
}
}
The reason we return a data
array and within it a collection is because of a standard in API responses where the content should be inside that data
array.
We get one more error which is that we're asserting that we get the Property's attributes returned from the response but the attributes are null
, can you imagine why?:
Unable to find JSON:
[{
"data": [
{
"id": 1,
"type": null,
"price": null,
"description": null
}
]
}]
within response JSON:
[{
"data": [
{
"id": 1,
"created_at": "2021-10-15T14:44:21.000000Z",
"updated_at": "2021-10-15T14:44:21.000000Z"
}
]
}]
You guessed it! we didn't update our PropertyFactory class to have the attributes that we planned to have:
use App\Models\Property;
use Illuminate\Database\Eloquent\Factories\Factory;
class PropertyFactory extends Factory
{
/**
* The name of the factory's corresponding model. * * @var string
*/
protected $model = Property::class;
/**
* Define the model's default state.
* @return array
*/
public function definition()
{
return [
'type' => $this->faker->word,
'price' => $this->faker->randomNumber(6),
'description' => $this->faker->paragraph,
];
}
}
We'll start getting errors related to "unknown columns...". because our migration doesn't contain the columns we're asserting we have.
Let's update the migration with the necessary columns:
Schema::create('properties', function (Blueprint $table) {
$table->id();
$table->string('type', 20);
$table->unsignedInteger('price');
$table->text('description');
$table->timestamps();
});
If we run the test again we'll notice it passes again and this time for good.
Now that we have created a tested index
method, let's continue with the Store method which requires a bit more work.-
Testing the Store method
For this test we'll start by creating our second test in the same file
tests/Feature/Api/PropertiesTest.php
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties(){...}
/** @test */
/** @test */
public function can_store_a_property()
{
// Build a non-persisted Property factory model.
$newProperty = Property::factory()->make();
$response = $this->postJson(
route('api.properties.store'),
$newProperty->toArray()
);
// We assert that we get back a status 201:
// Resource Created for now.
$response->assertCreated();
// Assert that at least one column gets returned from the response
// in the format we need .
$response->assertJson([
'data' => ['type' => $newProperty->type]
]);
// Assert the table properties contains the factory we made.
$this->assertDatabaseHas(
'properties',
$newProperty->toArray()
);
}
}
Let's recap on this test, first:
- We make a non-persisted Property model to use as the user's request using the Factory method:
make
. - We make a post request via API to the
route('api.properties.store')
route with the request data. - Then assert that we get back a response status code 201: Resource Created
- Assert we receive at least one of the new keys to validate it comes in the right format.
- And finally we assert that the table
properties
contains the new Property model.
Running the test we get an error:
Symfony\Component\Routing\Exception\RouteNotFoundException : Route [api.properties.store] not defined.
Which means exactly that; there is no API route named like the above route.
In /routes/api.php
:
Route::post(
'properties',
[PropertyController::class, 'store']
)->name('api.properties.store');
Of course we haven't created the store
method, we do that like this:
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
class PropertyController extends Controller
{
public function index() : JsonResponse {...}
public function store(Request $request)
{
return response()->json([
'data' => Property::create($request->all())
], 201);
}
}
This is actually the only thing we need to do but our next error is in regards to Mass Assignment, basically we need to create a protected $fillable = [];
property that contains the names of the columns you wish to mass assign and it looks like this:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class Property extends Model
{
use HasFactory;
protected $fillable = ['type', 'price', 'description'];
}
Great, now we got green. Now, of course we still need to validate those properties on creation so let's do this properly.
I'll begin by creating a "Unit" test (or so I call it) since I'm only interested in asserting that I receive an error as a result of "creating" or "updating" a Property and intentionally make them fail; this way you validate the FormRequest is being injected in the store
and update
methods in your controller.
Creating a FormRequest with artisan looks like this:
php artisan make:request PropertyRequest
This will create a file in /app/Http/Requests/PropertyRequest.php
, open it up and you'll get this class:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
public function authorize()
{
// Change this to: true
return false;
}
public function rules()
{
return [
//
];
}
}
In here, you'll notice a method called authorize
that returns false
, you can validate access to the method by returning true/false if a certain rule is placed; if it returns false the method in the controller will return a 403 status code: unauthorized.
Let's go ahead an create our PropertyRequestTest
to validate the rules we apply to our store
method and eventually our update
method as well.
php artisan make:test Http/Requests/PropertyRequestTest --unit
I usually place my validation tests in tests/Unit/Http/Requests/
to mimic the location of the controller where its being used. Open the new test:
<?php
namespace Tests\Unit\Http\Requests;
use Tests\TestCase;
class PropertyRequestTest extends TestCase
{
}
If you noticed, there is one detail I changed before continuing and that is the TestCase
definition imported by default. The PHPUnit\Framework\TestCase
definition comes by default in Unit tests and is the PHPUnit default class, we need to replace it for Laravel's TestCase located in the tests
directory to have Laravel assertions and helper functions.
So let's start with the first test which is making sure we enforce the required
rule:
use RefreshDatabase;
private string $routePrefix = 'api.properties.';
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$validatedField = 'type';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
Ok, so this is my way of standarizing validation tests. We create a common way to replicate rules and we just update the $brokenRule
variable to contain the value that will break the rule so we can assert the JSON validation errors contains the error this way I can just copy/paste the same test and just change the $validatedField
and $brokenRule
for the new test.
This is what's happening here:
- First we create a validated field variable so we reuse that column name and replacing it in other tests.
- We created a broken rule variable containing the value that would trigger the form request.
- Then we make a non-persisted model with the values that will break the validation.
- We make a POST request to attempt creating a new Property.
- We immediately assert that the JSON validation errors bag contains the validated field proving that there is an error therefore our Request is doing its job.
Getting an error after running the test:
Failed to find a validation error in the response for key: 'type'
Which means the test didn't find a validation error in the response of the POST request we did, obviously we haven't implemented the FormRequest in the PropertyController
, let's swap the Illuminate\Http\Request
for our PropertyRequest
so that our store
method looks like this:
public function store(PropertyRequest $request) : JsonResponse {...}
if we run the test we get the same error but that's actually not the correct one, I'll explain this in a second. Let's go to our type_is_required
test and add a method called withoutExceptionHandling
like so:
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$this->withoutExceptionHandling();
...
}
By default Laravel protects us from some exceptions by modifying the response to a "friendly" exception message, in reality our test is failing because we have implemented the PropertyRequest
which contains an authorize
method that returns false
, this method can be used to implement a validation on permissions, roles or another condition that if results in true
it let the request move on to the validation rules, if not, then it throws a 403 status code which is unauthorized
as described earlier.
To fix this, let's change false
to true
since we're not checking any kind of permission or condition here:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request. * * @return bool
*/
public function authorize()
{
return true;
}
...
After that we have to remove our method withoutExceptionHandling
so that we receive the expected exception message, then let's start adding the rules that we are testing in our FormRequest.
First validation rule is the required
and since we're testing the type
column that will be our first validation:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class PropertyRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'type' => ['required'],
];
}
}
Now if we run the test we'll get green and our test is passing validating that:
- The
store
method requires thetype
value to be present in the Request. - The FromRequest is being used in our
store
method. - We standarized our validation test a little bit so our next set of test will be easier to implement.
Now, I'll just add the other tests in a row since the implementation is very similar; after that I'll show and explain the validation Rules implemented in the FormRequest, I'll see you in a moment.
Since we specified the length of the type
column as a maximum of 20 characters, I'll go ahead and add that validation.
/**
* @test
* @throws \Throwable
*/
public function type_is_required() {...}
/**
* @test
*/
public function type_must_not_exceed_20_characters()
{
$validatedField = 'type';
$brokenRule = Str::random(21);
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
As you can see, we copy/pasted the previous test and changed the $brokenRule
value to what will make the test fail since that is what we want. $brokenRule
becomes a string of random letters which contains 21 characters triggering the validation error, since we haven't added the rule to the FormRequest it won't pass yet.
public function rules()
{
return [
'type' => ['required', 'max:20']
];
}
We add the rule max
to specify the maximum value we expect the field to have, which in this case is 20 and we get green!
On to the next test which is for price
, we'll just copy previous tests and continue:
/**
* @test
* @throws \Throwable
*/
public function price_is_required()
{
$validatedField = 'price';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
We just changed the name of the test followed by the $validatedField
value which represents the column that we're validating. As you can see validation turns into a breeze since we're just copy/pasting our previous tests.
Adding our rule for Price:
public function rules()
{
return [
'type' => ['required', 'max:20'],
'price' => ['required'],
];
}
Tests will pass since the work is already done, let's just quickly do our missing tests:
/**
* @test
* @throws \Throwable
*/
public function price_must_be_an_integer()
{
$validatedField = 'price';
$brokenRule = 'not-integer';
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
}
Validation rule:
public function rules()
{
return [
'type' => ['required', 'max:20'],
'price' => ['required', 'integer'],
];
}
And we get green for this test, we could even avoid this test by adding Attribute Casting to the price
column as integer
but I prefer to keep validations together for now.
Since description
field is a text
column type I'm not sure we require to validate this since a text
field can contain up to 65,535
bytes which is plenty to let the user type everything he/she wants.
And with that we have our store
method tested along with it's validations.
See you in our next test.
Testing the Update method
We're half way there. Now we will continue with the Update method.
As seen in the previous tests everything becomes quite simple after a while, let's start by adding our test to our PropertiesTest
class:
<?php
namespace Tests\Feature\Api;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
class PropertiesTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function can_get_all_properties() {...}
/** @test */
public function can_store_a_property() {...}
/** @test */
public function can_update_a_property()
{
$existingProperty = Property::factory()->create();
$newProperty = Property::factory()->make();
$response = $this->putJson(
route($this->routePrefix . 'update', $existingProperty),
$newProperty->toArray()
);
$response->assertJson([
'data' => [
// We keep the ID from the existing Property.
'id' => $existingProperty->id,
// But making sure the title changed.
'title' => $newProperty->title
]
]);
$this->assertDatabaseHas(
'properties',
$newProperty->toArray()
);
}
Running our tests we know our first error is in regards to the non-existent route: api.properties.update
, let's go add that real quick in our routes/api.php
file:
use App\Http\Controllers\Api\PropertyController;
Route::put(
'properties/{property}',
[PropertyController::class, 'update']
)->name('api.properties.update');
We already know that the next error will remind us that the update
method does not exist:
<?php
namespace App\Http\Controllers\Api;
use App\Models\Property;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\PropertyRequest;
class PropertyController extends Controller
{
public function index() : JsonResponse {...}
public function store(PropertyRequest $request): JsonResponse {...}
public function update(Request $request, Property $property)
{
return response()->json([
'data' => tap($property)->update($request->all())
]);
}
The update
method takes the Request as the first parameter (notice I didn't used the PropertyRequest since we haven't implemented the test first) and the Property that we are updating which comes from the endpoint using the Laravel's Route Implicit Binding.
We can also notice the tap method which basically returns the element that you pass it while being able to chain methods to it; at the end it will return the Model after being updated, as oppose to the alternative:
public function update(Request $request, Property $property): JsonResponse
{
$property->update($request->all());
return response()->json([
'data' => $property
]);
}
and which is valid too, it just requires an extra line.
At this point we should be getting a passing test and we're just missing the Validation tests, let's do that by going back to our tests\Unit\Http\Requests\PropertyRequestTest.php
file and we'll take a look at how to include the update
action in our existing tests:
use RefreshDatabase;
private string $routePrefix = 'api.properties.';
/**
* @test
* @throws \Throwable
*/
public function type_is_required()
{
$validatedField = 'type';
$brokenRule = null;
$property = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->postJson(
route($this->routePrefix . 'store'),
$property->toArray()
)->assertJsonValidationErrors($validatedField);
// Update assertion
$existingProperty = Property::factory()->create();
$newProperty = Property::factory()->make([
$validatedField => $brokenRule
]);
$this->putJson(
route($this->routePrefix . 'update', $existingProperty),
$newProperty->toArray()
)->assertJsonValidationErrors($validatedField);
}
In the Update assertion block we:
- Created the existing Property that we want to update.
- Made a non-persisted Property factory with the field under validation and the value that will break the test (null in this case)
- Then we made the
PUT
request to theapi.properties.update
route passing the existing Property as a parameter since that is what our route is expecting. - And we made an assertion for that request validating that we received a JSON error.
The only thing missing is to replicate the tests for the rest of the fields replacing the $validatedField
and $brokenRule
accordingly and inject the PropertyRequest
in our new update
method instead of the Laravel's Request class like this:
public function update(PropertyRequest $request, Property $property)
And we have a passing test.
Awesome, onto our final test.
Testing the Destroy method
The is the shortest test that we'll do in this article since we're just testing that once we hit the delete
endpoint we delete the Property model as well as return a response with a 204: No Content
status code.
/** @test */
public function can_update_a_property() {...}
/** @test */
public function can_delete_a_property()
{
$existingProperty = Property::factory()->create();
$this->deleteJson(
route($this->routePrefix . 'destroy', $existingProperty)
)->assertNoContent();
// You can also use assertStatus(204) instead of assertNoContent()
// in case you're using a Laravel version that does not have this assertion.
// (I believe it is available from v7.x onwards)
// Finally we just assert the `properties` table does not contain the model that we just deleted.
$this->assertDatabaseMissing(
'properties',
$existingProperty->toArray()
);
}
If we run it, we'll get the expected error which is that we don't have the specified route, let's add it:
Route::delete(
'properties',
[PropertyController::class, 'destroy']
)->name('api.properties.destroy');
And run it again to find that the destroy
method doesn't exist, I'll add that in our PropertyController
below update
method:
public function update(PropertyRequest $request, Property $property) {...}
public function destroy(Property $property)
{
$property->delete();
return response([], 204);
}
And I bet this passes, right?
The destroy
method is pretty simple; we receive the expected Property from the Route and since the Implicit binding resolved what Property Model we want to delete, we just run the delete()
method on it and then return the response.
- Note: I standardize the responses for this article but you can return whatever you need instead.
Conclusion
Although I tried to make this example as close to reality as possible, there is something I would have done differently if this were to be a real application:
Instead of returning JSON responses like we did, I would instead use API Resources , the reason is; I might want to make a few changes to the Collection/Model I want to return and an API Resource let's you modify that. Check the documentation for further examples.
Almost every end-to-end feature you will need to test has a similar structure as the tests mentioned above since we're making requests to an action and we expect a result from that you could easly adjust a test to your needs.
Some of the reasons a Team don't implement tests in my experience is because of the belief that testing takes time, as we can see you could copy/paste most of the tests and adjust them to the new scenario very quickly and if you standardize your workflow and code it's even faster. Of course copy/paste won't be possible in every feature but as soon as you start taking testing seriously your mind will get the hang of it and you'll notice the pattern in testing.
I suggest start testing small features and follow the TDD approach as often as possible, soon you might decide you like coding first and then test, it doesn't really matter as long as you test your features.
We're just getting started on TDD so expect more articles like this showing advance topics as we go.
I hope you find this article useful and let me know what would you do differently.
bcryp7, driesvints, kevinaswind, hassnian, maciejsk, zizu9, neil, davemi, jocelinkisenga, dumitriucristian and more liked this article