Everything You Can Test In Your Laravel Application
#testing #laravelA common challenge in testing is not only HOW to test something, but WHAT you can test. That's why I have made a list of all the things I like to test in my applications.
This article is about the How
and What
to test in Laravel. If you want to learn about the Why
you should write tests, check out my 3 Compelling Reasons For Developers To Write Tests article.
All test examples focus on testing concepts and can be applied to all testing frameworks. My examples are written with PEST.
Looking for something specific? Pick it:
- Testing Page Response Status
- Testing Page Response Text
- Testing Page Response View
- Testing Page Response JSON
- Testing Against The Database
- Testing Validation
- Testing Models / Relationships
- Testing Mails
- Testing Mail Content
- Testing Jobs & Queues
- Testing Notifications
- Testing Actions
- Testing Commands
- Testing Middlewares
- Testing Cache
- Testing File Uploads
- Testing Exceptions
- Testing Units (Unit Tests)
- Faking HTTP Calls
- Testing HTTP Calls
- Mocking Dependencies
- Architecture Testing
Testing Page Response Status
Testing page response is one of the simplest tests to write; still, it is extremely useful.
It makes sure a page responds with the correct HTTP status code, primarily a 200 response.
it('gives back a successful response for home page', function () {
$this->get('/')->assertOk();
});
It's a straightforward test, but knowing your home page does not throw an error is crucial. So, if you want to write your first test, this is the one to start with.
Testing Page Response Text
This test is similar to the first page response test. We also test the response, but this time we are interested in the content of the response.
it('lists products', function () {
// Arrange
$firstProduct = Product::factory()->create();
$secondProduct = Product::factory()->create();
// Act & Assert
$this->get('/')
->assertOk()
->assertSeeTextInOrder([
$firstProduct->title,
$secondProduct->title,
]);
});
Here we are ensuring we see our product titles on the home page. This is useful if you load the products from the database and ensure they are shown.
Here you also can be more specific, like when you
only want to show released products
.
it('lists released products', function () {
// Arrange
$releasedProduct = Product::factory()
->released()
->create();
$draftProduct = Product::factory()
->create();
// Act & Assert
$this->get('/')
->assertOk()
->assertSeeText($releasedProduct->title)
->assertDontSeeText($draftProduct->title);
});
It also demos how to test something that is not shown, which can be helpful too. This test wouldn't be that helpful if we only have static text on the home page.
Testing Page Response View
Next to testing the response status and content, you can also test the view that is returned.
it('returns correct view', function() {
// Act & Assert
$this->get('/')
->assertOk()
->assertViewIs('home');
});
You can take this even further and test the data that is passed to the view.
it('returns correct view', function() {
// Act & Assert
$this->get('/')
->assertOk()
->assertViewIs('home')
->assertViewHas('products');
});
Testing Page Response JSON
Often you want to return JSON data from your API.
This is where you can use Laravel's JSON helpers,
like the assertJson
method.
it('returns all products as JSON', function () {
// Arrange
$product = Product::factory()->create();
$anotherProduct = Product::factory()->create();
// Act & Assert
$this->post('api/products')
->assertOk()
->assertJson([
[
'title' => $product->title,
'description' => $product->description,
],
[
'title' => $anotherProduct->title,
'description' => $anotherProduct->description,
],
]);
});
Testing Against The Database
Since we store data in the database, we want to make sure that data is stored correctly. This is where Laravel can help you with some handy assertion helpers.
it('stores a product', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'title' => 'Product name',
'description' => 'Product description',
])->assertSuccessful();
// Assert
$this->assertDatabaseCount(Product::class, 1);
$this->assertDatabaseHas(Product::class, [
'title' => 'Product name',
'description' => 'Product description',
]);
});
The example ensures that a product is created and stored in the database for our post route.
Testing Validation
Validation is a crucial part of many applications.
You want to make sure that only valid data can be
submitted. By default, Laravel sends validation
errors back to the user, which we can check with
the assertInvalid
method.
it('requires the title', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'description' => 'Product description',
])->assertInvalid(['title' => 'required']);
});
it('requires the description', function () {
// Act
$this->actingAs(User::factory()->create())
->post('product', [
'title' => 'Product name',
])->assertInvalid(['description' => 'required']);
});
When dealing with many validation rules, using datasets can be pretty helpful. This can clean up your tests a lot.
it('requires title and description tested with a dataset', function($data, $error) {
// Act
$this->actingAs(User::factory()->create())
->post('product', $data)->assertInvalid($error);
})->with([
'title required' => [['description' => 'text'], ['title' => 'required']],
'description required' => [['title' => 'Title'], ['description' => 'required']],
]);
Testing Models / Relationships
First, I like to test every relationship of a
model. To be precise, we do not
want to test the
functionality of the relationship; this is what
Laravel already does. We want to make sure that
relationships are defined.
it('has products', function () {
// Arrange
$user = User::factory()
->has(Product::factory())
->create();
// Act
$products = $user->products;
// Assert
expect($products)
->toBeInstanceOf(Collection::class)
->first()->toBeInstanceOf(Product::class);
});
Developers have different opinions about what logic should be handled in a model, and that's ok. But if you add it, make sure also to test it.
it('only returns released courses for query scope', function () {
// Arrange
Course::factory()->released()->create();
Course::factory()->create();
// Act & Assert
expect(Course::released()->get())
->toHaveCount(1)
->first()->id->toEqual(1);
});
Another example would be a model accessor like:
protected function firstName(): Attribute
{
return Attribute::make(
get: fn (string $value) => ucfirst($value),
);
}
And the test would look like this:
it('capitalizes the first character of the first name', function () {
// Arrange
$user = User::factory()->create(['first_name' => 'christoph'])
// Act & Assert
expect($user->first_name)
->toBe('Christoph');
});
Testing Sending Mails
Laravel provides a lot of testing helpers, especially when using Facades.
class PublishPodcastController extends Controller
{
public function __invoke(Podcast $podcast)
{
// publish podcast
// ...
Mail::to($podcast->author)->send(new PodcastPublishedMail());
}
}
At the end of this controller, we are sending an
email. In our test, we can hit the controller
through an endpoint and make sure this
email would have been
sent.
it('sends email to podcast author', function() {
// Arrange
Mail::fake();
$podcast = Podcast::factory()->create();
// Act
$this->post(route('publish-podcast', $podcast));
// Assert
Mail::assertSent(PodcastPublishedMail::class);
});
Always run the Mail::fake()
method at the
beginning of your tests when testing emails. This
makes sure no actual email is being sent to a
user.
Most helper methods like assertSent
also accept
a callback as the second argument. In our case, it
receives the mailable object. It contains all the
email data, like the email to which it needs to be
sent.
This allows you to make even more assertions, like about the `to-address´ of the email.
Mail::assertSent(PodcastPublishedMail::class, function(PodcastPublishedMail $mail) use ($podcast) {
return $mail->hasTo($podcast->author->email);
});
Testing Mail Content
It also makes sense to test the content of an email. This is especially useful when you have a lot of emails in your application. You want to make sure that the content is correct.
it('contains the product title', function () {
// Arrange
$product = Product::factory()->make();
// Act
$mail = new PaymentSuccessfulMail($product);
// Assert
expect($mail)
->assertHasSubject('Your payment was successful')
->assertSeeInHtml($product->title);
});
Testing Jobs & Queues
I like to test jobs and queues separately, starting from the outside. This means I test a job is being pushed to a queue.
it('dispatches an import products job', function () {
// Arrange
Queue::fake();
// Act
$this->post('import');
// Assert
Queue::assertPushed(ImportProductsJob::class);
});
This ensures that my job will be pushed to the
queue for a specific trigger, like hitting an
endpoint. Again, the Queue::fake()
takes care of
not pushing a job. We do not want to run the job
at this point.
But we still have to test the job, right? Of course. It contains the crucial logic of this feature:
it('imports products', function() {
// Act
(new ImportProductsJob)->handle();
// Assert
$this->assertDatabaseCount(Product::class, 50);
// Make more assertions about the imported data
})
This new test concentrates on the job and what it
should do. We trigger the job directly by calling
the handle
on it, which every job has.
Testing Notifications
Notifications are great for informing your users about important events. Make sure to test them too:
it('sends notification about new product', function () {
// Arrange
Notification::fake();
$user = User::factory()->create();
$product = Product::factory()->create();
// Act
$this->artisan(InformAboutNewProductCommand::class, [
'productId' => $product->id,
'userId' => $user->id,
]);
// Assert
Notification::assertSentTo(
[$user], NewProductNotification::class
);
});
In the example above, we test a
notification sent to a user when a new
product is created. We are using the artisan
method to trigger the notification. This is a
great way to test notifications triggered
by a command.
Again, there is a fake
method for the notification
facade that makes sure no actual notification is
being sent.
Testing Actions
Actions are just simple classes that have one specific job. They are a great way to organize your code, and separate your logic from your controllers to keep them clean. But how do you test them?
Let's start again from the outside. First, we want to test that our action is called when hitting a specific endpoint.
it('calls add-product-to-user action', function () {
// Assert
$this->mock(AddProductToUserAction::class)
->shouldReceive('handle')
->atLeast()->once();
// Arrange
$product = Product::factory()->create();
$user = User::factory()->create();
// Act
$this->post("purchase/$user->id/$product->i");
});
We can do this by mocking our action class and expecting that the handle method is called. But, again, we are here not interested in what our action does; we want to make sure it is called when we hit our purchase controller.
To make this work, we must ensure that the container resolves our action.
class PurchaseController extends Controller
{
public function __invoke(User $user, Product $product): void
{
app(AddProductToUserAction::class->handle($user, $product);
// Send purchase success email, etc.
}
}
Then we can also test the action itself. Like a
job, we call the handle
method to trigger the
action.
it('adds product to user', function () {
// Arrange
$product = Product::factory()->create();
$user = User::factory()->create();
// Act
(new AddProductToUserAction())->handle($user, $product);
// Assert
expect($user->products)
->toHaveCount(1)
->first()->id->toEqual($product->id);
});
Testing Commands
Commands, similar to actions or jobs, can be tested by triggering them directly. This is a great way to test the logic of your command.
it('merges two accounts', function () {
// Arrange
$user = User::factory()->create();
$userToBeMerged = User::factory()->create();
// Act
$this->artisan(MergeAccountsCommand::class, [
'userId' => $user->id,
'userToBeMergedId' => $userToBeMerged->id,
]);
// Assert
$this->assertDatabaseCount(User::class, 1);
$this->assertDatabaseHas(User::class, [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
]);
});
Most interesting is the artisan
method, which we can call in any Laravel test. It will trigger the command and run it. We can pass the arguments and options to the command as an array. The rest is very similar to other tests.
But there are some more things we can do with commands. We can also expect output of a command like questions or information messages.
it('asks for user ids', function() {
// Arrange
$user = User::factory()->create();
$userToBeMerged = User::factory()->create();
// Act & Assert
$this->artisan(MergeAccountsCommand::class)
->expectsQuestion('Please provide the user ID of the user you want to keep', $user->id)
->expectsQuestion('Please provide the user ID of the user you want to merge', $userToBeMerged->id)
->expectsOutput('Accounts merged successfully')
->assertSuccessful();
});
As you can see in the example above, this is very useful for testing commands that ask for user input. We can also expect output and make sure the command was successful, which means an exit code of 0.
Testing Middlewares
A Middleware is always connected to a request, so I like to test the whole request instead of the isolated middleware.
In our example, we have an archive page
and first test the page's content.
it('shows archived products', function () {
// Arrange
$product = Product::factory()->create();
$archivedProduct = Product::factory()->archived()->create();
// Act & Assert
$this->get(action(PageArchiveController::class))
->assertSeeText($archivedProduct->title)
->assertDontSeeText($product->title);
});
But we also attached a middleware to this route to check if the archive feature is enabled
. If not, we will return a 404.
it('returns 404 when archive feature disabled', function() {
// Arrange
Feature::define('archive', false);
// Act & Assert
$this->get(action(PageArchiveController::class))
->assertNotFound();
});
As you can see, we do not test the middleware in isolation but the whole request and desired outcome.
Testing Cache
Caching is a crucial part of every application. But how do we test it? Let's take a look at this simple controller:
class CachedProductsApiController extends Controller
{
public function __invoke()
{
return Cache::remember('products', 60, function () {
return Product::all();
});
}
}
It uses the remember
method of the Cache
facade to cache the products for 60 minutes. And here is how we can test it:
it('calls cache remember method', function () {
// Assert
Cache::shouldReceive('remember')
->once()
->with('products', 60, Closure::class)
->andReturn(Product::all());
// Act
$this->post(action(CachedProductsApiController::class));
});
Similar to custom mocks, we can use the shouldReceive
method of the Cache
facade to mock the remember
method. We can also make sure it is called with the correct parameters and returns the expected result.
Testing File Uploads
Similar to testing mails, notifications, or jobs, we can also test file uploads with the help of a facade.
it('uploads CSV file', function () {
// Arrange
Storage::fake('uploads');
$file = UploadedFile::fake()->image('statistics.csv');
// Act
$this->post(action(CsvUploadController::class), [
'file' => $file,
])->assertOk();
// Assert
Storage::disk('uploads')->assertExists($file->hashName());
});
The fake
method of the Storage
facade makes sure we do not store any files on our real storage disks. And the UploadedFile
class helps us to create test files.
The storage facade also provides handy assertion helpers like assertExists
, to make sure the file was uploaded.
Testing Exceptions
Sometimes it is a good thing when an exception is thrown because we intentionally want to stop the execution of our code. We can test that too.
it('stops if at least one account not found', function () {
// Act
$this->artisan(MergeAccountsCommand::class, [
'userId' => 1,
'userToBeMergedId' => 2,
]);
})->throws(ModelNotFoundException::class);
We can chain the throws
method to our test in
PEST. This will make sure that the exception is
thrown.
Testing Units (Unit Tests)
Unit tests are great for testing small pieces of code, like a single method. No other dependencies are involved. This makes them very fast and easy to write.
Our example is about a data object. It contains a method that creates a new instance from an a webhook payload.
class UserData
{
public function __construct(
public string $email,
public string $name,
public string $country,
)
{}
public static function fromWebhookPayload(array $webhookCallData): UserData
{
return new self(
$webhookCallData['client_email'],
$webhookCallData['client_name'],
$webhookCallData['client_country'],
);
}
}
In the corresponding test, we only test what this method returns.
it('creates UserData object from paddle webhook call', function () {
// Arrange
$payload = [
'client_email' => 'test@test.at',
'client_name' => 'Christoph Rumpel',
'client_country' => 'AT',
];
// Act
$userData = UserData::fromWebhookPayload($payload);
// Assert
expect($userData)
->email->toBe('test@test.at')
->name->toBe('Christoph Rumpel')
->country->toBe('AT');
});
Faking HTTP Calls
Sometimes you need to make HTTP calls in your application. This could be to fetch data from an external API or to send data to another service. You often want to fake these calls in your tests so you do not have to rely on an external service.
it('import product', function () {
// Arrange
Http::fake();
// Act & Assert
// ...
});
The fake
method on the HTTP
facade will ensure no real call is made and that the response is always a 200
status code.
But you can be more specific too. For example, we are testing an action that fetches data from an external API and saves it to the database.
it('imports product', function() {
// Arrange
Http::fake([
'https://christoph-rumpel.com/import' => Http::response([
'title' => 'My new product',
'description' => 'This is a description',
]),
]);
$user = User::factory()->create();
// Act
(new ImportProductAction)->handle($user);
// Assert
$this->assertDatabaseHas(Product::class, [
'title' => 'My new product',
'description' => 'This is a description',
]);
});
Testing HTTP Calls
Next to faking HTTP calls, you can also test if a specific call was made. This is useful when you want to ensure that your code makes the right calls.
it('make the right call', function () {
// Arrange
Http::fake();
$user = User::factory()->create();
// Act
(new ImportProductAction)->handle($user);
// Assert
Http::assertSent(function ($request) {
return $request->url() === 'https://christoph-rumpel.com/import'
&& $request['accessToken'] === '123456';
});
});
Mocking Dependencies
When working with code with dependencies, it can
be helpful to mock them. This will let you
concentrate on the logic of your code and not on
the dependencies. This also
means mocking can be useful for any kind of tests
.
We already did that when testing our action classes, but this works with any dependency. In the following example, we have a controller with two dependencies: a payment provider and a mailer.
class PaymentController extends Controller
{
public function __invoke(PaymentProvider $paymentProvider, Mailer $mailer)
{
$paymentProvider->handle();
$mailer->to(auth()->user())->send(new PaymentSuccessfulMail);
}
}
In our test, we want to focus on testing that the
correct email is being sent. That's why we can
mock the payment provider and expect the handle
method to be called. As a result, our test will
not fail, even though the actual payment provider
is never called.
it('sends payment successful mail', function () {
// Arrange
Mail::fake();
// Expect
$this->mock(PaymentProvider::class)
->shouldReceive('handle')
->once();
// Act
$this->post('payment');
// Assert
Mail::assertSent(PaymentSuccessfulMail::class);
});
Architecture Testing
PEST introduced a new type of test called Architecture Testing
. It is a way to test your application's architecture and make sure it is following the rules you defined. This is a great way to make sure your code stays clean and maintainable.
test('no forgotten debug statements')
->expect(['dd', 'dump'])
->not->toBeUsed();
My favorite rule is to check that I didn't forget any debug statements in my code. I am sure you have been there too. You deploy your application to production, and suddenly you see a dd
statement on your page. This is embarrassing and can be avoided with architecture tests.
There are many more rules you can define. Read more about them in the official docs.
Conclusion
Being aware of the different types of tests and how to write them is a great skill to have. It will help you write better code and make sure that your application is working as expected.
You might want to learn more about
...
- Test Driven Development with Laravel and PEST
- Getting Started with TDD in PHP
- Testing Livewire Components
Is there anything I forgot to mention? If so, please let me know on Twitter. I am always happy to learn new things too.