Martin Joo

« back published by @mmartin_joo on December 24, 2021

Test-Driven API with Laravel and Pest (part 1)

Did you ever wondered how can you achieve near 100% code coverage without fighting with mocks, stubs, or spies? I constantly wrtite APIs with high coverage and I don't event know what those words mean. Instead I write API tests, that really test the whole application. In my opinion this is the most effective and most maintainable way to practice Test-Driven Development.

In this post, we’re gonna build a simple CRUD API using Laravel and Pest which is an amazing test framework. The goal of this post is to show you how to practice test-driven development or TDD.

In this application, we have products, prices, and categories. A category has many products, a product belongs to one category, and a product has many prices. A price has a from and to date attribute, for example, “Product A” has a price of $19 from 01/01/2022 to 01/31/2022 but has a different price from 01/02/2022 and so on.

You can find the whole repo on Github.

Create a category

This is the easiest part, so let’s start with this. I won’t list migrations or factories here because it’s boring. First, let’s write some tests for creating a category:

it('should return 422 if name is missing', function ($name) {
    postJson(route('categories.store'), [
        'name' => $name,            
        'description' => 'description',
    ])->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
})->with([
    '',
    null
]);

This is the “unhappy path” because you expect something different than a 2xx status code. If you have used Jest or Mocha it may seem familiar. Pest is very similar to these frameworks.

The it() method is a test function that describes a specific use case. In the category model, the name is the only required and unique attribute so we make sure that our API checks these rules. Pest comes with functions like getJson(), postJson(), putJson() and so on. They send a request with the appropriate HTTP verb and set the Content-Type header to application/json. So every data will be sent as JSON.

These functions return the Response from the server. In fact, the return value is an Illuminate\Testing\TestResponse instance, so it has a lot of assertion helpers, like the assertStatus(). So this is what happens in the first test:

  • We send a POST /api/v1/categories request without a name in the body
  • We assert that the status code is 422 which is unprocessable entity (validation error)

The last piece of the puzzle is the with() function and the $name parameter. This is basically a more convenient way to use PHPUnit’s data providers. The first test function will run twice, in the first run the $name parameter will be null, in the second an empty string. These values come from the with() method. In both cases, the status needs to be 422. We can also check that the categories table is completely empty after the POST request, but it’s okay.

it('should return 422 if name is not unique', function () {
   Category::factory(['name' => 'Books'])->create();

   postJson(route('categories.store'), [
       'name' => 'Books',
       'description' => 'description'
   ])->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY);
});

In this test, we post a valid value in the name, but it’s already taken by another category. And the name of the category must be unique, so once again we expect a 422 status code from the API.

Okay, now in the of TDD we can add some real code to make these tests green. First, we should create a StoreCategoryRequest:

class StoreCategoryRequest extends FormRequest
{
   public function rules()
   {
       return [
           'name' => ['required','string', Rule::unique('categories', 'name')->ignore($this->category?->id)],
           'description' => 'nullable|sometimes|string',
       ];
   }
}

For now, just ignore the ignore function (edit: it sounded cooler in my head sry). By the way, the ‘nullable|sometimes|string’ is a common pattern that is a little bit confusing at first. What does it mean in plain English?

  • The description is absolutely optional (nullable)
  • But, if it’s present in the request body (sometimes)
  • Then it must be a string (string)

We can move on to the Controller:

public function store(StoreCategoryRequest $request)
{
   return response([
       'data' => new CategoryResource(Category::create($request->validated()))
   ], Response::HTTP_CREATED);
}

That’s it. The request does the validation, after that we just create the category.

Warning: this is a playground project about TDD and CRUD APIs. That means in every situation I’ll try to choose the easiest, yet elegant solution. In a real-world, big application I probably use DTOs, Repositories, Services. But since the category model only has two properties I’m okay with this one-liner.

We haven’t seen a single test function for a while, so here’s one:

it('should create a category', function () {
    $response = postJson(
       route('categories.store'),
       [
           'name' => 'Books',
           'description' => 'Category for books',
       ]
         )
    ->assertStatus(Response::HTTP_CREATED)
    ->json('data');

   $category = getJson(
       route('categories.show', ['category' => $response['id']])
   )->json('data');

   expect($category)
        ->id->toBe($response['id'])
        ->name->toBe('Books')
        ->description->toBe('Category for books')
        ->productCount->toBe(0);
});

Finally the happy path. This time the status code is 201 CREATED. Notice the json(‘data’) chained after the postJson() call. This means we want to parse the response as JSON and we want to get the data attribute from it.

After the POST request, we need to make sure that the new category is saved in our database. There are two solutions:

  • Run a query and assert that the categories table has the new row
  • Use the GET /categories/{category} API to get our category

Since I know that we will have a GET API for the categories I choose the second option. It has some advantages:

  • This way your tests are completely decoupled from implementation details! For me, this is a big one. This means that I can refactor anything inside my application. Until the POST and the GET APIs behave the same way, I’m good and my tests will never break.
  • It feels cleaner and more high-level. Less low-level technical stuff.

But it also has some disadvantages:

  • It can be slower. Instead of a single query, we make an HTTP request. It’s not drastically slower, but after a few thousand tests, you can notice the difference.
  • At the beginning of the project or a module, you have to write a lot of APIs.
  • Maybe you don’t need the GET API outside of this test

Now let’s see once again the last part of the test:

expect($category)
    ->id->toBe($response['id'])
    ->name->toBe('Books')
    ->description->toBe('Category for books')
    ->productCount->toBe(0);

It’s cool, isn’t it? The except function wraps your data inside an object that has magic getters (id, name, and so on), and functions starting with toBe. You can check the documentation for more information. The toBe function is equivalent to the assertEquals in PHPUnit.

The GET API is really simple so we skip it.

Update a category

Updating a category is very simple. We will use the same request for updates with these rules:

return [
   'name' => ['required','string', Rule::unique('categories', 'name')->ignore($this->category?->id)],
   'description' => 'nullable|sometimes|string',
];

The ignore call makes it possible to ignore the currently updating category’s ID from the unique query. So I have a category with the name of ‘Books’. I update it, but only the description. Now, without the ignore it will return 422 because the name ‘Books’ is already taken. Of course, it’s not the desired result, so we ignore it. The $this->category refers to the category from the route that is api/v1/categories/{category}. Instead of talking let’s create a test for this use case:

it('should ignore the old name from unique validation', function () {
   $category = Category::factory(['name' => 'Books'])
       ->has(Product::factory()->count(3))
       ->create();

   putJson(
       route('categories.update', ['category' => $category->id]),
       [
           'name' => 'Books',
           'description' => 'New description',
       ]
    )->assertStatus(Response::HTTP_OK);

   $category = getJson(route('categories.show', ['category' => $category->id]))->json('data');
   expect($category)
       ->name->toBe('Books')
       ->description->toBe('New description')
       ->productCount->toBe(3);
});

Here I implemented the situation above and expect to get a 200 status code and an updated category with a brand new description. And now let’s see a regular test with an updated name:

it('should update a category', function () {
   $category = Category::factory()
       ->has(Product::factory()->count(3))
       ->create();

   putJson(
       route('categories.update', ['category' => $category->id]),
       [
           'name' => 'Books updated',
           'description' => 'New description',
       ]
   )->assertStatus(Response::HTTP_OK);

   $category = getJson(route('categories.show', ['category' => $category->id]))->json('data');
   expect($category)
       ->name->toBe('Books updated')
       ->description->toBe('New description')
       ->productCount->toBe(3);
});

After the tests, let’s write the Controller:

public function update(StoreCategoryRequest $request, Category $category)
{
   $category->fill($request->validated());
   $category->save();

   return response([
       'data' => new CategoryResource($category->fresh())
   ], Response::HTTP_OK);
}

We use the fill method on the category. Please notice that I don’t use $request->all() but $request->validated(). Once again the category is so simple we don’t even have to worry about code duplication both create and update are one-liners.

Delete a category

The last thing we need to do is the DELETE APi. We have one important rule here: if we delete a category we don’t want to delete all the products associated with it. We want their category_id to be NULL. I feel like throwing a test at you:

it('should set deleted category products category id to NULL', function () {
   $category = Category::factory()
        ->has(Product::factory()->count(3))
        ->create();

   deleteJson(route('categories.destroy', ['category' => $category->id]))
       ->assertStatus(Response::HTTP_NO_CONTENT);

   expect(Category::count())->toBe(0);
   expect(Product::count())->toBe(3);

   Product::all()
       ->each(fn (Product $product) => expect($product->category_id)->toBeNull());
});

First, we create a category with three products. Notice the factory call:

$category = Category::factory()
    ->has(Product::factory()->count(3))
    ->create();

You can use has() and for() to create related models.

After that, we call the DELETE API and expect a 204 status code. In the last part, we make sure that the categories table is empty, but we still have 3 products. We also check that the category_id is perfectly NULL for these products.

To make this test true, we need to implement this behaviour in the database layer:

$table->foreignId('category_id')
   ->index()
   ->nullable(true)
   ->references('id')
   ->on('categories')
   ->onDelete('SET NULL');

This is a snippet from the create_products_table migration. The onDelete(‘SET NULL’) does the magic part. We can also write a simpler test:

it('should delete a category', function () {
   $category = Category::factory()->create();

   deleteJson(route('categories.destroy', ['category' => $category->id]))
       ->assertStatus(Response::HTTP_NO_CONTENT);

   $this->assertDatabaseCount('categories', 0);
});

This test just simply deletes a category. And yes, you can still use the standard assert methods from the Laravel TestCase. In this example, we used the assertDatabaseCount.

And finally, let’s make them green with a Controller action:

public function destroy(Category $category)
{
   $category->delete();
   return response([], Response::HTTP_NO_CONTENT);
}

So basically we wrote about 20 lines of test code for 1 line of production code. Yeah, TDD sometimes sucks, but in the long term, it will payout.

Today we used red-green-refactor which you can apply to simple things like this API. And it means:

  • First, you write a test that describes your use case. At this stage, you can really understand the specs, you can design and think about your API, or how you want to name your classes, functions, and so on. This is the red stage because your tests fail. There is no real code yet.
  • After that, you can write the minimal implementation code that makes your tests pass. Nothing fancy, just the bare minimum. Now you can think about some edge cases, more tests. This is the green stage because your tests pass.
  • After you have the minimal implementation and you have tests, finally you can refactor and make your code better.

If you want to learn more about Test-Driven Development and APIs, check out my eBook Test-Driven APIs with Laravel and Pest.