Support the ongoing development of Laravel.io →

How (and when) to fake an Eloquent model

3 Feb, 2022 4 min read

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.

Last updated 1 year ago.

driesvints liked this article

1
Like this article? Let the author know and give them a clap!
mazedlx (mazedlx) Fullstack-Developer. ❤️ Laravel and the TALL Stack. Dad and BBQ enthusiast.

Other articles you might like

July 19th 2024

Standardizing API Responses Without Traits

Problem I've noticed that most libraries created for API responses are implemented using traits, and...

Read article
July 17th 2024

Collect feedback via Discord notifications in your Laravel project

How to create a feedback module in a Laravel project and receive a Discord notification when a messa...

Read article
July 12th 2024

Laravel Advanced: Top 10 Validation Rules You Didn't Know Existed

Do you know all the validation rules available in Laravel? Think again! Laravel has many ready-to-us...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.