Testing — pinning down the API's contract with Pest
We now have a working product listing and order creation. This chapter writes tests for them.
The point of writing tests is not only "finding bugs." The main purpose is pinning down the behavior that works now so that you notice when you break it later. This series has another 12 chapters' worth of features to add. Checking by hand that nothing existing broke, every time you add something, is not realistic.
Why an API is easy to test
Compared with an application that has screens, APIs are on the easy side to test.
The input and output are explicit. Give it an HTTP method, a URL, and a request body, and back come a status code and a response body. No browser to launch, no elements to hunt for on a page.
The judgment is mechanical. Not "is the button blue?" but "is the status 201?" and "does data.id have a value?" There is no ambiguity.
The status codes settled in chapter 4, the response shape settled in chapter 7, the error shape settled in chapter 9 — all of them were contracts with the client. A test is that contract written as code.
How to write Pest
Laravel's tests directory ships with sample tests in Pest or PHPUnit1. You can choose either style, but the official documentation shows Pest first in its examples, so this series uses Pest.
The basic form is just this.
<?php
test('the product listing can be fetched', function () {
$response = $this->getJson('/api/v1/products');
$response->assertStatus(200);
});
No class, no method. You pass a name and a body to test(). The name is free-form prose, so reading it tells you what is being checked.
Run it.
php artisan test
There is also it(). It means the same thing, and is designed so the name reads as a sentence.
it('returns a list of products', function () {
// ...
});
This series standardizes on test().
Gathering the setup
When several tests need the same preparation, gather it in beforeEach().
beforeEach(function () {
Product::factory()->count(30)->has(Stock::factory())->create();
});
test('the product listing can be fetched', function () {
// starts with 30 records already prepared
});
The first test
Let us test the listing API from chapter 8.
<?php
use App\Models\Product;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('the product listing returns 20 per page', function () {
Product::factory()->count(30)->has(Stock::factory())->create();
$response = $this->getJson('/api/v1/products');
$response
->assertStatus(200)
->assertJsonCount(20, 'data')
->assertJsonPath('meta.total', 30)
->assertJsonPath('meta.per_page', 20);
});
There are three elements here.
pest()->use(RefreshDatabase::class) — restores the database for each test2. Data created by the previous test does not survive into the next, so tests stop depending on execution order.
Preparing data with a factory — the factories from chapter 3 get used here. No need to write INSERT statements inside the test.
Checking contents with assertJsonPath — dot notation walks the hierarchy, as in meta.total3. The structure we kept by leaving data wrapping in place in chapter 7 becomes the path directly.
Available assertions
Here are the ones you use often.
| Method | What it checks |
|---|---|
assertStatus(200) | The status code |
assertOk() / assertCreated() / assertNoContent() | Shorthand for 200 / 201 / 204 |
assertJsonPath('data.0.name', 'Coffee beans') | The value at a given path |
assertJsonCount(20, 'data') | The number of array elements |
assertJsonStructure(['data' => [['id', 'name']]]) | The key structure (values not checked) |
assertJsonValidationErrors(['customer_email']) | Which fields errored on a 422 |
getJson / postJson / putJson / patchJson / deleteJson send the respective methods3.
Testing writes
Let us test order creation. From here we check not only the response but the state of the database.
<?php
use App\Models\Product;
use App\Models\Stock;
use Illuminate\Foundation\Testing\RefreshDatabase;
pest()->use(RefreshDatabase::class);
test('an order can be created', function () {
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);
$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
$this->assertDatabaseHas('orders', [
'customer_email' => 'taro.test@example.com',
'total_amount' => 3600,
]);
$this->assertDatabaseHas('order_items', [
'product_id' => $product->id,
'quantity' => 3,
'unit_price' => 1200,
]);
});
assertDatabaseHas checks that a row matching the given conditions exists2. Looking only at the response lets "returned 201 but saved nothing" slip past.
Writing the total as 3600 outright is deliberate too. Rather than computing 1200 × 3, write the expected result directly. Compute it inside the test and you will not notice when the test makes the same mistake as the implementation.
Check that stock went down too
Creating the order is not enough on its own.
$this->assertDatabaseHas('stocks', [
'product_id' => $product->id,
'quantity' => 7, // 10 - 3
]);
Pin down the side effects as well. When chapter 14 introduces transactions, this test becomes the detector for "did the stock decrement disappear?"
Pinning down the failure paths
A test suite with only the happy path guards half of what matters.
The error behavior settled in chapters 5 and 9 is part of the contract too.
test('a missing email address returns 422', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response
->assertStatus(422)
->assertJsonValidationErrors(['customer_email']);
});
test('insufficient stock returns 409', function () {
$product = Product::factory()->create();
Stock::factory()->soldOut()->create(['product_id' => $product->id]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response
->assertStatus(409)
->assertJsonPath('code', 'INSUFFICIENT_STOCK');
$this->assertDatabaseCount('orders', 0);
});
Three things are checked: the status is 409, code is INSUFFICIENT_STOCK, and not a single order was created.
The last one matters. Even when an error comes back, processing may have got partway through and left half-finished data. Until chapter 14 adds transactions, that state can genuinely occur. Pin it down in a test and it goes green when you fix it and red when you break it.
The soldOut() state from chapter 3 earns its keep here. Instead of writing the code to set quantity to 0, the intent has a name.
Guard the response shape too
test('the order creation response structure is unchanged', function () {
$product = Product::factory()->create();
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 1],
],
]);
$response->assertJsonStructure([
'data' => [
'id',
'customer_email',
'status',
'total_amount',
'ordered_at',
],
]);
});
assertJsonStructure looks at the key structure rather than the values. Chapter 7 said fields can be added but not removed. Remove one and this test fails.
Did you notice that items is not checked? The OrderResource from chapter 7 uses whenLoaded('items'), so the key disappears entirely when the relationship is not loaded. Right after creating an order it is not loaded, so items does not come back.
If you want the lines returned too, load them in the controller before handing the model to the Resource.
return (new OrderResource($order->load('items')))
->response()
->setStatusCode(201);
Whether to add that load() depends on whether the client needs the lines immediately after creation. If it does not, do not add it. Leaving them out makes the response lighter and saves a query.
Let tests guard against N+1
Chapter 8 set Model::preventLazyLoading(! $this->app->isProduction()). The test environment is not production, so the setting is active.
That means a test for an endpoint with a forgotten with() fails with an exception.
test('the product listing does not trigger N+1', function () {
Product::factory()->count(30)->has(Stock::factory())->create();
// ProductResource reads stock, so if the controller
// forgot with('stock'), this throws
$this->getJson('/api/v1/products')->assertStatus(200);
});
Even without a dedicated test, any existing test that hits a listing becomes the detector. Deciding in chapter 8 to enable it only outside production was about getting this effect while keeping production available.
The testing pattern for this series
From this chapter on, every chapter ends with a "Tests" section.
Every time we add a feature, we write that chapter's tests alongside it, rather than saving them all for the end. There are two reasons.
Right after writing it, you remember what the correct behavior is. Recalling three months later what an API was supposed to do is hard.
The other is that it surfaces code that is hard to test. If writing a test feels awkward, there is usually a reason in the design. Extracting into a service class in chapter 6 was partly about this testability.
Here are the testing tools each chapter brings in.
| Chapter | Testing tool |
|---|---|
| 11, Authentication | Sanctum::actingAs() to create an authenticated state |
| 12, Authorization | Pinning down that access to someone else's resource gives 403 / 404 |
| 13, Rate limiting | Confirming a 429 once the limit is crossed |
| 14, Transactions | Pinning down that sending the same operation twice has the side effect once |
| 15, Notifications | Mail::fake() / Notification::fake() to stop sends and verify them |
| 16-17, Queues | Queue::fake() to confirm jobs are dispatched |
| 19, Storage | Storage::fake() to verify without creating real files |
| 20, Scheduler | travelTo() to freeze the clock |
These fake helpers are covered in the chapter that teaches the feature they belong to. We do not gather them into one chapter of fakes, because learning how to use one without knowing what it stands in for does not stick.
What matters in production
Always write authorization tests
Chapter 12 covers this, but it is worth saying now. An authorization gap goes unnoticed until production unless you write a test for it.
A happy-path test confirms "you can fetch your own data." That passes even with authorization broken. If anyone can access anything, then you can access your own data too.
What you need is a test that you cannot access someone else's data.
test("another user's order cannot be fetched", function () {
// The form we write in chapter 12
});
Chapter 12 puts one of these on every endpoint.
Keep external communication out of tests
Tests that call an external API bring three problems: they get slow, they fail because of the other service's state, and they bother the other party.
Laravel has a mechanism for stopping the HTTP client. When chapter 16 writes a job calling an external API, we use Http::fake().
Sending mail is the same. Send real mail in a test and the moment you forget fake(), a flood of messages goes to the test address.
Do not let tests depend on execution order
RefreshDatabase restores the database, but other state can survive. The cache covered in chapter 18 is an example.
test('popular products are returned', function () {
// May read a stale result the previous test put in the cache
});
Chapter 18 clears the cache in the setup of tests that use it. A test that "passes on its own but fails in the full run" is caused by exactly this kind of leftover state.
Do not delete a test to go green
When a change makes a test fail, there are two responses: fix the test, or fix the implementation.
A failing test means behavior you previously decided on has changed. If the change was intended, rewrite the test to the new expectation. If it was not, the implementation is what is wrong.
Deleting the test without deciding is the worst option. The behavior that test was guarding is now guarded by nobody.
Summary
- The main purpose of tests is not hunting bugs but pinning down current behavior so you notice when you break it
- APIs have explicit inputs and outputs, which makes them easy to test. A test is the contract written as code
pest()->use(RefreshDatabase::class)restores the database for each test- Check the saved result with
assertDatabaseHas, not just the response. Do not let "returned 201 but saved nothing" slip past - The happy path is half the job. Pin down the error status, the
code, and that no data was created - Write expected values directly rather than computing them; computing hides a mistake shared with the implementation
- From this chapter on, every chapter ends with a testing section
What to read next
Reads and writes both work now, pinned down by tests. But anyone can call them. From the next chapter, Authentication — token authentication with Sanctum, we add authentication and authorization. We build sign-up and login, and move orders from guests onto user accounts. The tests written in this chapter will need rewriting once authentication is in place, and the next chapter covers that procedure too.
Exercises
The following test passes even when the implementation is broken. Explain why
test('an order can be created', function () {
$product = Product::factory()->create(['price' => 1200]);
Stock::factory()->create(['product_id' => $product->id, 'quantity' => 10]);
$response = $this->postJson('/api/v1/orders', [
'customer_email' => 'taro.test@example.com',
'items' => [
['product_id' => $product->id, 'quantity' => 3],
],
]);
$response->assertStatus(201);
});
Sample answer
It only checks that a 201 came back.
All of the following implementations pass this test.
- Return 201 without creating an order record
- Save the total as 0
- Create no order lines
- Never decrement stock
None of them counts as "the order was created," yet the test stays green.
Add the checks it needs.
$response
->assertStatus(201)
->assertJsonPath('data.total_amount', 3600);
$this->assertDatabaseHas('orders', ['total_amount' => 3600]);
$this->assertDatabaseHas('order_items', ['quantity' => 3, 'unit_price' => 1200]);
$this->assertDatabaseHas('stocks', ['product_id' => $product->id, 'quantity' => 7]);
A status code only tells you that processing reached the end. What actually happened is confirmed by the response contents and the database state.
The stock decrement in particular is easy to forget. With the order created it looks as though the goal was met, but if stock did not go down the item gets sold twice.
How do you respond to "we have no time for tests, so we will write them after release"?
Sample answer
What this decision costs you is not time but what you are able to write.
Tests written after release copy down "the behavior that works now." You look at the implementation and write a test confirming it behaves that way. If the implementation has a bug, you pin the bug down along with it.
Change the order and the meaning of the test changes.
- Written before or right after the implementation → it says "this is how it should behave." A wrong implementation fails
- Written three months later → it says "this is how it behaves." A wrong implementation passes
The latter still has the effect of catching future regressions, so it is not worthless. It just does half the intended job.
Here are two realistic middle grounds.
Write the failure paths first: even if you cannot do everything, write the error behavior (422 / 409 / 403) first. That part is hard to check by hand and is exactly what turns into "come to think of it, we never checked that" after release.
Write in order of what hurts most when broken: start with payment, stock, and authorization, where the damage is largest. A product listing in the wrong order is recoverable; double charging takes real work to unwind.
The constraint "there is no time" is real, so the constructive framing is not all-or-nothing but a matter of priority.