Colocating Tests in Laravel
In a typical Laravel application, tests are housed in the /Tests
directory and spread across /Tests/Feature
and /Tests/Unit
, which mimics the structure within the app/
directory. While this is a great starting point for apps, it quickly breaks down within a more extensive application and minimizes developer efficiency and happiness over time.
Let's look at the typical structure with minimal application code and tests.
.
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ └── MyUserController.php
│ └── Models/
│ └── User.php
└── tests/
├── TestCase.php
├── Features/
│ └── Http/
│ └── Controllers/
│ └── MyUserControllerTest.php
└── Unit/
└── Models/
└── UserTest.php
This issue gets exacerbated in larger applications with additional sub-directories. For example,
.
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ └── Admin/
│ │ └── Users/
│ │ └── MyUserController.php
│ └── Models/
│ └── User.php
└── tests/
├── TestCase.php
├── Features/
│ └── Http/
│ └── Controllers/
│ └── Admin/
│ └── Users/
│ └── MyUserControllerTest.php
└── Unit/
└── Models/
└── UserTest.php
This structure further breaks down if you've chosen to break up your application within modules or domains but don't include the tests within the directories. The application and test structures could easily be five or more directories deep.
Discoverability of application files with their test partners is challenging, and it is unclear if a given application file has a partnering test.
The solution
Colocating tests is an ideal structure. The test files are elevated to the same "status" and layer as application files. This makes it very easy to notice, at a glance, whether a file has a partnering test. Further, this simplifies pull requests for reviewers since the two files are next to each other.
The structure of our application now looks like this.
.
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ ├── MyUserController.php
│ │ └── MyUserControllerTest.php
│ └── Models/
│ ├── User.php
│ └── UserTest.php
└── tests/
└── TestCase.php
or
.
├── app/
│ ├── Http/
│ │ └── Controllers/
│ │ └── Admin/
│ │ └── Users/
│ │ ├── MyUserController.php
│ │ └── MyUserControllerTest.php
│ └── Models/
│ ├── User.php
│ └── UserTest.php
└── tests/
└── TestCase.php
Now imagine if this app contains hundreds of different directories and files. Can you easily see if MyUserController
has a test? Yes! How about the User
model? Yes again!
The mechanics
Unfortunately, we cannot just move our tests into this new structure. We'll have to make some small adjustments, but they are small and straightforward.
composer.json
Add autoload.exclude-from-classmap
{
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
- }
+ },
+ "exclude-from-classmap": [
+ "app/**/*Test"
+ ]
}
}
phpunit.xml
Add or adjust source
and testsuites
.
<source>
<include>
<directory suffix=".php">./app</directory>
</include>
+ <exclude>
+ <directory suffix="Test.php">./app</directory>
+ </exclude>
</source>
<testsuites>
- <testsuite name="Unit">
- <directory>tests/Unit</directory>
- </testsuite>
- <testsuite name="Feature">
- <directory>tests/Feature</directory>
- </testsuite>
+ <testsuite name="Application Test Suite">
+ <directory>./app</directory>
+ </testsuite>
</testsuites>
tests/Pest.php
Ensure all tests have the TestCase
and other uses
classes available.
uses(
Tests\TestCase::class,
// Illuminate\Foundation\Testing\RefreshDatabase::class,
-)->in('Feature');
+)->in('../');
app/Console/Kernel.php (< v11)
Add or adjust the load()
method.
protected function load($paths)
{
$paths = Arr::wrap($paths);
$namespace = $this->app->getNamespace();
foreach ((new Finder)->in($paths)->files() as $command) {
$command = $namespace.str_replace(
['/', '.php'],
['\\', ''],
Str::after($command->getPathname(), realpath(app_path()).DIRECTORY_SEPARATOR)
);
+ $isTestClass = Str::endsWith($command, 'Test');
if (
+ !$isTestClass &&
is_subclass_of($command, Command::class) &&
!(new ReflectionClass($command))->isAbstract()
) {
Artisan::starting(function ($artisan) use ($command) {
$artisan->resolve($command);
});
}
}
}
app/Console/Commands/*
isn't supported. To colocate these tests correctly, you'll have to use PHPUnit's class-based syntax.External Tooling
If your application uses a tool like Code Climate, you might need to make additional config changes. For example, within the .codeclimate.yml
, add **/*Test.php
within the exclude_patterns
.
exclude_patterns:
+ - '**/*Test.php'
Quality of life
If you like the idea of colocating tests, but don't want the additional mess of looking at test files all the time you can enable File Nesting in PHPStorm, and other JetBrains IDEs. Once you add the .php -> Test.php
mapping your files will be collapsed.
Thanks to Enzo Innocenzi for bringing that to my attention.
If you use VSCode, please check out Liam Hammett's extension.
Wrapping up
Even though colocating your tests within the app/
directory in your Laravel application is currently atypical, I highly recommend you try it out. By colocating tests alongside application code, the direct mapping of the two will become stronger, minimizing the cognitive load in your app. You will also have a simplified structure and ease of refactoring if you happen to move your modules or domains within your app or to a new one.
Please comment below or reach out via X/Twitter about what you think about colocating tests and if you've implemented it within your application.
Listen to the Podcast
Subscribe to my newsletter
Read articles from Chris Gmyr directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Chris Gmyr
Chris Gmyr
Husband, dad, & grilling aficionado. Loves Laravel & coffee. Staff Engineer @ Curology, and TrianglePHP Co-organizer