When I first started doing TDD, I always followed the unit testing mantra of testing in isolation.

These days, I've done a full 180. Whenever I start building out features, I start off with the design, then write an integration test, and refactor to units from there.

Preface

This isn't a post about what the Best Practice™ is. This is how we work on our projects.

Let's Build Something

Let's make a feature for Youtube for creators publishing their video.

Let's begin with the user story.

  • As a creator
  • When I click publish on my video
  • My video becomes public
  • And all of my subscribed users that have clicked the notification bell are notified.
  • Our Test

    The first test I tend to write is everything the feature needs to do.

    It's usually extremely simple but it starts getting a tad bit bigger when there's integrations like mail, events, or broadcasting involved.

    /** @test **/
    function make_the_video_public_and_notify_all_notifiable_subscribers()
    {
      Notification::fake();
    
      $creator = User::factory()->create();
      $video   = Video::factory()->create([
        'user_id' => $creator
      ]);
    
      $otherUsers  = User::factory()->times(1)->create();
      $subscribers = User::factory()->times(2)->create()
        ->each->subscribe($creator);
    
      $notifiableSubscribers = User::factory()->times(3)->create()
        ->each->subscribe($creator)
        # I know, it's too long but I can't think of anything else.
        ->each->turnOnNotificationsFor($creator);
    
      $this
        ->actingAs($user)
        ->patch('/creator-studio/video/1/publish')
        ->assertRedirect('/watch?v=1');
    
      Notification::assertSentTo($notifiableSubscribers, VideoPublished::class);
      Notification::assertNotSentTo($subscribers, VideoPublished::class);
      Notification::assertNotSentTo($otherUsers, VideoPublished::class);
    }

    Looking at the tests, I think it's expressive enough that someone new who jumps into it will have a clear understanding on what needs to happen.

    For clarity of the tests though we're:

    • NOT notifying all the users.
    • NOT notifying all the subscribers.
    • ONLY notifying all the subscribers that have turned the notifications on.

    Next up is I write the feature without any abstraction on the controller.

    # PublishVideoController.php
    public function __invoke(Video $video)
    {
      $video->update(['published_at' => now()]);
    
      Notification::send(
        $video->user->subscribers()->where('should_notify', true)->get(),
        new VideoPublished($video)
      );
    
      return redirect()->route('videos.show', $video);
    }

    From the get go, I don't immediately write any validation, authorization, and I avoid any abstraction to a different file. (Maybe move the query into a private method, but that's a write up all together.)

    All I do is write everything I need to make the first test pass.

    On small projects, I sometimes don't refactor to a model. It's a per case to case basis.

    Let's Start Refactoring!

    Now that we have our feature, let's beging writing our unit tests.

    Please note, whhen I say unit tests, I don't really mean isolated unit tests. I'm pretty liberal (and argually incorrect) about the term, but what I instead mean is testing a single method that affects no other code I've written, not code that's integrated with the framework.

    So these specific "unit tests" will still hit the database.

    Now that's out of the way, let's refactor changing the video's publish date.

    # Video.php
    public function publish()
    {
      $this->update(['published_at' => now()]);
    
      return $this;
    }
    
    # VideoTest.php
    public function update_the_published_at_date_to_the_current_time()
    {
      // published_at => null
      $video = Video::factory('unpublished')->create();
    
      $video->publish();
    
      $this->assertNotNull($video->published_at);
    }
    
    # PublishVideoController.php
    public function __invoke(Video $video)
    {
      $video->publish();
    
      Notification::send(
        $video->user->subscribers()->where('should_notify', true)->get(),
        new VideoPublished($video)
      );
    
      return redirect()->route('videos.show', $video);
    }

    A simple change but a readable one.

    We've changed the controller without having to change our test.

    If we did test in isolation, we'll also have to change our $video->shouldReceive('update') to $video->shouldReceive('publish').

    Okay then, next, let's make a dedicated relationship for subscribers that have clicked that bell!

    # User.php
    public function notifiableSubscribers($query)
    {
      return $this->subscribers()->where('should_notify', true);
    }
    
    # UserTest.php
    public function get_all_subscribers_that_want_to_be_notified()
    {
      $creator = User::factory()->create();
    
      // Random Users
      $otherUsers = User::factory()->times(1)->create();
    
      // Subscribers
      $subscribers = User::factory()->times(2)->create()
        ->each->subscribe($creator);
    
      // Subscribers that has clicked the notification bell.
      User::factory()->times(3)->create()
          ->each->subscribe($creator)
          ->each->turnOnNotificationsFor($creator);
    
      $this->assertCount(3, $creator->notifiableSubscribers);
    }
    
    # PublishVideoController.php
    public function __invoke(Video $video)
    {
      $video->publish();
    
      Notification::send(
        $video->user->notifiableSubscribers,
        new VideoPublished($video)
      );
    
      return redirect()->route('videos.show', $video);
    }

    Now we're getting somewhere! We've removed a pretty huge query on the controller, but let's do more.

    If you've noticed earlier, we've left the room for chainability on the publish method by returning $this. Let's work on top of that.

    # Video.php
    public function notifySubscribers($query)
    {
      Notification::send(
        $this->user->notifiableSubscribers,
        new VideoPublished($this)
      );
    }
    
    # PublishVideoController.php
    public function __invoke(Video $video)
    {
      $video->publish()->notifiableSubscribers();
    
      return redirect()->route('videos.show', $video);
    }

    I could write a unit test for that method, but personally, I won't since the Notification has already been tested within the framework.

    What's Next?

    Well, after writing the whole functionality of the feature, I start testing the validation and authorization.

    Maybe something like:

    function only_the_owner_and_collaborator_are_allowed_to_publish_the_video()
    {
      $notACollaborator = User::factory()->create();
      $video = Video::factory('unpublished')->create();
    
      $this
        ->actingAs($notACollaborator)
        ->patch('/creator-studio/video/1/publish')
        ->assertUnauthorized();
    }
    
    function __invoke(Video $video)
    {
      $this->authorize('publish', $video);
    
      // ...
    }

    From the top of my head, here are the other more things I might end up writing:

    • collaborators_can_publish_the_video
    • a_published_video_cant_be_republished

    Caveats

    It's Slower

    Since we're hitting the database, it's objectively slower compared to running tests in isolation.

    Personally though, I"m willing to make that trade off since most of the projects we handle only have a couple of hundred to a few thousand tests.

    Since I'm only running the complete suite after the feature is finished and through the CI, it won't be too painful.

    Benefits

    Less Changes to the Test

    When I started doing isolated tests, it started to become a glorified line checker.

    When I start to try a different implementation on the same feature, I always end up having to refactor my tests as well.

    Make sure this feature runs this method, then this, and this, and this.

    By having an integration test instead, even if does heavily depend on other things, I tend to check if the database contains the data, or the changes necessary.

    I change the feature test when the feature changes, not when the implementation changes.

    Wiggle Room

    Doing feature tests allows me to scale up my code very slowly.

    There's room to let the login hang out in the controller if I decide to jump into a different feature for now.

    There may be times that I want to write all the base features and do some refactor on the second sprint.

    Avoiding Wrong Abstractions

    When I started writing isolated tests, the planning was more dependent on guess work.

    There were cases that the backend was already finished even before any user experience or basic design has been done.

    The problem I have with this is that when the backend comes first, the backend drives the design of the application rather than the other way around.

    Or the team I've worked with end up using the wrong parameters that was being passed from the front-end, causing a rewrite on the return, or the method parameters.

    Wrong abstractions are expensive.

    It's like creating a design for a website that never had any content to provide. We'll design it as beatuifully as we can but once the client provides a single sentence on a 3 paragraph layout, it doesn't become a good fit.

    The point I want to get across is that, when I started working with unit tests first, I always seem to have made more bridges between my models so they can properly work together, but doing it from the highest level going down, I get to abstract to different parts of the code base when necessary.

    I hope any of that made sense.