Ensuring that a growing codebase follows best practices and does not deviate from the standards is essential for any project. However, this can typically only be enforced manually by code reviews and other similar processes. As with any other manual task, this can be time-consuming, tedious, and error-prone. That's where architecture testing comes in.

Architecture testing allows you to automatically enforce code standards such as naming conventions, method usage, directory structure, and more.

In this article, we're going to learn about architecture testing and the benefits of using it. We'll then look at how to write architecture tests for our Laravel applications using Laravel Pest, the popular PHP testing framework. By the end, you'll be confident in writing architecture tests for your Laravel applications.

What is Laravel Pest?

Pest is an open-source PHP testing framework built on top of PHPUnit. At the time of writing, it has over 9.4 million downloads and 7.9k stars on GitHub. It uses a syntax and approach similar to JavaScript's Jest and Ruby's RSpec. If you've used either of these, you should feel at home using Pest for PHP tests.

For example, imagine you have a simple add function that adds two numbers together, and you want to ensure that it returns the correct value. You might write a test in Pest like this:

test('add function returns the correct value')
    ->expect(add(1, 2))
    ->toBe(3);

As we can see, the tests use a fluent syntax that allows us to chain methods together. This results in tests that are simple, easy to read, and understand.

A benefit to using Pest for testing your Laravel applications is that it offers several features that you can use to improve your testing experience. These include:

  • Architecture testing - This allows you to enforce code standards automatically, such as naming conventions, method usage, and directory structure. (That's what we're covering in this article.)
  • Parallel testing - This allows you to run your tests in parallel, which can help to speed up your test suite.
  • Snapshot testing - This allows you to test that values are the same as when the snapshot was created. Snapshot testing can help when testing API responses or when performing large refactors, and you want to ensure the output hasn't changed.
  • Test profiling - This allows you to see how long each test takes to run and can be useful for identifying slow tests that you may want to refactor.
  • Watch mode - This allows you to automatically re-run your tests whenever a file changes in your project.
  • Code coverage - This allows you to see how much of your code is exercised by your tests to know which parts of your codebase need more tests. It can also provide insight into the type coverage of your codebase to identify any methods that aren't using return types and type hints.

Since Pest is built on top of PHPUnit, you can also run your existing PHPUnit tests using Pest. That means that you don't need to worry about migrating your current tests to the Pest syntax to be able to use it and start benefiting from the other features it offers.

What is architecture testing?

Architecture testing differs from the traditional tests you might normally write for your projects. Whereas your traditional tests test the functionality of your application, architecture tests test the structure of your application. They allow you to enforce standards and consistency in your projects.

Depending on the type of project you're working on, you may have a set of standards or guidelines set by your employer, client, or yourself. For example, you might want to enforce things such as:

  • Strict type-checking is used in all files.
  • The app/Interfaces directory only contains interfaces.
  • All classes in the app/Services directory are suffixed with Service (e.g., UserService, BillingService, etc.).
  • All models in the app/Models directory extend the Illuminate\Database\Eloquent\Model class.
  • Controllers aren't used anywhere apart from the routes files in the application.
  • Only traits exist in the app/Traits directory.

Generally, you should enforce these things manually via code reviews and other similar processes. With architecture testing, you can enforce these standards automatically and know for sure that they're followed.

For example, take this simple test that enforces that all interfaces exist within the App\Interfaces namespace:

test('interfaces namespace only contains interfaces')
    ->expect('App\Interfaces')
    ->toBeInterfaces();

The above test can be run like any other Pest test. The test will pass if all the files in the App\Interfaces namespace are interfaces. However, the test will fail if any of the files in this namespace aren't interfaces.

Benefits of architecture testing

Now that we have a basic understanding of architecture testing let's look at its benefits in your projects.

Reduce code review time

As we've already mentioned, architecture testing is an excellent way of automating the process of enforcing standards in your codebase.

By using an automated architecture testing strategy, you can provide instant feedback that doesn't involve other developers. This means that a developer can spot and fix architecture issues before other developers are involved in the approval process. This speeds up the development process and reduces the time the other developer needs to spend reviewing the code and looking for architectural issues.

Improve code quality and consistency

Enforcing standards and consistency early on in a project can help to improve the overall code quality. It helps implement a sensible set of rules that developers can follow to ensure they write code that other developers can understand.

Every developer has their own way of writing code. I'm sure you'll agree that once you've worked on a project for a long enough time, you can usually tell who wrote a particular piece of code by the variable and method names or how the classes are structured. Minor differences like these aren't typically a big deal as long as they follow the general standards of the project that the other developers are also following.

These types of differences can cause consistency issues and make it difficult for other developers to understand and work on the codebase. Although inconsistencies such as these should have been caught during a manual code review process, they may not have been.

For example, if you want to enforce that there are no interfaces outside of the app/Interfaces directory, you could write some Pest tests like this:

test('services namespace only includes classes')
    ->expect('App\Services')
    ->toBeClasses();

test('interfaces namespace only contains interfaces')
    ->expect('App\Interfaces')
    ->toBeInterfaces();

Now, if there's an interface in the App\Services namespace, the Pest test will fail and must be fixed before merging the code into the main branch. As a result, it can help ensure that every developer uses the same approach.

Get started with architecture testing

Now that we've learned about architecture testing and its benefits in your projects, we'll walk through installing Pest and writing our first architecture tests.

Laravel Pest testing documentation

You'll need to install Pest in your Laravel project using Composer. You can do this by running the following command in your project's root directory:

composer require pestphp/pest-plugin-laravel --dev

Next, we'll look at typical examples of architecture tests you might want to add to your Laravel applications. We'll begin by creating a simple test that asserts that the App\Models namespace contains only classes and that each class extends the Illuminate\Database\Eloquent\Model class.

Where you place your architecture tests is a personal preference, but I like to put mine in a tests/Architecture directory. Doing this allows me to keep my architecture tests separate from my feature and unit tests. For our tests inside that directory to be detected and run, we'll need to update the phpunit.xml file to include it. We can do this by adding a new Architecture test suite to the <testsuites> section:

<testsuites>
    ...
    <testsuite name="Architecture">
        <directory>tests/Architecture</directory>
    </testsuite>
</testsuites>

I like to structure my architecture tests in a way that's similar to my application code's directory structure. By doing this, it makes it easier to find the tests that are related to a particular part of the project. For example, I place my architecture tests for models (found in app/Models) in a tests/Architecture/ModelsTest.php test file. I put my controller architecture tests (in app/Http/Controllers) in a tests/Architecture/Http/ControllersTest.php test file. And so on.

Next, create a tests/Architecture/ModelsTest.php file and add the following code to it:

use Illuminate\Database\Eloquent\Model;

test('models extends base model')
    ->expect('App\Models')
    ->toExtend(Model::class);

We can now run this test using the following command:

php artisan test

If you configured everything correctly, you should see an output like this when you run the test:

PASS  Tests\Architecture\ModelsTest
✓ models extends base model                                                      0.06s  

Tests:    1 passed (2 assertions)
Duration: 0.14s

Now, let's purposely break the tests to ensure your test is working correctly and detecting any architectural issues. To do this, let's add an empty PHP class that doesn't extend the Illuminate\Database\Eloquent\Model class. The class may look something like this:

namespace App\Models;

class UserService
{

}

If you re-run the tests, the tests should fail, and you will get an output that looks like this:

FAIL  Tests\Architecture\ModelsTest
⨯ models extends base model                                                      0.07s
──────────────────────────────────────────────────────────----------------------------
FAILED  Tests\Architecture\ModelsTest > models          ArchExpectationFailedException 
Expecting 'app/Models/UserService.php' to extend 'Illuminate\Database\Eloquent\Model'.

at app/Models/UserService.php:5
    1▕ <?php
    2▕ 
    3▕ namespace App\Models;
    4▕ 
➜   5▕ class UserService
    6▕ {
    7▕ 
    8▕ }
    9▕ 

1   app/Models/UserService.php:5


Tests:    1 failed (2 assertions)
Duration: 0.18s

Common examples of architecture tests

Now that we've looked at how to install Pest and write our first architecture tests let's look at some common examples of architecture tests you might want to add to your Laravel applications.

Here are some of the common methods that you'll likely be using in your tests:

  • toBeAbstract - Assert the classes in the given namespace are abstract (using the keyword abstract).
  • toBeClasses - Assert the files in the given namespace are PHP classes.
  • toBeEnums - Assert the files in the given namespace are PHP enums.
  • toBeInterfaces - Assert the files in the given namespace are PHP interfaces.
  • toBeTraits - Assert the files in the given namespace are PHP traits.
  • toBeInvokable - Assert the classes in the given namespace are invokable (meaning they have an __invoke method).
  • toBeFinal - Assert the classes in the given namespace are final (using the keyword final).
  • toBeReadonly - Assert the classes in the given namespace are readonly (using the keyword readonly).
  • toExtend - Assert the classes in the given namespace extend the given class.
  • toImplement - Assert the classes in the given namespace implement the given interface.
  • toHaveMethod - Assert the classes in the given namespace have the given method.
  • toHavePrefix - Assert the classes in the given namespace start with the given string in their classnames.
  • toHaveSuffix - Assert the classes in the given namespace end with the given string in their classnames.
  • toUseStrictTypes - Assert the files in the given namespace use strict types (using the keywords declare(strict_types=1)).

You can check out the Pest documentation for a complete list of available assertions.

Now, let's look at some common tests you may want to write. This is not an exhaustive list, but it should provide an insight into the different assertions you might want to use. You can also use these as a starting point for your own tests and modify them to suit your needs.

Tests for commands

You may want to write a test for your Artisan commands to assert that the App\Console\Commands namespace only contains valid command classes. To do this, you may want to write a test like this:

declare(strict_types=1);

use Illuminate\Console\Command;

test('commands')
    ->expect('App\Console\Commands')
    ->toBeClasses()
    ->toUseStrictTypes()
    ->toExtend(Command::class)
    ->toHaveMethod('handle');

In this test, we're defining that we only expect classes in the App\Console\Commands namespace. We're then asserting that each class uses strict types (using declare(strict_types=1)), extends the Illuminate\Console\Command class, and has a handle method.

If we were to add any files in this namespace that don't meet these requirements (such as adding a class that doesn't extend the Illuminate\Console\Command class), the test would fail.

Tests for controllers

You may also want to write a test to assert that your controllers follow specific standards. Let's take a look at an example test you could write, and then we'll discuss what we're doing:

declare(strict_types=1);

use Illuminate\Routing\Controller;

test('controllers')
    ->expect('App\Http\Controllers')
    ->toBeClasses()
    ->toUseStrictTypes()
    ->toBeFinal()
    ->classes()
    ->toExtend(Controller::class);

In the test above, we assert that every PHP file in the App\Http\Controllers namespace is a class. We then assert that each of the classes uses strict types (using declare(strict_types=1)) and are final (using the final keyword). Finally, we assert that each class extends the Illuminate\Routing\Controller class.

If we were to add any other classes in this namespace that don't meet these requirements (such as adding a class that doesn't extend the Illuminate\Routing\Controller class), the test would fail.

Tests for interfaces

You may also want to write a test to assert that your project's App\Interfaces namespace only contains interfaces. To do this, you may want to write a test like this:

declare(strict_types=1);

test('interfaces')
    ->expect('App\Interfaces')
    ->toBeInterfaces();

If we were to add any files in this namespace that don't meet these requirements (such as adding a class or trait), the test would fail.

Tests for global functions

As well as testing that PHP files follow specific standards, we can also test that we don't use certain PHP global functions in our codebase. This can be a handy way to ensure that we don't accidentally leave debugging statements (such as dd()) in the code, which could cause issues in production.

You may want to write a test like this:

declare(strict_types=1);

test('globals')
    ->expect(['dd', 'ddd', 'die', 'dump', 'ray', 'sleep'])
    ->toBeUsedInNothing();

In the test above, we assert that the functions dd, ddd, die, dump, ray, and sleep aren't used anywhere in the application. If it finds one of them, the test will fail.

Tests for jobs

Another helpful place you may want to write some architecture tests for is your job classes. You may wish to assert that all of your job classes implement the Illuminate\Contracts\Queue\ShouldQueue interface and have a handle method. To do this, you may want to write a test like this:

declare(strict_types=1);

use Illuminate\Contracts\Queue\ShouldQueue;

test('jobs')
    ->expect('App\Jobs')
    ->toBeClasses()
    ->toImplement(ShouldQueue::class)
    ->toHaveMethod('handle');

In the test above, we assert that the App\Jobs namespace only includes classes that implement the Illuminate\Contracts\Queue\ShouldQueue interface and have a handle method. If we were to add an invalid file (such as a class that didn't implement the interface), the test would fail.

Tests for models

Let's imagine that we have an app/Models directory to demonstrate how we might use the 'ignoring' modifier. Within this directory, we have a Traits directory (using the App\Models\Traits namespace). Imagine that this directory contains traits used by our models and nowhere else in the codebase.

We may want to write two tests that look like this:

test('models')
    ->expect('App\Models')
    ->toBeClasses()
    ->ignoring('App\Models\Traits');

test('models extends base model')
    ->expect('App\Models')
    ->toExtend(Model::class)
    ->ignoring('App\Models\Traits');

In the first test (named models), we assert that every PHP file within the App\Models namespace is a class. We then use the ignoring modifier to ignore the App\Models\Traits namespace. That means that this test will ignore any files in this namespace.

In the second test (named models extends base model), we're asserting that every class (except those in the App\Models\Traits namespace) extends the Illuminate\Database\Eloquent\Model class, thus making it a valid model class.

Improving your code with architecture testing

In this article, we've learned about what architecture testing is and the benefits of using it. We've also looked at how to write architecture tests for our applications using Laravel Pest.

Now that you're ready to start writing architecture tests for your Laravel applications, you'll soon see how they lead to an improved codebase. Over time, your tests will lead to you and your team making better decisions, and the value of the investment you made in architecture tests will compound. Happy testing!

Get the Honeybadger newsletter

Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo
    Ashley Allen

    Ashley is a freelance Laravel web developer who loves contributing to open-source projects and building exciting systems to help businesses succeed.

    More articles by Ashley Allen
    An advertisement for Honeybadger that reads 'Turn your logs into events.'

    "Splunk-like querying without having to sell my kidneys? nice"

    That’s a direct quote from someone who just saw Honeybadger Insights. It’s a bit like Papertrail or DataDog—but with just the good parts and a reasonable price tag.

    Best of all, Insights logging is available on our free tier as part of a comprehensive monitoring suite including error tracking, uptime monitoring, status pages, and more.

    Start logging for FREE
    Simple 5-minute setup — No credit card required