When you're trying to test methods on an Eloquent model, you often need to hit the database to really test your code.
But sometimes the functionality you're testing doesn't really depend on database features. Is there any way to test that stuff without hitting the database?
Testing against the database
Say we had Albums
with Songs
and we wanted to test that we could calculate the total duration of an album.
Our test might look something like this:
class AlbumTest extends TestCase
{
use DatabaseMigrations;
public function test_can_calculate_total_duration()
{
// 1. Create an album and save it to the database
$album = factory('Album')->create();
// 2. Build some songs with known lengths
$songs = new Collection([
factory('Song')->make(['duration' => 291]),
factory('Song')->make(['duration' => 123]),
factory('Song')->make(['duration' => 100]),
]);
// 3. Save them through the `songs` relationship to
// set the foreign keys
$album->songs()->saveMany($songs);
// 4. Verify that the album duration matches the
// combined song duration
$this->assertEquals(514, $album->duration);
}
}
A simple implementation of $album->duration
could look like this:
class Album extends Model
{
// ...
public function getDurationAttribute()
{
return $this->songs->sum('duration');
}
// ...
}
If we run this test everything will pass, but at least on my machine, it takes about ~90ms on average to run.
For tests that really need to hit the database for me to verify the behavior I'm trying to verify, I'm totally fine with that.
But in this case, we aren't trying to test a specific set of query scopes, or verify that something is being saved, we just want to sum some numbers!
There must be another way!
In-memory relationships
When you eager load a relationship, using the $album->songs
property shorthand doesn't perform a new query; instead it fetches the Songs
from a memoized property on the Album
.
We can pretend to eager load a relationship manually using the setRelation
method:
public function test_can_calculate_total_duration()
{
$album = factory('Album')->create();
$songs = new Collection([
factory('Song')->make(['duration' => 291]),
factory('Song')->make(['duration' => 123]),
factory('Song')->make(['duration' => 100]),
]);
- $album->songs()->saveMany($songs);
+ $album->setRelation('songs', $songs);
$this->assertEquals(514, $album->duration);
}
Now we can get rid of the DatabaseMigrations
trait and use make
to build our Album
instead of create
, leaving us with a test that looks like this:
class AlbumTest extends TestCase
{
public function test_can_calculate_total_duration()
{
$album = factory('Album')->make();
$songs = new Collection([
factory('Song')->make(['duration' => 291]),
factory('Song')->make(['duration' => 123]),
factory('Song')->make(['duration' => 100]),
]);
$album->setRelation('songs', $songs);
$this->assertEquals(514, $album->duration);
}
}
Running this test still passes, but this time it only takes about 3ms to finish! That's thirty times faster.
Nothing is free
As with anything, there's a cost to using this approach.
If we changed our implementation to sum the song durations in the database, our new test would fail:
class Album extends Model
{
// ...
public function getDurationAttribute()
{
// Notice the extra parentheses!
return $this->songs()->sum('duration');
}
// ...
}
So while the performance improvements are insane, they come at the cost of coupling us to a particular style of implementation, and make things a little less flexible to refactor.
Either way, it's a cool trick to keep in mind if your test suite is starting to slow down, and often the benefits will outweigh the cost.
Trying to wrap your head around testing? Test-Driven Laravel is a course I recently launched that teaches you how to TDD an app from start to finish. Learn more about it here.