Introduction
Maintaining standards in an ever-growing codebase that multiple developers contribute to can be difficult and tedious. Ensuring that the codebase follows best practices and does not deviate from the standards is essential for any project. But this is typically something that can 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 what architecture testing is and the benefits of using it. We'll then look at how to write architecture tests for our Laravel applications using the popular PHP testing framework, Pest. By the end, you'll be confident to start writing architecture tests for your Laravel applications.
What is 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 Jest (a popular JavaScript testing framework) and Ruby's RSpec. If you're familiar with writing JavaScript tests using Jest, 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:
1test('add function returns the correct value')2 ->expect(add(1, 2))3 ->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. - All action classes in the
app/Actions
directory are invokable (meaning they have an__invoke
method). - Controllers aren't used anywhere apart from the routes files in the application.
- Only traits exist in the
app/Traits
directory. - All classes inside the
app/DataTransferObjects
directory are readonly.
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:
1test('interfaces namespace only contains interfaces')2 ->expect('App\Interfaces')3 ->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.
To demonstrate this, let's look at an example workflow between two developers in a team that doesn't use architecture testing. We'll assume that one of the developers has been working on a new feature and is making a pull request to merge their changes into the main branch:
- Developer A writes the code for the feature.
- Developer A makes a pull request.
- Developer A asks for a review from Developer B.
- Developer B reviews the code and spots an architectural issue (for example, a file in the wrong directory).
- Developer B leaves a comment on the pull request and asks Developer A to fix the issue.
- Developer A fixes the issue and asks for another review.
- Developer B reviews the code again and approves the pull request (assuming no other errors).
As we can see, this process has the potential to be slow because it relies on other developers in the team. It also depends on the other developer to spot the issue, which means it could still get through the approval process even if there's an architectural issue.
By using an automated architecture testing strategy, the process could look like this:
- Developer A writes the code for the feature.
- (Optional) Developer A runs the architecture tests locally. If they spot any issues, they fix them.
- Developer A makes a pull request.
- The CI pipeline runs the architecture tests.
- Developer fixes any architectural issues.
- Developer A asks for a review from Developer B.
- Developer B reviews the code and approves the pull request (assuming no other errors).
As we can see, by being able to run the architecture tests locally and in the CI pipeline, it can provide instant feedback that doesn't involve the other developer. This means that Developer A can spot and fix the issues before Developer B is 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.
However, the codebase may have some alarming differences if you don't enforce standards. For example, you might have one developer who prefers to keep their interfaces in an app/Interfaces
directory. However, you might have another developer who prefers to keep the interfaces in a folder related to the class. For instance, they might put a UserServiceInterface
interface inside an app/Services/User
directory so it lives alongside the UserService
class instead of placing it in the app/Interfaces
directory.
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. However, if you want to enforce that there are no interfaces outside of the app/Interfaces
directory, you could write some tests like this:
1test('services namespace only includes classes')2 ->expect('App\Services')3 ->toBeClasses();4 5test('interfaces namespace only contains interfaces')6 ->expect('App\Interfaces')7 ->toBeInterfaces();
Now, if there's an interface in the App\Services
namespace, the 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 learn how to install Pest and write 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:
1composer require pestphp/pest-plugin-laravel --dev
Pest should now be installed and ready to run.
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
testsuite to the <testsuites>
section:
phpunit.xml
1<testsuites>2 ...3 <testsuite name="Architecture">4 <directory>tests/Architecture</directory>5 </testsuite>6</testsuites>
If you've not previously made any changes to the default phpunit.xml
file that ships with Laravel, this will result in the file looking something like this:
phpunit.xml
1<?xml version="1.0" encoding="UTF-8"?>2<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"3 xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"4 bootstrap="vendor/autoload.php"5 colors="true"6>7 <testsuites>8 <testsuite name="Unit">9 <directory>tests/Unit</directory>10 </testsuite>11 <testsuite name="Feature">12 <directory>tests/Feature</directory>13 </testsuite>14 <testsuite name="Architecture">15 <directory>tests/Architecture</directory>16 </testsuite>17 </testsuites>18 <source>19 <include>20 <directory>app</directory>21 </include>22 </source>23 <php>24 <env name="APP_ENV" value="testing"/>25 <env name="BCRYPT_ROUNDS" value="4"/>26 <env name="CACHE_DRIVER" value="array"/>27 <env name="MAIL_MAILER" value="array"/>28 <env name="QUEUE_CONNECTION" value="sync"/>29 <env name="SESSION_DRIVER" value="array"/>30 <env name="TELESCOPE_ENABLED" value="false"/>31 </php>32</phpunit>
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.
So we will create a tests/Architecture/ModelsTest.php
file and add the following code to it:
tests/Architecture/ModelsTest.php
1use Illuminate\Database\Eloquent\Model;23test('models extends base model')4 ->expect('App\Models')5 ->toExtend(Model::class);
We can now run this test using the following command:
1php artisan test
If you configured everything correctly, you should see an output like this when you run the test:
1PASS Tests\Architecture\ModelsTest2✓ models extends base model 0.06s34Tests: 1 passed (2 assertions)5Duration: 0.14s
We'll 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:
app/Models/UserService.php
1namespace App\Models;23class UserService4{56}
If you re-run the tests, the tests should fail, and you will get an output that looks like this:
1FAIL Tests\Architecture\ModelsTest 2⨯ models extends base model 0.07s 3──────────────────────────────────────────────────────────---------------------------- 4FAILED Tests\Architecture\ModelsTest > models ArchExpectationFailedException 5Expecting 'app/Models/UserService.php' to extend 'Illuminate\Database\Eloquent\Model'. 6 7at app/Models/UserService.php:5 8 1▕ 9 2▕10 3▕ namespace App\Models;11 4▕12➜ 5▕ class UserService13 6▕ {14 7▕15 8▕ }16 9▕17181 app/Models/UserService.php:5192021Tests: 1 failed (2 assertions)22Duration: 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:
1declare(strict_types=1); 2 3use Illuminate\Console\Command; 4 5test('commands') 6 ->expect('App\Console\Commands') 7 ->toBeClasses() 8 ->toUseStrictTypes() 9 ->toExtend(Command::class)10 ->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:
1declare(strict_types=1); 2 3use Illuminate\Routing\Controller; 4 5test('controllers') 6 ->expect('App\Http\Controllers') 7 ->toBeClasses() 8 ->toUseStrictTypes() 9 ->toBeFinal()10 ->classes()11 ->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:
1declare(strict_types=1);2 3test('interfaces')4 ->expect('App\Interfaces')5 ->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:
1declare(strict_types=1);2 3test('globals')4 ->expect(['dd', 'ddd', 'die', 'dump', 'ray', 'sleep'])5 ->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:
1declare(strict_types=1);2 3use Illuminate\Contracts\Queue\ShouldQueue;4 5test('jobs')6 ->expect('App\Jobs')7 ->toBeClasses()8 ->toImplement(ShouldQueue::class)9 ->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 look at one of the modifiers that Pest allows us to use to make our assertions more specific. We'll take a look at the ignoring
modifier.
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 three tests that look like this:
1test('models') 2 ->expect('App\Models') 3 ->toBeClasses() 4 ->ignoring('App\Models\Traits'); 5 6test('models extends base model') 7 ->expect('App\Models') 8 ->toExtend(Model::class) 9 ->ignoring('App\Models\Traits');10 11test('model traits')12 ->expect('App\Models\Traits')13 ->toBeTraits()14 ->toOnlyBeUsedIn('App\Models');
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.
Finally, in the third test (named model traits
), we assert that every PHP file within the App\Models\Traits
namespace is a trait. We then assert that the traits found in this namespace do not live outside of the App\Models
namespace. This means that the test would fail if we used any of these traits in another part of the application (such as in a controller).
Conclusion
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 Laravel applications using the popular PHP testing framework, Pest.
You're now ready to start writing architecture tests for your Laravel applications.