Colocating Tests in Laravel

Chris GmyrChris Gmyr
4 min read

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);
            });
        }
    }
}
🐛
Currently, in Laravel 11, Pest file tests colocated in 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

2
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