If you have a lot of interfaces, it can be useful to test if the interfaces are bound in your Laravel application.
TLDR The complete test
If you only want the test, here is it.
<?php
declare(strict_types=1);
namespace Tests\AutoReview;
use Generator;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Symfony\Component\Finder\Finder;
use Tests\TestCase;
use Throwable;
final class ContractBindingsTest extends TestCase
{
// The path to the root of your project
final public const PROJECT_DIR = __DIR__ . '/../../';
final public const DEFAULT_PATHS = [
__DIR__ . '/../../app',
// other paths
];
private static array $ignore = [
// Add the interface that aren't bound by purpose
];
#[Test]
#[DataProvider('contractsDataProvider')]
public function weCanGetTheImplementationFromTheContainer(string $contractName): void
{
// run
try {
$class = $this->app->make($contractName);
// verify/assert
$this->assertInstanceOf($contractName, $class);
} catch (Throwable $throwable) {
$this->fail('Failed to get an instance for "' . $contractName.'". The error is: ' . $throwable->getMessage());
}
}
public static function contractsDataProvider(): Generator
{
$finder = Finder::create();
$contractFiles = $finder->name('*Contract.php')->files()->in(self::DEFAULT_PATHS);
foreach ($contractFiles as $file) {
$fqdn = self::getFqdnForFilePath($file->getRealPath());
if (in_array($fqdn, self::$ignore, true)) {
continue;
}
yield $file->getRealPath() => [$fqdn];
}
}
protected static function getFqdnForFilePath(string $filePath): string
{
$realProjectDir = realpath(self::PROJECT_DIR) . '/';
// Any off the App classes
if (str_starts_with($filePath, $realProjectDir . 'app/')) {
// remove the app dir and the .php extension
$className = substr(
$filePath,
strlen($realProjectDir . 'app/'),
-4
);
// Replace the / with a \
$className = str_replace('/', '\\', $className);
// Add the app namespace
return 'App\\' . $className;
}
// Possible add other namespace parts
// Unknown
self::fail('Impossible to match "' . $filePath . '" to FQDN');
}
}
The working
The working has a couple of steps.
- First find the interface files in your project.
- Map the files to an FQDN
- Test if we can get an instance for the FQDN from the application.
Find your interfaces files
To find the interfaces in a project I use the Finder component from Symfony. On my current project all the interfaces have a suffix of Contract. With that I can search for all *Contract.php files to find the contracts. If you have any other standard you can change the finder calls to get the files with the interfaces.
The DEFAULT_PATHS is an array with paths in your project. Most applications will have only one path but my current project has many.
With the ignore array it is possible to skip an interface. This is for interfaces that aren't bound on purpose. For example if it defines some functionality.
final public const DEFAULT_PATHS = [
__DIR__ . '/../../app',
// other paths
];
private static array $ignore = [
// Add the interface that aren't bound by purpose
];
public static function contractsDataProvider(): Generator
{
$finder = Finder::create();
$contractFiles = $finder->name('*Contract.php')->files()->in(self::DEFAULT_PATHS);
foreach ($contractFiles as $file) {
$fqdn = self::getFqdnForFilePath($file->getRealPath());
if (in_array($fqdn, self::$ignore, true)) {
continue;
}
yield $file->getRealPath() => [$fqdn];
}
}
Map the filepath to FQDN
After receiving the files we need to get the FQDN for a class. This says that the file app/Dummy/DummyContract.php has a class with the FQDN App\Dummy\DummyContract
If you have many namespaces or directories you can add more checks.
The last line say that we fail if it is impossible to match.
The result of this function is used to yield the FQDN from the contractsDataProvider method.
// The path to the root of your project
final public const PROJECT_DIR = __DIR__ . '/../../';
protected static function getFqdnForFilePath(string $filePath): string
{
$realProjectDir = realpath(self::PROJECT_DIR) . '/';
// Any off the App classes
if (str_starts_with($filePath, $realProjectDir . 'app/')) {
// remove the app dir and the .php extension
$className = substr(
$filePath,
strlen($realProjectDir . 'app/'),
-4
);
// Replace the / with a \
$className = str_replace('/', '\\', $className);
// Add the app namespace
return 'App\\' . $className;
}
// Possible add other namespace parts
// Unknown
self::fail('Impossible to match "' . $filePath . '" to FQDN');
}
The test function
And then the test itself. The previous written contractsDataProvider is the data provider for this test function. That means this test will run for each FQDN and gets the FQDN as parameter.
The function calls make on the app to get the instance. If we get an exception we let the test fail with the FQDN and the throwable message so we can see the result.
The test will also fail if we get an instance that doesn't implement the interface.
#[Test]
#[DataProvider('contractsDataProvider')]
public function weCanGetTheImplementationFromTheContainer(string $contractName): void
{
// run
try {
$class = $this->app->make($contractName);
// verify/assert
$this->assertInstanceOf($contractName, $class);
} catch (Throwable $throwable) {
$this->fail('Failed to get an instance for "' . $contractName.'". The error is: ' . $throwable->getMessage());
}
}