How (and when) to fake an Eloquent model
In a recent Laravel project I've built (following TDD principles) I stumbled across this problem:
How do I fake calls to a database that isn't part of my application?
My application needs to import data into its MySQL database from an Oracle database. And I don't want to re-create the Oracle database in my codebase. And how would I even handle running only certain migrations? But I want to make sure the importing controller does what it is supposed to, so I thought to myself:
I just simply mock any calls to the Oracle database and return what I'd expect it to.
There's a pattern for this we can use, called Repository Pattern: it acts as a layer between the application and the database. Let's get started!
The Setup
- a model
Order
which has a corresponding table in MySQL - an interface
OracleOrderInterface
that defines which methods our model need to implement - a model
OracleOrder
that implementsOracleOrderInterface
for real-world use - a model
FakeOracleOrder
for testing purposes that implementsOracleOrderInterface
- a controller
ImportOrdersFromOracleController
that collects orders from the Oracle database in imports them to the MySQL database
Order
model
This is a plain Eloquent model.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
//
}
OracleOrderInterface
The interface defines which methods need to be implemented.
<?php
namespace App\Interfaces;
interface OracleOrderInterface
{
public static function orders();
}
OracleOrder
model
This is the real-world usage model which connects to the Oracel database.
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
use App\Interfaces\OracleOrderInterface;
class OracleOrder extends Model implements OracleOrderInterface
{
protected $connection = 'oracle';
public static function orders()
{
return self::all();
}
}
FakeOracleOrder
model
This is the fake model we use in our tests. It doesn't connect to any database and just returns a collection of a class which extends Illuminate\Support\Collection
.
<?php
namespace App;
use Illuminate\Support\Collection;
use App\Interfaces\OracleOrderInterface;
class FakeOracleOrder implements OracleOrderInterface
{
public static function orders()
{
return collect([
new class extends Collection {
},
]);
}
}
ImportOrdersFromOracleController
This controller handles the import. We inject OracleOrderInterface
when calling __invoke()
on the controller.
<?php
namespace App\Http\Controllers;
use App\Order;
use App\Interfaces\OracleOrderInterface;
class ImportOrdersFromOracleController extends Controller
{
public function __invoke(OracleOrderInterface $oracleOrder)
{
$oracleOrder::orders()
->each(function ($orderToImport) {
Order::create($orderToImport->toArray());
});
}
}
Class binding
Now, we need to tell Laravel which implementation of OracleOrderInterface
to use, this is done in AppServiceProvider.php
:
<?php
namespace App\Providers;
use App\OracleOrder;
use Illuminate\Support\ServiceProvider;
use App\Interfaces\OracleOrderInterface;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*
* @return void
*/
public function register()
{
//
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
$this->app->bind(
OracleOrderInterface::class,
OracleOrder::class
);
}
}
When writing our test we will swap out OracleOrder
with FakeOracleOrder
.
Testing
Let's write a test that makes sure our import controller does it's job.
PHPUnit XML
Standard Laravel phpunit.xml
extended with
-
<env name="DB_CONNECTION" value="sqlite"/>
, and -
<env name="DB_DATABASE" value=":memory:"/>
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="false">
<testsuites>
<testsuite name="Feature">
<directory suffix="Test.php">./tests/Feature</directory>
</testsuite>
<testsuite name="Unit">
<directory suffix="Test.php">./tests/Unit</directory>
</testsuite>
</testsuites>
<filter>
<whitelist processUncoveredFilesFromWhitelist="true">
<directory suffix=".php">./app</directory>
</whitelist>
</filter>
<php>
<env name="APP_ENV" value="testing"/>
<env name="CACHE_DRIVER" value="array"/>
<env name="SESSION_DRIVER" value="array"/>
<env name="QUEUE_DRIVER" value="sync"/>
<env name="MAIL_DRIVER" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
</php>
</phpunit>
Routing
We add one route to handle the import.
<?php
Route::post('/import', 'ImportOrdersFromOracleController');
The test
First we swap out the current OracleOrderInterface
with our FakeOracleOrder
implementation. Then we post to /import
and assert that the response is ok and the order in Oracle gets imported to MySQL.
<?php
namespace Tests\Feature;
use App\Order;
use Tests\TestCase;
use App\FakeOracleOrder;
use App\Interfaces\OracleOrderInterface;
use Illuminate\Foundation\Testing\RefreshDatabase;
class ImportOrdersFromOracleTest extends TestCase
{
use RefreshDatabase;
/** @test */
public function it_imports_orders_from_oracle()
{
$this->app->bind(
OracleOrderInterface::class,
FakeOracleOrder::class
);
$response = $this->post('/import');
$response->assertOk();
$this->assertSame(1, Order::count());
}
}
Running the Test
Let's run the test, fingers crossed!
$ phpunit
PHPUnit 8.3.5 by Sebastian Bergmann and contributors.
.1 / 1 (100%)
Time: 240 ms, Memory: 20.00 MB
OK (1 test, 2 assertions)
It passes! Awesome!
Conclusion
I would not recommend using the repository pattern for all eloquent models, just run your tests (if you can) against SQLite using RefreshDatabase
, which will run your migrations beforehand.
In my case neither did I want to create a separate Oracle database for testing nor do I want to mock every call to OracleOrder
which would kind of act as spell checking for your code and doesn't provide any additional value.