Martin Joo - Test-Driven APIs With Laravel and Pest
Martin Joo - Test-Driven APIs With Laravel and Pest
<?php
By Martin Joo
Introduction
The fundamentals
Test-Driven Development
The red
The green
The refactor
REST API
The basics
PUT vs PATCH
Nested resources
JSON API
Overview
Identification
Attributes
Relationships
Requests
API Best Practices
The design
User stories
Database design
API design
Pest
Useful concepts from Domain-Driven Design
The implementation
Blueprint
API versioning
UUIDs
Configuring Pest
Department API
Create a department
Update a department
Get departments
Resources
Employee API
Payment types
Create / Update an employee
Get employees
Get department employees
Resources and value objects
Payday API
Create paychecks
Get paychecks for an employee
Thank you
1 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Introduction
We live in the world of APIs. We write them, we use them, and often we hate them. As software developers,
we like patterns and standards. At this moment there are:
21 PSR standards
55 design patterns
24,128 ISO standards
Even with this many patterns, recommendations, and standards we often fail to write APIs that look similar
to each other and have great test coverage. We're arguing about the test pyramid and abstract concepts
instead of writing tests that really cover the whole API. We come up with unique solutions for general
problems because it feels satisfying.
GET /api/products?category_id=10
GET /api/products/10
POST /api/getProducts
Request body:
{
"category_id": 10
}
Behind the API we have controllers with 10+ methods and 500 lines of code. In my opinion, this API should
look like this:
GET /api/v1/categories/1dbd238c4-99ee-42d8-a7ae-b8602653de4c/products
In this book, I'm gonna show you how I build APIs that follow the JSON API standard and I'll show you how to
identify important use-cases that should be covered with tests.
2 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
3 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The fundamentals
First of all, I would like to talk about the fundamentals. In this chapter, we won't write actual code but
discuss some basic concepts we will use in the rest of the book. If you're already familiar with:
Test-Driven Development
REST API
JSON API
feel free to skip this chapter and dive deep into the design. For the rest of us let's start with test-driven
development or TDD.
4 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Test-Driven Development
The red
Test-Driven development is a programming approach where the tests drive the actual implementation
code. In practice, this means that we write the tests first and then the production code. I know it sounds
complicated and maybe a little bit scary. So I try to rephrase it:
In Test-Driven Development the first step is to specify how you want to use your class / function.
Only after that do you start writing the actual code.
Let me illustrate it with a very simple example. I need to write a Calculator class that needs to be able to
divide two numbers. Let's use it before we write it!
5 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
6 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Now, that we're using asserts we need a context. This is our test class:
It's always a good idea to separate your test functions based on 'use-cases'. In this basic example we have
two different scenarios:
7 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
I don't know about you, but I'm going crazy just by looking at this function name:
testDivideWhenTheDividerIsZero . Fortunately, PHPUnit has a solution for us:
/** @test !$
public function it_should_return_zero_when_the_divider_is_zero()
{
}
}
8 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
With the @test annotation you can omit the test word from the function name and by using underscores
you can actually read it. As you can see a test function's name reads like an English sentence: It should
divide valid numbers.
In this stage, you have no real code, only tests. So, naturally, these tests will fail (the Calculator class doesn't
even exist).
The green
It's time to write some code! After we wrote the tests, we can write the actual code:
class Calculator
{
public function divide(float $a, float $b): float
{
if ($b !!% 0.0) {
return 0;
} else {
return round($a / $b, 2);
}
}
}
In this stage, your main focus should not be to write the most beautiful code ever. No, you want to write the
minimum amount of code that makes the tests green. After your tests pass maybe you come up with a new
edge-case so you should write another test, and then handle this edge-case in your code.
The refactor
So we wrote the minimum amount of code to make the tests pass. This means that we have an automated
test for every use case we know right now. If you think about it, this is a huge benefit! From that point you
are almost unable to make bugs, so you can start refactoring:
9 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
class Calculator
{
public function divide(float $a, float $b): float
{
try {
return round($a / $b, 2);
} catch (DivisionByZeroError) {
return 0;
}
}
}
This stage is called refactor because you don't write new code but make the existing codebase better.
10 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Key takeaways:
When you write a test for a non-existing function you're writing the specification for that function.
Your tests should read like documentation / specification.
11 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
REST API
The basics
REST or RESTful stands for representational state transfer which doesn't reveal much. In fact, it's just a way
to build APIs that expose resources. It's not a strict protocol (like SOAP) but a set of best practices and it can
be more fluid. It heavily relies on HTTP methods and status codes.
GET /api/v1/employees/{employee} Get one employee 200 - OK, 404 - Not Found
POST /api/v1/employees Create a new employee 201 - Created, 429 - Unprocessable Entity
As you can see the whole API feels "resourceful". It's simple and exposes resources.
PUT vs PATCH
PUT and PATCH confuse a lot of developers, because both of them can be used in updates, but in fact, it's
simple:
A PATCH request is a partial update, so in the request, you only specify a few attributes.
A PUT request replaces the whole resource, so in the request you specify everything.
Let's say you have a list of posts. Each row has a button called 'Publish'. If you click on this button you can
specify when you want to publish the post. After that your app updates the publish_at attribute of the
Post model to the date, you specified. Something like this:
12 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
PATCH /api/v1/posts/{post}/publish
{
"publish_at": "2022-03-10"
}
On the other hand, if you have an edit form for the post, when you click the save button the client will send
every attribute to the server doesn't matter if it's changed or not. This is a PUT request, because it replaces
the post on the server:
PUT /api/v1/posts/{post}
{
"title": "My post",
"body": "<div>Fancy content here!&div>",
"categoryId": 1,
}
A successful response after a PUT request should have no body. The client sent every attribute so
there's no reason to send back anything.
A successful response after a PATCH request should have a body. Maybe the API calculates something
from the attributes that the client sent.
A PUT request should be idempotent. This means I can write a bash script and send the PUT request
above 1000 times in a row after any side effect. The server remains in the same state.
A PATCH request is not necessarily idempotent.
13 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Nested resources
Another good thing that REST API taught us is that we can use nested resources in most situations. We will
build a mini CRM / Payroll management application later in this book. So we have employees and each
employee has many paychecks. Imagine the application has a dedicated page for an employee. On this
page, there's a list that shows all the paychecks for an employee.
On the API level, you have two choices. You can make the paycheck a top-level resource:
GET /payhecks?employee_id=1
GET /employees/1/paychecks
It really depends on the exact situation but in most cases, you can and should go with the second option in
my opinion. How do you decide? You can answer these questions:
But even if the answer is yes, you can benefit from two separate API endpoints. It really depends
on the exact situation.
We will use this approach in the demo application later and I show you how the controllers follow the
nested resources.
Key takeaways:
14 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
JSON API
Overview
We write so many APIs and if you think about it each of them is different:
The filtering and the sorting are different every damn time.
Wouldn't be great if there were a standard that generalizes all of these concepts? Yes, there is! And it's
called JSON API. Let's take a look:
15 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
{
"data": [{
"type": "articles",
"id": 1,
"attributes": {
"title": "JSON:API paints my bikeshed!",
"body": "This is the body of the article",
"commentCount": 2,
"likeCount": 8
},
"relationships": {
"author": {
"links": {
"self": "http:!"example.com/articles/1/relationships/author",
"related": "http:!"example.com/articles/1/author"
}
},
"comments": [{
"data": { "type": "user", "id": 9}
}]
},
"links": {
"self": "http:!"example.com/articles/1"
}
}],
"included": [{
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"links": {
"self": "http:!"example.com/comments/5"
}
}]
}
16 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
I know, I know... At first, it looks like hell, so let's interpret it step by step.
Identification
{
"id": 1,
"type": "articles"
}
Every response has these two keys no matter what. The id is straightforward, the type is the plural form of
the resource.
Attributes
{
"attributes": {
"title": "JSON:API paints my bikeshed!",
"body": "This is the body of the article",
"commentCount": 2,
"likeCount": 8
}
}
The attributes key contains the actual data. I bet that in most of your APIs this is the top-level data.
Relationships
This is the more interesting stuff. In web development we have two main choices when it comes to
relationships in APIs:
There are pros and cons to both of these approaches and most of the time it depends on your application.
But there is two good rules of thumbs:
If you include (almost) everything you may end up with huge responses and N+1 query problems. For
example, you return a response with 50 articles and each article loads the author. You end up with 51
queries.
If you include (almost) nothing you have small responses but you may have too many requests to the
server. For example, you return a response with 50 articles, after that the client makes 50 additional
HTTP requests to get the authors.
17 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
{
"relationships": {
"author": {
"links": {
"self": "http:!"example.com/articles/1/relationships/author",
"related": "http:!"example.com/articles/1/author"
}
},
"comments": [{
"data": {"type": "comments", "id": 5}
}]
},
}
In this example, the comments relationship is loaded while the author only contains a link where you can
fetch the author. In fact, it contains two links and it's a little bit confusing so let's make it clear:
related: with this URL I can manipulate the actual user (author). So if I make a DELETE
http://example.com/articles/1/author request I delete the user from the users table.
self: this URL only manipulates the relationship between the author and the article. So if I make a
DELETE http://example.com/articles/1/relationships/author request I only destroy the
relationship between the user and the article. But it won't delete the user from the users table.
As you can see in the comments key there is an ID and a type, but no attributes. Where is it? It lives in the
included key:
"included": [{
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"links": {
"self": "http:!"example.com/comments/5"
}
}]
18 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Relationships: it only contains information about the relationships. It tells you that an article has author
and comments relationships.
Included: it contains the loaded relationships. In the example above the API endpoint only loads the
comments, so in the included key you can find the comments but not the author. The author can be
fetched using the links in the relationships object.
This is the part I like the least about JSON APi. In my opinion, it would be much better if there's only a
relationships key that looks like this:
{
"relationships": {
"author": {
"links": {
"self": "http:!"example.com/articles/1/relationships/author",
"related": "http:!"example.com/articles/1/author"
}
},
"comments": [{
"type": "comments",
"id": "5",
"attributes": {
"body": "First!"
},
"links": {
"self": "http:!"example.com/comments/5"
}
}]
},
}
So it actually contains the comments and provides links for the author. However in this book, I will follow
the official specification, but here's my opinion: you don't have to obey a standard just because it's a
standard. You can use only the good parts from it that fit your application.
Either way, you have a reasonable default standard in your company / projects.
19 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Requests
# Filtering by title
GET /articles?filter[title]=laravel
# Including relationships
GET /articles?include=author,comments
As you can see the client is in full control of what and how it wants to query. Fortunately, the Laravel
community has two amazing packages that make the requests and also the responses very easy to use:
@timacdonald87 created a package called json-api for Laravel. This package extends the base
JsonResource class and implements (at the time) most of the specifications.
@spatie_be created a package called laravel-query-builder. I will talk about it in more detail, but it
implements all of the filterings, sorting logic with Eloquent.
Key takeaways:
The JSON API provides a very reasonable standard for use to generalize API requests and responses.
Querying relationships or providing links depends on your application, or in fact the exact scenario but
JSON API supports both.
You should check out Tim Macdonald's Youtube videos where he builds the json-api package.
20 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Use the HTTP status codes. For example, if your endpoint is asynchronous (meaning it dispatches jobs
that will take time) don't return 200 but 202 - Accepted and a link where the client can request the
status of the operation.
Don't expose auto-increment integer IDs! This is an important one. Using IDs like 1, 2 in your URLs is a
security concern and can be exploited. It's especially important if your API is public. This is how I do it:
I have integer IDs in the database. It's mostly MySQL which is optimized for using auto-increment
IDs.
I also have UUIDs for almost every model.
In the API I only expose the UUIDs.
Versioning your API. This is also more important if you write a public API. I show you later how to do it
easily with Laravel's RouteServiceProvider.
Loading relationships
FIltering
Sorting
Sparse fieldsets
Try to standardize these things so you have the same solutions in your projects. In this case, we're
gonna use JSON API.
Make your response easy to use for the client, for example:
All of the above is easy to achieve in Laravel with the help of a few packages.
GET /api/v1/employees/a3b5ce95-c9c8-40f7-b8d7-06133c768a92?
include=department,paychecks
GET /api/v1/emplyoees/a3b5ce95-c9c8-40f7-b8d7-06133c768a92/paychecks?sort=-
payed_at
21 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
{
"data": {
"id": "a3b5ce95-c9c8-40f7-b8d7-06133c768a92",
"type": "employees",
"attributes": {
"fullName": "Adalberto Yost V",
"email": "[email protected]",
"jobTitle": "Full stack developer",
"payment": {
"type": "salary",
"amount": {
"cents": 5184700,
"dollars": "$51,847.00"
}
}
},
"relationships": {
"department": {
"data": {"id": "e12cea58-d2ec-495a-a542-03fd29872d4c", "type":
"departments"}
}
},
"meta": {},
"links": {
"self": "http:!"localhost:8000/api/v1/employees/a3b5ce95-c9c8-40f7-
b8d7-06133c768a92"
}
},
"included": [
{
"id": "e12cea58-d2ec-495a-a542-03fd29872d4c",
"type": "departments",
"attributes": {
"name": "Development",
},
22 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
"relationships": {},
"meta": {},
"links": {
"self": "http:!"localhost:8000/api/v1/departments/e12cea58-d2ec-495a-
a542-03fd29872d4c"
}
}
]
}
23 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The design
In this chapter I'd like to talk about the design of the demo application we're gonna build. It's a mini CRM /
payroll system where the users can manage:
Departments
Employees
With fix salary
Or hourly rate
Time logs
It will create paychecks for each user based on their salary or hourly rate.
24 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
User stories
Name Description
Create a
Users can create a new department with a simple name and description.
department
Update a
Users can update an existing department.
department
List all
Users can list all departments.
departments
Show a Users can see the details of a department. We need to list all the employees in
department the department.
Create an
Users can create new employees.
employee
Update an
Users can update an existing employee.
employee
List all
Users can list all employees. They can be filtered.
employees
Show an Users can see the details of an employee. We need to list all the paychecks for an
employee employee.
Delete an Users can delete an employee. We don't want to loose important data like
employee paychecks or time logs.
Users can pay all the employees. We need to create paychecks for employees
Payday
with the right amount.
25 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Database design
department:
description: It holds information about a department inside a company
attributes:
- name
- description
relationships:
- employees: A department has many employees
- paychecks: A department has many paychecks through employees
employee:
description: It holds information about an employee and his / her salary
attributes:
- full_name
- email
- department_id
- job_title
- payment_type:
- salary
- hourly_rate
- salary: Only if payment_type is salary
- hourly_rate: Only if payment_type is hourly_rate
relationships:
- department: An employee belongs to a department
- paychecks: An employee has many paychecks
- time_logs: An employee has many time_logs
paycheck:
description: It holds information about an employee paycheck
attributes:
- employee_id
- net_amount
- payed_at
relationships:
- employee: A paycheck belongs to an employee
26 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
time_logs:
description: Time tracked by an employee
attributes:
- employee_id
- started_at
- stopped_at
- minutes
relationships:
- employee: A time_log belongs to an employee
We need to store amounts in the employee and in the paycheck tables. We can store them as float
numbers, but in this case maybe we face some rounding issues. You know neither PHP nor Javascript
(our client) is great with types and numbers. Or we can store them as cents in integer but expose them
as dollar value. In this case rounding is not an issue, but we have to convert the values somewhere.
As you can see in the time_logs table I used minutes instead of hours. This is the same as cents vs
dollars. I feel like if we store them as minutes like 90 instead of 1.5 we have less issues and more
flexibility later.
There are two payment types: salary and hourly rate. I feel like it's a good opportunity to use enums,
factories, and the strategy design pattern!
As you can see I follow the Laravel "standard" when it comes to dates. So instead of date I call the
column payed_at or started_at . Not a big deal but these names feel more natural.
27 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
API design
From the "user stories" we can easily plan our API:
POST /departments
PUT /departments
GET /departments
GET /departments/{department}
GET /departments/{department}/employees
POST /employees
PUT /employees
GET /employees
GET /employees/{employee}
DELETE /employees/{employee}
GET /employees/{employee}/payhecks
POST /payday
That's the bare minimum our application needs. Maybe there's gonna be more than that, but that's okay for
now. I use PUT for update endpoints by the way. Later I will discuss these endpoints in more detail, but I
guess you already have a broad idea about the application.
28 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Pest
In this book, I'm gonna use Pest which is an amazing testing framework for PHP.
A traditional API test in Laravel looks like this (it's not a complete test, just a short example):
/** @test !$
public function it_should_return_an_employee()
{
$employee = $this!#getJson(route('emplyoees'))!#json('data');
PHPUnit has assert functions like: assertEquals, assertEmpty and we can use them to test our values.
Pest takes a more functional approach and the same test looks like this:
expect($employee)
!#name!#toBe('John Doe')
!#payment!#type!#toBe('salary')
!#payment!#amount!#dollars!#toBe('$50,000');
});
As the first step, we wrap our value (it can be a scalar, an array, or an object) using the expect
function.
After that, we can access the properties (like name or type) from the original array as an object.
And we can use expectations like the toBe which checks that two values are the same.
29 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
expect($employee)
!#name!#toBe('John Doe');
The good thing about Pest is that you chain multiple things after each other:
So you can mix the attributes with the Pest expectations. I know it feels like magic, but it's really easy to use.
By the way, the implementation is very similar to Laravel's higher-order collections. Pest has a class called
HigherOrderExpectation. This class does the magic part. You don't have to worry about it but feel free to
check the source code.
By the way, we need to wrap every test in a function called it . This will tell Pest that it's a test and it runs it
when we say:
./vendor/bin/pest
Pest can do so much more than this. For example, later we'll use sequences to expect collections.
30 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Value Objects: they help us work with scalar data by wrapping them in an object. For example, we can
create a Money class that holds an integer value and helps us convert from cents to dollars and also
can format as a string.
DataTransferObjects (DTO): they will help us work with associative arrays. Instead of a big array where
you have undocumented, unknown array keys, you have a dead-simple object where each array key
becomes a type-hinted well-documented property.
Actions: we use them to express our user stories as stand-alone, reusable classes. They're not really
from the DDD, I think they were invented by the Laravel community but they are the successor of the
Service classes.
Key takeaways:
31 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The implementation
The repository for this project can be found here.
In this section we're gonna implement the payroll application step-by-step. But first, I want to introduce you
a very useful Laravel package called Blueprint.
Blueprint
You can define your tables in a yaml file, and it will create:
Migrations
Models
Factories
And even controllers with CRUD actions
models:
Department:
uuid: uuid
name: string:50
description: longtext
relationships:
hasMany: Employee
Employee:
uuid: uuid
full_name: string:100
email: string:100 index
department_id: id foreign
job_title: string:50
payment_type: string:20
salary: integer unsigned nullable
hourly_rate: integer unsigned nullable
relationships:
hasMany: Paycheck, Timelog
I don't want to list the whole file here you can see it in the repository, it's called draft.yaml .
32 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
API versioning
API versioning means two things:
/api/v1/employees
/api/v2/employees
/routes/api/v1.php
/routes/api/v2.php
In order make this possible we need to modify the boot method in the RouteServiceProvider :
$this!#routes(function () {
Route!'prefix('api/v1')
!#middleware(['api', 'auth:sanctum'])
!#group(base_path('routes/api/v1.php'));
Route!'prefix('api/v2')
!#middleware(['api', 'auth:sanctum'])
!#group(base_path('routes/api/v2.php'));
});
}
As you can see we can also apply middlewares to these routes so we don't need to write them in the route
files. That's it! Now we have API endpoints with versions.
One more thing: in an application where you only have API routes (like this demo app) you don't really need
the api prefix, but for some reason I like it...
33 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
UUIDs
As I mentioned earlier in my opinion is best practice to only expose UUIDs in your URLs. In order to do this
we need a UUID column in every table. There are a few things to do.
!" !!(
});
Once again:
use Ramsey\Uuid\Uuid;
trait HasUuid
{
public static function bootHasUuid(): void
{
static!'creating(function (Model $model): void {
$model!#uuid = Uuid!'uuid4()!#toString();
});
}
}
It registers a model event called creating. This will run every time when we run an insert query (so
creating a new model). It's important to note that this method will not run on update. And this is good. We
don't want to generate a new UUID every time we update a model.
The last thing is to use this trait and tell Eloquent that we want to bind our models with the uuid column:
34 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The getRouteKeyName method is used by Laravel when resolving a model instance from a route. For
example:
When Laravel sees the {employee} parameter in the route it executes a query similar to this:
But if you override the getRouteKeyName method and return uuid the query will look like this:
And that's exactly what we want. Of course if you want you can create an abstract class called Model or
BaseModel and override the getRouteKeyName method there.
By the way, I like to put every Model related trait, interface, or abstract class under the
App\Models\Concerns namespace.
35 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Configuring Pest
One last thing before we start writing code. There is a Pest.php file in the tests folder. In this file there's
a very important line:
uses(Tests\TestCase!'class, LazilyRefreshDatabase!'class)!#in('Feature');
TestCase is a class provided by Laravel (also lives in the tests folder). This class has a lot of helpers that
we can use with Laravel, most importantly:
LazilyRefreshDatabase also provided by Laravel and it will refresh the database before every test. It will:
If you want to learn more about this trait you should watch this short video from Laracasts.
When we write tests with Pest we don't actually write test classes. It's just a file with functions in it so this is
why we need to do these things in a separate Pest.php config file.
36 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Department API
Now let's start with the first API. That's gonna be the department API since this is the most simple and it
doesn't have any dependencies (like the Payday API requires both employees and departments).
Create a department
This endpoint will accept a name and a description attribute from the request. Only the name is
required.
Without further ado, let's write the first test in this amazing application:
expect($department)
!#attributes!#name!#toBe('Development')
!#attributes!#description!#toBe('Awesome developers across the board!');
});
As I said before it's not a class, it's a file with a bunch of it functions in it. The postJson is a helper
method provided by Laravel. It comes from the TestCase class and the pest-plugin-laravel makes it
possible to use it as a simple function. This is why we need to configure Pest to use the TestCase class in
the Pest.php file.
So the postJson will send a POST request and it will send the data as JSON. On the other hand, the -
>json('data') call is parsing the response. It will parse as JSON and it takes the data key from the
response. Don't forget that JSON API wraps everything into a data key.
After we send the request and parse the response we need to check the result. In this case, we simply check
the name and description attributes. Don't forget that JSON API wraps every attribute in a key called
attributes .
And as I showed you earlier you can chain attributes and Pest functions after each other.
37 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Now let's see the unhappy path. The name of the department is required and it needs to be unique. We can
cover this requirement with a test like this:
postJson(route('departments.store'), [
'name' !) $name,
'description' !) 'description',
])!#assertInvalid(['name']);
})!#with([
'',
null,
'Development'
]);
First, let's clarify the Pest specific parts. I chained the with function after the it , and I also gave a ?string
$name parameter to the callback function. The with method simply gets an element out of the array and
calls the callback with it. So under the hood, it does something like this:
postJson(route('departments.store'), [
'name' !) $name,
'description' !) 'description',
])!#assertInvalid(['name']);
};
I hope it makes it more clear. It's a very good way to run the same test with different values, in this case:
38 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The empty string and the null will fail because the name is required.
The string Development will fail because at the beginning of the test we create a department with this
very name. So it's already taken.
After the postJson we call the assertInvalid helper. This method comes from the TestResponse class
and is provided by Laravel. It's a great way to assert HTTP-specific things on the Response object.
assertInvalid is a recent addition to Laravel 8.x. If you have older Laravel you can use this:
$response!#assertStatus(Response!'HTTP_UNPROCESSABLE_ENTITY);
That's it! We covered the whole POST /departments API with tests. Before we move on I want to recap the
anatomy of a test:
Given
When
Then
The given describes our environment, the setup. What needs to be initialized or created to test this feature?
The when describes the action we want to trigger. What feature I'd like to test? And finally, the then shows
us what's the expected behavior. In our case, it looks like:
!" When
postJson(route('departments.store'), [
'name' !) $name,
'description' !) 'description',
])
!" Then
!#assertInvalid(['name']);
})!#with([
39 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
'',
null,
'Development'
]);
As you can see in the repository, I create a separate test file for each user story, so in this case:
CreateDepartmentTest
UpdateDepartmentTest
If you run these tests with ./vendor/bin/pest they will fail of course, so it's time to write some actual
code. Earlier I talked about DTOs, let's start with them:
namespace App\DataTransferObjects;
class DepartmentData
{
public function !*construct(
public readonly string $name,
public readonly ?string $description
) {}
}
It's a really simple class that holds data. All the data that necessary to create a new department. As we move
forward I try to explain why they are good.
40 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
namespace App\Actions;
use App\DataTransferObjects\DepartmentData;
use App\Models\Department;
class CreateDepartmentAction
{
public function execute(DepartmentData $departmentData): Department
{
return Department!'create([
'name' !) $departmentData!#name,
'description' !) $departmentData!#description,
]);
}
}
You can see that an action class is almost like a user story. A user can create a department, so we have a
class called CreateDepartmentAction . An action class always has an execute method. In this case, it gets
a DepartmetnData . This is the first advantage of using DTOs. Instead of an associative array, we have an
object that holds type-hinted properties. In the case of an array, you can only tell what keys it has if you dig
into the source code. I know that this is just a function with 3 lines of code, but imagine a 10 years old legacy
application where a function contains 300 (or 3000) lines of code. What would you have in that situation?
Random associative arrays or DTOs? I would go with DTOs.
41 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
return DepartmentResource!'make($department)
!#response()
!#status(Response!'HTTP_CREATED);
}
}
42 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
[
"name": "Development",
"description": "Awesome developers accross the board!",
]
The ... operator will spread the elements of this array and named arguments makes it possible to accept
them as separate arguments. So the $array['name'] will be passed as the name argument for the
constructor.
A quick summary:
You might think: "This is way more complicated than it should be. I'm out of here." Why not just do
something like this:
return DepartmentResource!'make($department)
!#response()
!#status(Response!'HTTP_CREATED);
}
}
In this particular example, it's easier to do it in the controller, yes. But don't forget that the department is a
dead-simple model and this is a demo application. In the real world models and user stories are way more
complicated than this. I mean way more complicated. Just to name a few extra steps:
43 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Dispatching jobs
Logging
A course
With 15 lessons
Each lesson has some content like questions, tests, images, videos, links
Each lesson has some deadline like Lesson #5 needs to be finished 10 days after a user started
the course
The course has an audience
It has a schedule like Lesson #1 will be published on 2022-03-01, Lesson #2 will be published 5
days after that
So we obviously don't want to implement everything in the Controller. The natural next step is to move
some logic in the models. After two weeks you will end up with a User model that contains 5000 lines of
code and has 30 use statements at the beginning of the class. I can guarantee it! Every project ends up
with a monster User model.
And when you work on a larger project it's always a good idea to keep things consistent. So if you're using
DTOs and Actions for 30 of your models, but writing queries in the controllers for 15 of your models, it may
not be the best approach.
The second important point that you may consider: writing the logic in the Controller is not reusable. So if
you write the store method as above, there's no way to implement an import for example. When the client
gives you a big XLS and you have to build the database from it. If you have actions, it's easy to do this.
You may also ask: why use the DTO, why not just pass the request to the action? Generally, I think that it's a
bad idea to pass around an object in your business logic layer that clearly belongs to the HTTP layer. Other
than that the DTO is independent of the context, so once again it can be used for an import. Think about it,
if we use the Request object in the Action, then how do we call it from the import Command?
So in this book, I will stick with Actions and DTOs. I consider them as best practices.
44 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Update a department
First, we write the tests:
putJson(route('departments.update', compact('department')), [
'name' !) $name,
'description' !) $description,
])!#assertNoContent();
expect(Department!'find($department!#id))
!#name!#toBe($name)
!#description!#toBe($description);
})!#with([
['name' !) 'Development', 'description' !) 'Updated Description'],
['name' !) 'Development New', 'description' !) 'Updated Description'],
]);
In the case of a PUT request, I usually don't have a response body, this is why I query the updated
department from the database. You can see that I specified two datasets for with . In the first one, the
name is the same as the original department's name that was created with the factory. This is important
because the name must be unique, but obviously, when it's an update we want to ignore the current model.
This test case covers that scenario. And I cover it with a test because it needs a special validation rule called
ignore :
45 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
If you don't add this to your validation you cannot update a department unless you change its name. Now
let's implement it. Our new action:
class UpdateDepartmentAction
{
public function execute(
Department $department,
DepartmentData $departmentData
): Department {
$department!#name = $departmentData!#name;
$department!#description = $departmentData!#description;
$department!#save();
return $department;
}
}
46 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
return DepartmentResource!'make($department)
!#response()
!#status(Response!'HTTP_CREATED);
}
return response()!#noContent();
}
}
Wait a minute... Everything is duplicated! You're right, so let's refactor it. We have tests for both create and
update, and we found duplicated code. Remember the refactor stage from the TDD chapter?
47 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
First, we can extract a method from the controller methods to remove duplication:
Important note: upsert is the only method I use to write in the controllers besides the standard CRUD
methods.
The trick here is that in the store I create an empty Department and pass it to upsert which will pass it to
the action:
48 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
class UpsertDepartmentAction
{
public function execute(
Department $department,
DepartmentData $departmentData
): Department {
$department!#name = $departmentData!#name;
$department!#description = $departmentData!#description;
$department!#save();
return $department;
}
}
In the action, we use $model->save() so if it exists Eloquent will run an update query, if it doesn't exist it
will run an insert query. Another option would be to use updateOrCreate which looks like:
class UpsertDepartmentAction
{
public function execute(
Department $department,
DepartmentData $departmentData
): Department {
return Department!'updateOrCreate(
[
'id' !) $department!#id,
],
[
'name' !) $departmentData!#name,
'description' !) $departmentData!#description,
],
);
}
}
49 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Get departments
We need to implement two endoints:
GET /api/v1/departments
GET /api/v1/departments/{department}
50 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Resources
So far we haven't talked about API resources. Earlier, I told you that there's a great package that helps us
implement JSON API. It's called timacdonald/json-api and the DepartmentResource looks like this:
use TiMacDonald\JsonApi\JsonApiResource;
51 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
{
"data": {
"id": "e12cea58-d2ec-495a-a542-03fd29872d4c",
"type": "departments",
"attributes": {
"name": "Development",
"description": "Illum accusamus cumque aut modi."
},
"relationships": {},
"meta": {},
"links": {
"self": "http:!"localhost:8000/api/v1/departments/e12cea58-d2ec-495a-
a542-03fd29872d4c"
}
},
"included": []
}
And as you can see I need to extend the JsonApiResource provided by this package instead of Laravel's
JsonResource .
Important note: In the toRelationships method we need to use callbacks. That's because it's lazily
evaluated. This package handles the include query parameter from the URL. So if the URL looks like this:
GET /departments/e12cea58-d2ec-495a-a542-03fd29872d4c?include=employees
Then it will execute the callback and loads all the employee models. But if the URL looks like this:
GET /departments/e12cea58-d2ec-495a-a542-03fd29872d4c
52 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Then it will not execute the callback and employees won't be loaded. This is similar to the whenLoaded
function from Laravel's resource class.
53 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Employee API
Payment types
Now we're gonna move on to employees. Before writing the API we need to handle payment types. It's
either salary or hourly rate:
Salary means that we store the yearly salary for an employee, and when it's payday, the application will
create a paycheck with amount = salary / 12.
Hourly rate means that we store the hourly rate of an employee and when it's payday, the app will sum
up the time logs for the current month and create a paycheck with amount = hours worked * hourly
rate.
PHP8.1 enums
Factories
Strategy design pattern
First, I will discuss these concepts one by one, but after that, I put everything together, and hopefully it's
gonna be clear. Let's start with the enum:
namespace App\Enums;
This is a backed enum with the type of string. As you can see each case has a string value. This is good
because it can be used in validations for example, and we also want to store these values for
employees.payment_type column (later on that).
$type = PaymentTypes!'from('salary');
$type!#value !!% 'salary';
The from is a factory function provided by PHP. It can be used to create an enum from a scalar value, and
it's possible because the PaymentTypes is a backed enum with string. The value property will return the
value you specify after a case , so salary or hourlyRate in this case.
54 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
'paymentType' !) [
'required',
new Enum(PaymentTypes!'class),
],
This validation will only accept salary or hourlyRate in the paymentType key.
Moving on to the strategy pattern. The employees table has a column called payment_type , each payment
type will require different logic when paying an employee, right? Typically you can write something like this:
And everywhere in the application where you want to do something based on the payment type, you must
check these values. Now imagine a bigger, more complex enterprise application where maybe you have:
These statuses, types can easily become a nightmare and cause tons of if-else statements. And oftentimes
these if statements will nest like hell because a pending debit invoice needs different logic than a paid debit
invoice. Fortunately, the strategy pattern can help in these situations.
Instead of writing if statements for the different values, we can create a separate class for each one:
55 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
56 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
There is a base class that defines the common interface. In this case, each payment type has a
monthlyAmount method that will return the amount that needs to be paid for the employee. The Salary
subclass will calculate it from the yearly salary, while the HourlyRate subclass will work with the time logs.
After this refactor this is how the payEmployee method looks like:
Now, you don't have to use if-else statements anymore! You have a separate class for each type:
The strategy pattern brings one more important benefit. If you need to handle more payment types, you
don't have to change your code in multiple places. The only thing you need to do is to add a new class! For
example:
57 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
And you don't need to change the payEmployee method or any other function where you care about
payment types.
In the examples above I assumed that $employee->payment_type is not a string anymore, but a
PaymentType instance. You can achieve this by using an attribute accessor in the Employee model:
Please note that the accessor has the same name as the database column, payment_type , so I need to use
the $this->original['payment_type'] . I cannot use $this->payment_tyoe because it will call the
accessor again and it's an infinite loop. I make use of the enum here, and there's a new method called
makePaymentType . This is a factory inside the enum that will create either a Salary or HourlyRate
instance based on the employee:
58 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
As you can see an enum can be used in a match statement. It basically behaves like a factory method. So, by
using
Enums
Strategy
Factory
These are important rules so it's a good idea to cover them with tests:
59 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
postJson(route('employees.store'), [
'fullName' !) 'Test Employee',
'email' !) $email,
'departmentId' !) Department!'factory()!#create()!#uuid,
'jobTitle' !) 'BE Developer',
'paymentType' !) 'salary',
'salary' !) 75000 * 100,
])!#assertInvalid();
})!#with([
'[email protected]',
'invalid',
null,
'',
]);
60 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
'departmentId' !) Department!'factory()!#create()!#uuid,
'jobTitle' !) 'BE Developer',
'paymentType' !) $paymentType,
'salary' !) $salary,
'hourlyRate' !) $hourlyRate,
])!#assertInvalid();
})!#with([
['paymentType' !) 'salary', 'salary' !) null, 'hourlyRate' !) 30 * 100],
['paymentType' !) 'salary', 'salary' !) 0, 'hourlyRate' !) null],
['paymentType' !) 'hourlyRate', 'salary' !) 75000 * 100, 'hourlyRate' !)
null],
['paymentType' !) 'hourlyRate', 'salary' !) null, 'hourlyRate' !) 0],
]);
The API expects UUIDs, this is why the departmentId key contains UUID.
The API expects the amounts in cents. For readability I used 75000 * 100 which means $75000.
These tests take advantage of Pest's with the same we used them before.
So this is the 'unhappy' path. To make these tests pass we only need a request with the validation rules and
an empty Controller that uses this request.
61 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
We'll talk about the salary and hourlyRate validation later. As earlier I used the name upsert because I
know that it's gonna be used in both the POST and PUT API. Some important notes:
After that, the validation tests will pass, so it's time to cover the 'happy' path:
62 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
expect($employee)
!#attributes!#fullName!#toBe('John Doe')
!#attributes!#email!#toBe('[email protected]')
!#attributes!#jobTitle!#toBe('BE Developer')
!#attributes!#payment!#type!#toBe('salary')
!#attributes!#payment!#amount!#cents!#toBe(75000 * 100)
!#attributes!#payment!#amount!#dollars!#toBe('$75,000.00');
});
expect($employee)
!#attributes!#fullName!#toBe('John Doe')
!#attributes!#email!#toBe('[email protected]')
!#attributes!#jobTitle!#toBe('BE Developer')
!#attributes!#payment!#type!#toBe('hourlyRate')
!#attributes!#payment!#amount!#cents!#toBe(30 * 100)
!#attributes!#payment!#amount!#dollars!#toBe('$30.00');
63 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
});
Two tests for two use-cases: creating an employee with salary, and creating an employee with an hourly
rate. Later I'll show you the resources, but as you can see it has a payment object that looks like this:
{
"payment": {
"type": "hourlyRate",
"amount": {
"cents": 3000,
"dollars": "$30.00"
}
}
}
A nice, nested object that contains both the raw data and the formatted as well.
We'll use DTOs just as before, but the EmployeeData is a little bit more complicated than the
DepartmentData was:
class EmployeeData
{
public function !*construct(
public readonly string $fullName,
public readonly string $email,
public readonly Department $department,
public readonly string $jobTitle,
public readonly string $paymentType,
public readonly ?int $salary,
public readonly ?int $hourlyRate,
) {}
64 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
$request!#jobTitle,
$request!#paymentType,
$request!#salary,
$request!#hourlyRate,
);
}
}
If you remember the DepartmentData only has a constructor and is instantiated by:
new DepartmentData(!!($request!#validated());
However, the EmployeeData contains some nullable fields and it also has a department. In this case, it's
much easier to write a factory function that takes a request. This is where we use the getDepartment from
the request. I consider it best practice to use models in a DTO instead of IDs. In this case, I used a
Department instance instead of an ID.
65 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The only difference, in this case, the upsert method uses the fromRequest method from the DTO to
create a new instance.
66 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
class UpsertEmployeeAction
{
/**
* @throws ValidationException
!$
public function execute(
Employee $employee,
EmployeeData $employeeData
): Employee {
$this!#validate($employeeData);
$employee!#full_name = $employeeData!#fullName;
$employee!#email = $employeeData!#email;
$employee!#department_id = $employeeData!#department!#id;
$employee!#job_title = $employeeData!#jobTitle;
$employee!#payment_type = $employeeData!#paymentType;
$employee!#salary = $employeeData!#salary;
$employee!#hourly_rate = $employeeData!#hourlyRate;
$employee!#save();
return $employee;
}
67 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
/**
* @throws ValidationException
!$
private function validate(EmployeeData $employeeData): void
{
$rules = [
$employeeData!#paymentType !) [
'required',
'numeric',
Rule!'notIn([0]),
],
];
$validator = Validator!'make([
'paymentType' !) $employeeData!#paymentType,
'salary' !) $employeeData!#salary,
'hourlyRate' !) $employeeData!#hourlyRate,
], $rules);
$validator!#validate();
}
I decided to validate the salary and hourlyRate by hand instead of using the request. The reasons:
68 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Get employees
This endpoint will return all the employees, and we also want some filtering based on:
Full name
Job title
Email
Department's name
# Returns employees where the full_name contains "freek" AND the job_title
contains "developer"
GET /api/v1/employees?filter[full_name]=freek&filter[job_title]=developer
return EmployeeResource!'collection($employees);
}
69 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
allowedFilters : this array defines what columns can be used in the request URL under the filter
key.
allowedIncludes : this array defines what relationships can be included by the client.
And that's it! We have a fully functioning filter in our API. This package can do much more so check out the
documentation!
GET /api/v1/employees?filter[department_id]=e12cea58-d2ec-495a-a542-
03fd29872d4c
GET /api/v1/departments/e12cea58-d2ec-495a-a542-03fd29872d4c/employees
I almost always choose the second one here. There are a few reasons:
It's a "first-class citizen". This is a separate endpoint, with its own controller, which is in my opinion
always a better option than having a (probably) undocumented filter in some endpoint.
It feels more like a REST API and just looks better for me.
GET /api/v1/departments/e12cea58-d2ec-495a-a542-03fd29872d4c/employees?
filter[full_name]=john
Route!'get(
'departments/{department}/employees',
[DepartmentEmployeeController!'class, 'index']
)!#name('department-employees.index');
70 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
$developers = Employee!'factory([
'department_id' !) $development!#id,
])!#count(5)!#create();
Employee!'factory([
'department_id' !) $marketing!#id,
])!#count(2)!#create();
expect($employees)!#toHaveCount(5);
expect($employees)
!#each(fn ($employee) !) $employee!#id!#toBeIn($developers-
>pluck('uuid')));
});
Step by step:
With these two functions, we can check that all employees from the response are developers.
71 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Employee!'factory([
'department_id' !) $development!#id,
])!#count(4)!#create();
$developer = Employee!'factory([
'full_name' !) 'Test John Doe',
'department_id' !) $development!#id,
])!#create();
Employee!'factory([
'department_id' !) $marketing!#id,
])!#count(2)!#create();
$employees = getJson(
route('department-employees.index', [
'department' !) $development,
'filter' !) [
'full_name' !) 'Test',
]
]))
!#json('data');
expect($employees)!#toHaveCount(1);
expect($employees[0])!#id!#toBe($developer!#uuid);
});
The setup is very similar, but here we have the $developer , which contains the employee we will search
for.
When I have nested resources I almost always create separate controllers. In this case, it's the
DepartmentEmployeeController :
72 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
return EmployeeResource!'collection($employees);
}
}
As you can see we can chain any other Eloquent method to the Spatie query builder. In this case, I used the
whereBelongsTo which is the same as:
!#where('department_id', $department!#id);
By the way, in most cases, I don't try to generalize these query builder configs and I almost always write
them in the controllers. That's because it's unique for this particular endpoint. But if you find yourself
writing the filters over and over again, maybe you should create an allowedFilters property or function
for your models.
73 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
{
"payment": {
"type": "hourlyRate",
"amount": {
"cents": 3000,
"dollars": "$30.00"
}
}
}
We also need this structure for the paychecks later. So it's a good idea to create an object that holds money
values:
74 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
namespace App\ValueObjects;
class Money
{
public function !*construct(private readonly int $valueInCents)
{
}
This is a value object I talked about earlier. As you can see it's really just a class that holds some value. It
holds an integer value (the amount in cents) and can format it as dollars value and as cents value. That's it.
75 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
The benefits:
Single source of truth: If you need to format the values differently you only need to change one class.
Cents and dollars values live together in one class. So cohesive data lives together. That usually makes
most applications easier to maintain.
It's type hinted.
76 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
But we know that a paycheck will contain the same data structure, so we can create an Amount value
object:
class Amount
{
public function !*construct(
private readonly Money $cents,
private readonly Money $dollars
) {}
/**
* @return array{cents: int, dollars: string}
!$
public function toArray(): array
{
return [
'cents' !) $this!#cents!#toCents(),
'dollars' !) $this!#dollars!#toDollars(),
];
}
}
This class does not contain any real logic, it just holds two Money objects. One for the cents value and one
for the dollars value.
77 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
78 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Payday API
Create paychecks
The payday API will create the paychecks for employees based on their salary or hourly rate. We can start
with some tests:
postJson(route('payday.store'))
!#assertNoContent();
$this!#assertDatabaseHas('paychecks', [
'employee_id' !) $employees[0]!#id,
'net_amount' !) 416666,
]);
$this!#assertDatabaseHas('paychecks', [
'employee_id' !) $employees[1]!#id,
'net_amount' !) 583333,
]);
});
79 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
This test makes sure that the API will create a paycheck for employees with yearly salaries:
It creates two employees. I used the sequence method from Factory . It will create 2 employees with
the given attributes.
After that, it checks that there are two records in the paychecks table with the right amount.
The salary column in the employees table contains yearly salary, like 50000.
The net_amount column in the paychecks table contains the monthly amount.
In the first case we expect 50000 / 12 so $4166.66, in the second case we expect 70000 / 12 so
$5833.33
It's really easy to implement this feature. If you remember we have a Salary class that has a
monthlyAmount method. This method will return the monthly amount for a salaried employee, so yearly
salary / 12:
80 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
parent!'!*construct($employee);
}
81 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
class PaydayAction
{
public function execute(): void
{
foreach (Employee!'all() as $employee) {
$employee!#paychecks()!#create([
'net_amount' !) $employee!#payment_type!#monthlyAmount(),
'payed_at' !) now(),
]);
}
}
}
It goes through all employees, calculates the monthly amount from the PaymentType class and creates a
paycheck with the current date. This class makes the tests pass. As you can see this action (and also the API)
will create paychecks for the current month.
The test for employees with hourly rate is a little bit more complicated:
82 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
$dayBeforeYesterday = now()!#subDays(2);
$yesterday = now()!#subDay();
$today = now();
Timelog!'factory()
!#count(3)
!#sequence(
[
'employee_id' !) $employee,
'minutes' !) 90,
'started_at' !) $dayBeforeYesterday,
'stopped_at' !) $dayBeforeYesterday!#copy()!#addMinutes(90)
],
[
'employee_id' !) $employee,
'minutes' !) 15,
'started_at' !) $yesterday,
'stopped_at' !) $yesterday!#copy()!#addMinutes(15)
],
[
'employee_id' !) $employee,
'minutes' !) 51,
'started_at' !) $today,
'stopped_at' !) $today!#copy()!#addMinutes(51)
],
)
!#create();
postJson(route('payday.store'))
83 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
!#assertNoContent();
$this!#assertDatabaseHas('paychecks', [
'employee_id' !) $employee!#id,
'net_amount' !) 30 * 100,
]);
});
});
When paying employees with an hourly rate, we need to query time logs that were created in the current
month. In order to ensure that all time logs are created in the same month, we need to time travel.
This line will "lock" the time to 2022-01-10. So if I call now() in the closure, it will return 2022-02-10. Under
the hood, this function uses:
Carbon!'setTestNow('2022-02-10');
This way, I can create time_logs for February and when I call the API (which uses now() in the Action) it
will query time_logs from February.
So in the test, I create three time_logs that add up to 156 minutes which is 2.6 hours. When paying
employees with an hourly rate we need to round these numbers, so instead of 2.6 hours, we create a
paycheck for 3 hours.
One more caveat: I use $today->copy()->addMinutes() because addMinutes will mutate the original
Carbon object, so if I don't add copy it will add minutes to the started_at date as well.
84 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
parent!'!*construct($employee);
}
85 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
It queries every Timelog entry where the stopped_at date is in the current month.
It sums the value of minutes .
This function will make the test passes. There is another test case:
it('should create paychecks for hourly rate employees only for current
month', function () {
$this!#travelTo(Carbon!'parse('2022-02-10'), function () {
$employee = Employee!'factory([
'hourly_rate' !) 10 * 100,
'payment_type' !) PaymentTypes!'HOURLY_RATE!#value,
])!#create();
Timelog!'factory()
!#count(2)
!#sequence(
[
'employee_id' !) $employee,
'minutes' !) 60,
'started_at' !) now()!#subMonth(),
'stopped_at' !) now()!#subMonth()!#addMinutes(60)
],
[
'employee_id' !) $employee,
'minutes' !) 60,
'started_at' !) now(),
'stopped_at' !) now()!#addMinutes(60)
],
)
!#create();
postJson(route('payday.store'))
!#assertNoContent();
$this!#assertDatabaseHas('paychecks', [
'employee_id' !) $employee!#id,
'net_amount' !) 10 * 100,
86 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
]);
});
});
This test makes sure that we only create paychecks for the current month. The first Timelog is due at the
previous month, so after calling the payday endpoint we want to see a paycheck for 1 hour, not 2.
Also, there's an edge-case when it comes to paying hourly rate employees. It's possible that an employee
has no time logs for the current month. Right now, the PaydayAction will create a paycheck with
net_amount 0 in this case. So let's fix this.
it('should not create paychecks for hourly rate employees without time logs',
function () {
$this!#travelTo(Carbon!'parse('2022-02-10'), function () {
Employee!'factory([
'hourly_rate' !) 10 * 100,
'payment_type' !) PaymentTypes!'HOURLY_RATE!#value,
])!#create();
postJson(route('payday.store'))
!#assertNoContent();
$this!#assertDatabaseCount('paychecks', 0);
});
});
87 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
There are no time logs so we expect the paychecks table to be empty. Of course, it will fail, we need to
handle this in the PaydayAction :
class PaydayAction
{
public function execute(): void
{
foreach (Employee!'all() as $employee) {
$amount = $employee!#payment_type!#monthlyAmount();
if ($amount !!% 0) {
continue;
}
$employee!#paychecks()!#create([
'net_amount' !) $amount,
'payed_at' !) now(),
]);
}
}
}
The last we can add to this function is transactions. In my opinion, it's important, because this method will
create hundreds or thousands of paychecks, so if something goes wrong it's a good idea to rollback the
database:
88 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
class PaydayAction
{
public function execute(): void
{
DB!'transaction(function () {
foreach (Employee!'all() as $employee) {
$amount = $employee!#payment_type!#monthlyAmount();
if ($amount !!% 0) {
continue;
}
$employee!#paychecks()!#create([
'net_amount' !) $amount,
'payed_at' !) now(),
]);
}
});
}
}
Laravel makes it easy by using the DB::transaction . If an exception is thrown in the callback it will
rollback the database. If everything goes well it will commit .
This is one of the main advantages of using the strategy pattern: the 'client' code remains really simple. No
complicated if else statement! This small function handles both salaried and hourly rate employees. If I
need to add employees with a commission, for example, I don't even need to touch it! I just add a new
PaymentType class and a new case to the enum.
89 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
GET /api/v1/employees/a3b5ce95-c9c8-40f7-b8d7-06133c768a92/paychecks
90 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
91 / 92
Martin Joo - Test-Driven APIs with Laravel and Pest
Thank you
Thank you very much for reading my book! I hope you have learned a few cool techniques.
By the way, I’m Martin Joo, a software engineer since 2012. I’m passionate about Domain-Driven Design,
Test-Driven Development, Laravel, and beautiful code in general.
You can find me on Twitter @mmartin_joo where I post tips and tricks about PHP, Laravel every day. If you
have questions just send me a DM on Twitter.
92 / 92