How (and when) to fake an Eloquent model
Photo by Sander Sammy on Unsplash
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.
driesvints liked this article
Other articles you might like
How to add WebAuthn Passkeys To Backpack Admin Panel
Want to make your Laravel Backpack admin panel more secure with a unique login experience for your a...
Quickest way to setup PHP Environment (Laravel Herd + MySql)
Setting up a local development environment can be a time taking hassle—whether it's using Docker or...
Access Route Model-Bound Models with "#[RouteParameter]"
Introduction I've recently been using the new #[RouteParameter] attribute in Laravel, and I've been...
The Laravel portal for problem solving, knowledge sharing and community building.
The community