Interested how Laravel works under the hood? → Check my Laravel Core Adventures Course

Everything You Can Test In Your Laravel Application

#testing #laravel

A 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:

Note: All examples can also be found on the dedicated repository.

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.

Reference: Learn more about testing page responses on the official docs.

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.

Reference: Learn more about testing page responses on the official docs.

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');
});
Reference: Learn more about testing page responses on the official docs.

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,
            ],
        ]);
});
Reference: Learn more about testing page responses on the official docs.

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.

Reference: Find all database assertions on the official documentation.

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']],
]);
Reference: Learn more about validation assertions on the official docs.

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);
});
Reference: Learn more about testing mailables on the official docs.

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);
});
Reference: Learn about all the other assertions for testing mail content on the official docs.

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.

Reference: Learn more about testing notifications on the official docs.

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.

Reference: Learn more about testing commands on the official docs.

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);
});
Note: Instead of providing the string to the route "get" method manually, I'm using the "action" helper, which gets the route of a controller.

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';
    });
});
Reference: Learn more about faking HTTP calls on the official docs.

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...

Is there anything I forgot to mention? If so, please let me know on Twitter. I am always happy to learn new things too.

Do you enjoy my posts?

Sign up for my newsletter to receive updates on my latest content.

You will receive monthly updates on my latest articles and products. I do care about the protection of your data. Read my Privacy Policy.