- read

Optimizing Your Laravel Test Suite: Paradigm Shifts

Dmitry Khorev 43

Optimizing Your Laravel Test Suite: Paradigm Shifts

Dmitry Khorev
Level Up Coding
Published in
9 min read2 hours ago

--

Optimizing Your Laravel Test Suite: Paradigm Shifts.

In this article, we’ll discuss a variety of practices that can greatly enhance your test suite. Some of these are straightforward to implement, while others might require a paradigm shift in how you think about testing. These practices are particularly beneficial for larger projects, where development speed can often slow down as the application grows in complexity.

Strict Checks & Strong Typing

Let’s talk about strict checks and strong typing, which I consider to be essential elements for any long-term software project.

Strong Typing

Strong typing doesn’t just help us prevent coding errors, it also acts as a form of documentation via method signature type hints. With strong typing in place, it becomes easier to understand the properties and types of objects you’re working with. This is particularly useful for IDEs, which can provide hints and catch issues quicker.

<?php

declare(strict_types=1);

namespace App\DTO;

use Spatie\DataTransferObject\DataTransferObject;

class UserRegistrationDto extends DataTransferObject
{
public string $firstName;

public string $lastName;

public string $email;

public string $password;
}

Below is how you can handle a user registration Data Transfer Object (DTO) in a type-safe manner, thanks to strong typing:

Handling a user registration Data Transfer Object (DTO) in a type-safe manner.

Strict Checks

Strict type declarations are a PHP-specific feature that I personally like to include in every new PHP file I create, even configuration files. I strongly advocate for this approach because it adds another layer of reliability to your code.

For instance, consider a situation where a type mismatch occurs in a user delete request; the DTO is supplying an integer, but the model is expecting a string. If strict types are not enabled, PHP will do a silent type coercion.

PHP silent type coercion before enabling strict types.

In PHP, type coercion refers to the automatic or implicit conversion of values from one data type to another. This allows you to perform operations between different types without explicitly casting them. For example, when using the loose equality operator `==`, PHP may convert a string and an integer to the same type before performing a comparison (`”42" == 42` evaluates to `true`). While convenient, type coercion can lead to unexpected behavior and bugs, especially when you’re dealing with functions that expect specific types or when using loose comparison operators.

By adding declare(strict_types=1); at the top of your files, you disable PHP’s silent type coercion, thereby reducing potential errors.

Note: Strict checks only apply to the file that includes the declaration. Importing a “strict” file into a non-strict file doesn’t make the latter strict.

Strict Checks for Test Suite

For a more comprehensive layer of stability across your software project, you should also incorporate strict checks within your test suite.

The first step is to add declare(strict_types=1);at the top of each test case file.

<?php

declare(strict_types=1);

namespace Tests;

class AssertJsonTest extends TestCase
{}

Laravel provides a variety of useful testing helpers that may seem similar but actually behave differently in subtle ways. Understanding these nuances is crucial when aiming for stricter type checking in your project.

Now, let’s take a closer look at how assertEquals() differs from assertSame():

The usage of assertEquals() can result in silent type casting, meaning you could be validating two different types without even realizing it.

/** @test */
public function assertEquals_demo(): void
{
$this->assertEquals(1, '1');
$this->assertEquals(1, '1.0');
}

Switching to assertSame() immediately exposes any type discrepancies and will cause the test to fail.

/** @test */
public function assertSame_demo($actual): void
{
$this->assertSame(1, '1');
$this->assertSame(1, '1.0');
}

Failed asserting that ‘1’ is identical to 1.

Failed asserting that ‘1.0’ is identical to 1.

For this reason, I highly recommend using more rigorous test methods like assertSame() wherever possible.

For value assertions, assertSame() is often the go-to method, while assertJson() is commonly used for validating HTTP responses during integration testing.

However, it’s crucial to understand the difference between assertJson() and its stricter counterpart, assertExactJson(). The former only validates that the expected keys and values exist in the API response, but it ignores any additional data returned. For instance, if your response unintentionally includes sensitive information like a user’s password, assertJson() wouldn’t catch that issue.

/** @test */
public function assertJson_demo(): void
{
$response = new TestResponse(
new Response([
'name' => 'Dmitry',
'age' => 30,
'password' => 'secret',
], 200)
);

$response->assertJson([
'name' => 'Dmitry',
'age' => 30,
]);
}

Switching to assertExactJson() offers stricter validation; it ensures that the response contains only the specified data, helping you identify any unintentional data leaks immediately.

/** @test */
public function assertExactJson_demo(): void
{
$response = new TestResponse(
new Response([
'name' => 'Dmitry',
'age' => 30,
'password' => 'secret',
], 200)
);

$response->assertExactJson([
'name' => 'Dmitry',
'age' => 30,
]);
}
Failed asserting that two strings are equal.
<Click to see difference>
Failed asserting that two strings are equal.

SQLite vs. DB Testing

SQLite vs. DB Testing

By default, and as seen in numerous Laravel tutorials, testing against an in-memory SQLite database is a commonly recommended approach. This strategy is particularly useful for small demos, negating the need to set up additional databases or services. However, this approach comes with certain limitations that are critical to consider, especially if you’re not running SQLite in your production environment.

Key Differences Between SQLite and Traditional Databases like MySQL or PostgreSQL:

  1. Foreign Key Constraints: In SQLite, foreign keys are disabled by default. Although Laravel 10+ has addressed this issue, it remains a concern for older projects. When testing with SQLite, you might overlook the absence of foreign key checks, creating a false sense of security.
  2. Dynamic Typing: SQLite employs dynamic typing, which allows you to store any value of any data type in any column, regardless of the column’s declared type. While this may appear flexible, it poses a risk of data integrity issues that could easily slip past your test suite.
  3. Database-Specific Syntax: If your application relies on database-specific syntax or functions, then testing with SQLite may not be viable at all.

Why These Differences Matter

Understanding these nuances is critical for validating the reliability of your test suites. While SQLite testing is quick and convenient, it might not comprehensively simulate a real-world, production database environment. Therefore, it’s advisable to not rely solely on in-memory database testing for complex, real-world projects.

DB Testing Performance

You might initially be concerned about the performance hit when shifting from SQLite to a more robust database like MySQL for testing. However, the slight decrease in speed is often outweighed by the benefits of more accurate testing. For small to medium-sized projects, you’ll likely find that the performance difference is negligible.

DB Testing Performance — without schema dump.

For larger projects, bootstrapping may take a bit longer as you’ll need to run all the database migrations first. However, the overall runtime of the test suite remains fairly comparable, especially when you’re testing against an empty database.

To improve test suite performance, you can use features like schema dumping (php artisan schema:dump) to reduce boot time or consider parallel testing. Laravel 10+ allows parallel testing by default; for older projects, you might want to look into using the paratest library.

DB Testing Performance —with schema dump.

Getting Started with Database Testing

To shift your primary testing database driver to something like MySQL, you’ll need to make appropriate changes in your phpunit.xml configuration file.

<?xml version="1.0" encoding="UTF-8"?>
...
<php>
<env name="APP_ENV" value="testing"/>
<!-- <env name="DB_CONNECTION" value="sqlite"/>-->
<!-- <env name="DB_DATABASE" value=":memory:"/>-->
<env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value="forge"/>
</php>
</phpunit>

By being aware of these limitations and performance considerations, you can make more informed choices about your database testing strategies, which will pay off in the long run.

Array Cache vs. Redis Cache

Array Cache vs. Redis Cache

Testing Redis integration in a Laravel application can yield different outcomes compared to testing with Laravel’s default array cache driver. Understanding these differences is essential for ensuring that your application behaves as expected, especially if you intend to use Redis in production.

Cache Value Retrieval Differences

Here’s an example to illustrate the point. Imagine you store a float value in the cache and later attempt to retrieve it. Using Laravel’s array driver, you’ll get exactly the value you stored.

/** @test */
public function integrity_with_cache(): void
{
Cache::set('rate', 1.55);

$this->assertSame(1.55, Cache::get('rate'));
}

However, the Redis driver behaves differently. When you retrieve a value from Redis, it returns as a string, regardless of its original data type, and the same test will fail due to the type mismatch.

Failed asserting that ‘1.55’ is identical to 1.55.

Potential Issues and Solutions

  1. Type Errors: If your application relies on strict data types, this discrepancy can lead to TypeError. In other words, passing a string-retrieved value from Redis into a function that expects a float will trigger an error.
  2. Implicit Typecasting: When strict type checks are not enforced, PHP may implicitly cast the string to an integer, leading to unexpected behavior and even bugs that appear to be rooted in your business logic.

Therefore, if your production environment utilizes Redis, it’s advisable to use Redis for your cache testing as well.

Configuring PHPUnit for Redis Testing

To begin using Redis as your primary cache driver in your PHPUnit tests, you’ll need to adjust your phpunit.xml configuration file appropriately.

<?xml version="1.0" encoding="UTF-8"?>
...
<php>
<env name="APP_ENV" value="testing"/>
<!-- <env name="CACHE_DRIVER" value="array"/>-->
<env name="CACHE_DRIVER" value="redis"/>
<env name="REDIS_DATABASE" value="10"/>
</php>
</phpunit>

In summary, testing with a driver that you plan to use in production is crucial for catching subtle issues that may not surface when using Laravel’s array cache driver. The slight complexities of setting up your test environment to use Redis are outweighed by the benefit of knowing your application will behave as expected in a production setting.

Working With Dates

Working With Dates

Date-related issues in your test suite can lead to flaky and unreliable tests, a problem that is particularly amplified when using PHPUnit with Laravel. Let’s delve into common challenges and solutions for ensuring date reliability in your tests.

Time-Dependent Tests

Say you have a reporting system that relies on date filters. You’d naturally want to assert that data points fall within a certain date range. A common practice is to use Laravel’s now() helper function to generate these data points. However, as your test suite grows, the variable execution time can cause now() to drift, leading to inconsistent results.

To address this, one option is to lock the value of now() using Carbon’s setTestNow() method, which ensures that now() returns a consistent time throughout the test run.

$now = now(); // this value is not fixed during test suite run
Carbon::setTestNow($now); // fixes now() to always return one value

Alternatively, you could specify explicit dates for your tests, particularly in scenarios where dates are crucial, like in reporting or data aggregation.

$now = CarbonImmutable::parse('2023-09-01 10:00:00');
Carbon::setTestNow($now); // fixes now() to always return one value

Explicitly setting dates in your tests not only eliminates flakiness but also improves code readability, aiding comprehension for future maintenance.

Daylight Saving Time (DST) Challenges

Another unexpected source of test flakiness is Daylight Saving Time changes, which can affect time calculations in your tests. For example, let’s say your tests are pegged to Canada’s DST schedule.

UTC time differs by 1 hour.

If your test suite runs on different dates around the DST change, you might experience time calculation discrepancies. The solution? Again, use explicit dates and times in your tests, and keep them constant across the test case.

App & Database Timezone

As an added layer of reliability, consider setting the default timezone to UTC in both your Laravel application and your database.

In PHP, you can verify the server’s timezone setting like so:

echo date_default_timezone_get();

Database timezone checks vary by engine. In a PostgreSQL database, you can check the current time zone setting by executing the following query:

SHOW timezone;

In MySQL, you can check the current time zone setting by executing the following query:

SHOW GLOBAL VARIABLES LIKE 'time_zone';

In summary, the key to a reliable, time-sensitive test suite is consistency. By explicitly setting date-time values and sticking to a standard timezone, you eliminate variables that can make your tests flaky and hard to debug.

I hope this was helpful. Good luck, and happy engineering!

For part one of Laravel Testing series check here.