How (and when) to fake an Eloquent model
vor 2 Monaten

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 implements OracleOrderInterface for real-world use
  • a model FakeOracleOrder for testing purposes that implements OracleOrderInterface
  • 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.