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 withService
(e.g.,UserService
,BillingService
, etc.). - All models in the
app/Models
directory extend theIlluminate\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.
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 keywordabstract
).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 keywordfinal
).toBeReadonly
- Assert the classes in the given namespace are readonly (using the keywordreadonly
).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 keywordsdeclare(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!