Outside-In Development

Outside-in development is a methodology that focuses on customer needs by solving for core business flows in the fashion expected for use. In the case of a web application, for example, development would first begin with solving for the set of interactions with a webpage and work backwards into domain models and other abstractions.

Within the realm of testing, we follow this pattern by:

  1. Writing an end-to-end test asserting behavior through a web browser
  2. Writing implementation to get that test to pass
  3. Along the way, writing integration or unit tests to replicate the failure at the level above
  4. Once the integration or unit test passes, re-run the end-to-end test
  5. Continue this process until the end-to-end test passes

Example

Imagine a scenario where we display a birthday message for a member of the site.

We might first write an end-to-end test as:

require "rails_helper"

RSpec.describe "Wishing the user a happy birthday" do
  it "with their age" do
    travel_to Date.new(2023, 1, 1) do
      member = create(:member, born_on: Date.new(1983, 1, 1))
      sign_in_as(member)

      expect(page).to have_notification("Happy 40th birthday!")
    end
  end
end

With an empty ActiveRecord model:

class Member < ApplicationRecord
end

And the view:

<% if current_member.birthday? %>
  <p class="notice">Happy <%= current_member.age.ordinalize %> birthday!</p>
<% end %>

We'd see NoMethodErrors for both Member#birthday? and Member#age. Until this point, we've been working in the "outside" portion of outside-in development. Because we can now write tests at the unit level, we can implement a test (and implementation) for Member#birthday?:

require "rails_helper"

RSpec.describe Member do
  describe "#birthday?" do
    it "returns true if the month and day are the same as today" do
      travel_to Date.new(2023, 1, 1) do
        member = Member.new(born_on: Date.new(2020, 1, 1))

        expect(member.birthday?).to be_true
      end
    end

    it "returns false if the month and day are different than today" do
      travel_to Date.new(2023, 1, 1) do
        member = Member.new(born_on: Date.new(2020, 1, 2))

        expect(member.birthday?).to be_false
      end
    end
  end
end

The implementation:

class Member < ApplicationRecord
  def birthday?
    Date.current.month == born_on.month && Date.current.day == born_on.day
  end
end

Next, we'd re-run the end-to-end test and see a NoMethodError for Member#age, write the unit test and implementation (the "in" of outside-in development), see the unit test pass, and finally see the end-to-end test pass.

While this process might sound tedious, it helps drive implementation and correct behavior for only what's necessary for the code to work as expected.