Lately I've been trying my best to get into TDD, test-driven development, which is a style of programming where you build tests alongside your code. I picked Laravel a while ago as my primary PHP framework for a lot of projects because of how easy it makes testing. Laravel makes it simple -- from making a test (php artisan make:test YourTest
), to the easy to use API they provide for interfacing with PHPUnit ($this->assertStatus(404)
).
Laravel does a great job of documenting their framework, and the official docs are usually more up to date than most code bases and tutorials I scoured the net to find. However, the testing docs are also incredibly simplified, and missing crucial steps (like how to run a test). It left me wondering if there was something I was missing.
After a bit of tinkering around, here's what I discovered.
Testing JSON APIs
The most recent version of Laravel has special testing methods for JSON APIs. It allows us to query Laravel's internal API directly and easily make assertions on the response.
Writing your first test
Let's make the test first. Use the following artisan
command:
php artisan make: test ShopTest
Now you can open up your first test file located in tests/Feature/ShopTest.php
and add a JSON API call:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ShopTest extends TestCase
{
/**
* Tests shop index without params to see if it works
*
* @return void
*/
public function testShopIndex()
{
$response = $this->json('GET', '/api/v1/shops');
$response
->assertStatus(201);
}
}
How it works
We first query the API using the json()
method which returns a response from the API. Then we check the response and assert certain things, like the status code (assertStatus()
), or data itself (assertJson
).
Debugging tests
If at any point the test fails, and you're not sure why, just do a dd($response)
to dump the response into the console. You can also use commands like var_dump($variable)
to print out text in the console during tests. It helps verify when a method returns a boolean (like deleting a resource) or verifying properties exist on a resource (e.g. $post->id
). Be wary of using var_dump()
for larger variables (like the response object) - it tends to crash and freeze tests.
Be careful when asserting data with duplicate property names, even if they're nested (e.g.
{ id: 1, comment { id: 129 } }
). assertJson will collapse the array and assume you mean both IDs. In this case, you have to use assertExactJson to check duplicate property names.
Running the tests
Laravel doesn't actually write this anywhere in their docs, and you'd assume that you can run a test with something like php artisan test
-- but you actually have to use the PHPUnit library in your composer dependencies:
./vendor/bin/phpunit
Run that command from the project root and PHPUnit will run all tests located in your /tests/
folder.
Make sure all composer dependencies are installed or you won't be able to run this script. And if you're running Laravel inside of Docker, make sure to run PHPUnit inside the container
docker-compose exec workspace ./vendor/bin/phpunit
Running a single test
Sometimes you need to run a single test in isolation. I had to look this one up too:
./vendor/bin/phpunit --filter TestControllerName
This also works as well, albeit more verbose:
./vendor/bin/phpunit tests/Feature/TestControllerName.php
Keep it simple silly
I found that with basic Laravel APIs, you tend to repeat the same tests over and over again. Wouldn't it be nice if an API test looked like one of our Models, and was as simple as writing an extended class with protected variables?:
<?php
namespace Tests\Feature\Controllers;
use Tests\CrudTest;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
class StrainsControllerTest extends CrudTest
{
/**
* The model to use when creating dummy data
*
* @var class
*/
protected $model = \KushyApi\Posts::class;
/**
* The endpoint to query in the API
* e.g = /api/v1/<endpoint>
*
* @var string
*/
protected $endpoint = 'strains';
/**
* Any additional "states" to add to factory
*
* @var string
*/
protected $states = 'strains';
/**
* Extra data to pass to POST endpoint
* aka the (store() method)
*
* Must be array (ends up merged with another)
*
* @var array
*/
protected $store = [
'category' => '1'
];
}
With the power of abstract classes in OOP, we can create a kind of base class to extend the functionality of our specific endpoint's test class. Let's create a new test called tests/CrudTest.php
:
<?php
namespace Tests;
use Tests\TestCase;
use Tests\Traits\AttachJwtToken;
use Illuminate\Foundation\Testing\WithFaker;
use Illuminate\Foundation\Testing\RefreshDatabase;
abstract class CrudTest extends TestCase
{
use AttachJwtToken;
/**
* Uses the model factory to generate a fake entry
*
* @return class
*/
public function createPost()
{
if($this->states)
{
return factory($this->model)->states($this->states)->create();
}
return factory($this->model)->create();
}
/**
* GET /endpoint/
* Should return 201 with data array
*
* @return void
*/
public function testIndex()
{
$response = $this->json('GET', "api/v1/{$this->endpoint}");
$response
->assertStatus(201)
->assertJson([
'data' => true
]);
}
/**
* GET /endpoint/<id>
* Should return 201 with data array
*
* @return void
*/
public function testShow()
{
// Create a test shop with filled out fields
$activity = $this->createPost();
// Check the API for the new entry
$response = $this->json('GET', "api/v1/{$this->endpoint}/{$activity->id}");
// Delete the test shop
$activity->delete();
$response
->assertStatus(201)
->assertJson([
'data' => true
]);
}
/**
* POST /endpoint/
*
* @return void
*/
public function testStore()
{
$activity = $this->createPost();
$activity = $activity->toArray();
/**
* Pass in any extra data
*/
if($this->store)
{
$activity = array_merge($activity, $this->store);
}
$response = $this->json('POST', "api/v1/{$this->endpoint}/", $activity);
($this->model)::destroy($activity['id']);
$response
->assertStatus(201)
->assertJson([
'data' => true
]);
}
/**
* DELETE /endpoint/<id>
* Tests the destroy() method that deletes the shop
*
* @return void
*/
public function testDestroy()
{
$activity = $this->createPost();
$response = $this->json('DELETE', "api/v1/{$this->endpoint}/{$activity->id}");
$response
->assertStatus(200);
}
}
This class tests the index, show, store, and destroy methods that come in the basic API controllers that artisan generates for you. And it does it dynamically, using the variables you define in your endpoint's test class.
Testing with Factories
This requires that you have a Factory for your class, so the createPost()
method can generate fake data to test. Factories use the Faker library to generate fake data for a specific model, whether you need it for tasks seeding or testing. Say you need to create 50 users - a factory will generate random (and even unique) names, passwords, and emails for all 50.
Creating factories are very simple, check out the Laravel docs. Here's an example of one with a few different types of fields (images, location data, etc):
php artisan make:factory YourModelNameFactory
<?php
use Faker\Generator as Faker;
$factory->define(YourAppName\YourModelName::class, function (Faker $faker) {
return [
'id' => $faker->uuid,
'name' => $faker->name,
'slug' => $faker->slug,
'featured_img' => $faker->imageUrl(640, 480),
'avatar' => $faker->imageUrl(48, 48),
'description' => $faker->paragraph,
'latitude' => $faker->latitude(-90, 90),
'longitude' => $faker->longitude(-180, 180),
'address' => $faker->streetAddress,
'city' => $faker->city,
'state' => $faker->state,
'postal_code' => $faker->postcode,
'country' => $faker->country,
'rating' => $faker->numberBetween(0, 5),
'featured' => $faker->numberBetween(0, 1),
'verified' => $faker->numberBetween(0, 1)
];
});
$factory->state(YourAppName\YourModelName::class, 'product', [
'category' => 'Product',
]);
Use states to add extra data or modify existing params in your fake Model entry.
factory(YourModelName::class)->state('product');
Works great if you need to have control over a model's specific parameter(s), like setting a user as an admin or customer. In this case, we set the post's category.
Handling JWT
If you noticed, I have a trait on the abstract class called AttachJwtToken
. I use Laravel's Passport package and connect to the API using OAuth2 authentication. Because of this, all JSON requests in tests require the proper authorization headers (with a JWT for the user).
The AttachJwtToken
trait handles this process for us. I found this originally on Github by @jgrossi and based my code off it. It checks for an existing user (which you can pass in using the loginAs
method), and if no user is found, creates one, and returns a JWT for the user (using Laravel Passport's built in createToken()
function)
Check out my full source code on Gist
It's that simple!
It really is this simple to test your API (if it's a simple CRUD API).
The hardest part of researching testing was feeling secure in the amount of information I'd accrued. At the end of the day, when you look at the source code, there are only so many testing methods Laravel/PHPUnit provides. You assert one thing or another, whether it's the status code or JSON itself, or mock users using the actingAs($user)
.
What becomes more complex are tasks like replicating features that span across multiple controllers, jobs, services, etc -- or mocking functionality like image storage. Or just creating factories for all your models.
Testing is easy, setting up the proper conditions becomes the true test.
Hope this helps your API TDD 🤘 Ryo
References: