How to boost your Laravel and PHPUnit testing performance

How to boost your Laravel and PHPUnit testing performance

PHPUnit is a testing framework that followed the xUnit framework which is a testing framework that is originally written by Kent Beck.

"x" refers to the language such as PHPUnit for PHP or JUnit for Java, and NUnit for .NET, and Unit is the concept.

xUnit features

  • Write tests with the same language that you are developing in.

  • All tests run in isolation.

  • Tests can be grouped in a suite, to be able to run and rerun them on demand.

xUnit Rules

  • Every test suite Ends with "Test", e.g. LoginTest.

  • Every test case "method" starts with "test", e.g. test_successful_login.

  • Every test suite should have a setUp method that will run before each test case in the testing suite, to initialize the test case to be isolated.

  • Every test suite should have a tearDown method that will run after each test case in the testing suite to clean up what the test case did.

After explaining how PHPUnit works behind the scenes let's now dive deeper to spot the things that affect the testing time and how to reduce the testing time.


What are the things that can increase the testing time

  • Database queries.

  • Logs whether it's on std or a file.

  • Communicating with any third party.

In this article, we will focus on the first point.

How to deal with database queries in testing

We can deal with database queries in many ways.

  1. We can mock the database queries but mocking database queries is a brittle and unreliable way to test your code. It is better to hit the database directly and assert the results, as this will ensure that your code works correctly against any database version.

    Here are some of the reasons why mocking database queries is a bad idea:

    • It can be difficult to create accurate mocks that match the behavior of the real database.

    • Mocks can be slow and inefficient, as they have to be created and initialized for each test.

    • Mocks can make it difficult to test database-specific features.

    • Mocks can give a false sense of security, as they may not catch all possible errors.

  2. We can configure an in-memory database driver to simulate the database.
    With that approach, we need to create each record that we want in each test case using the Model::factory then do your queries and assert the results.
    .env.testing or phpunit.xml

     DB_CONNECTION=:memory:
    
  3. In-memory databases are not suitable for static data or large datasets. In these cases, you need to use a real database and migrate your data. This ensures that the migrations will work fine and that your application can handle large amounts of data.

Let's dive deeper into the third approach.


Laravel provides very good helper traits to deal with your Laravel tests.

Let's have RefreshDatabase trait first.

We will test that all cities exist and we will check if a specific city exists.

<?php

namespace Tests\Feature;

use App\Models\City;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class StaticDataTest extends TestCase
{
    use RefreshDatabase;

    public function setup(): void
    {
        parent::setUp();

        $this->artisan('db:seed');
    }

    public function test_all_cities_exist(): void
    {
        $this->assertDatabaseCount('cities', 10000);
    }

    public function test_city_is_blocked(): void
    {
        $city = City::where('name', 'Israel')->first();

        $this->assertNotNull($city);
        $this->assertFalse($city->active);
    }
}

The problem with that approach is that it will migrate the database and seed it with every test case "method".

Seeding the database with every test case is inefficient and time-consuming, especially for large datasets. We can improve the performance of our tests by seeding the database once per test suite.

This can be achieved by extending the RefreshDatabase trait functionality to allow it to seed after migration.

<?php

namespace Tests\Traits;

use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\RefreshDatabaseState;
use Illuminate\Support\Facades\Log;
use Illuminate\Contracts\Console\Kernel;

trait DatabaseRefresher
{
    use RefreshDatabase;

    /**
     * Refresh a conventional test database.
     *
     * @return void
     */
    protected function refreshTestDatabase()
    {
        if (!RefreshDatabaseState::$migrated) {
            Log::info('RefreshDatabase');

            $this->artisan('migrate:fresh', $this->migrateFreshUsing());
            $this->artisan('db:seed');

            $this->app[Kernel::class]->setArtisan(null);

            RefreshDatabaseState::$migrated = true;
        }

        $this->beginDatabaseTransaction();
    }
}

I just override the refreshTestDatabase method that is responsible for refreshing the database with every test suite, then it runs beginDatabaseTransaction because, Laravel runs any test case inside a transaction by default and then rollbacks the changes after passing the test case to save time, and resources, and achieve isolation.

Now, we can use the DatabaseRefresher trait that I have just created directly instead of the RefreshDatabase trait in the test suites.

That is a good enhancement but with growing the application and test suites this will take a ton of time to run.

💡
A better approach is to seed the database once before all tests and use transactions to isolate each test.

We can achieve this by using this trait in the first test case that will run, which will migrate and seed the database once when starting the test.

<?php

namespace Tests\Feature;

use App\Models\City;
use Tests\TestCase;
use Tests\Traits\DatabaseRefresher;

class FirstTest extends TestCase
{
    use DatabaseRefresher;

    public function test_somthing(): void
    {
        $this->assertTrue(true);
    }
}
💡
Don't forget to add a test case so the test suite is not ignored by the PHPUnit framework.

What about the transactions now, How can test inside transactions while using the trait in the first test suite only?!

Laravel makes it easy to test inside transactions. Simply use the DatabaseTransactions trait in any test case that needs to access the database.

<?php

namespace Tests\Feature;

use App\Models\City;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;

class StaticDataTest extends TestCase
{
    use DatabaseTransactions;

    public function test_all_cities_exist(): void
    {
        $this->assertDatabaseCount('cities', 10000);
    }

    public function test_city_is_blocked(): void
    {
        $city = City::where('name', 'Israel')->first();

        $this->assertNotNull($city);
        $this->assertFalse($city->active);
    }
}
💡
Following these steps can significantly reduce your testing time by up to 4x times. As your tests grow, the saved time will increase.

References