Stubbing Eloquent Relations for Faster Tests

Stubbing Eloquent Relations for Faster Tests

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.