Benito Serna Tips and tools for Ruby on Rails developers

Is easier to do TDD for your dependencies after you have designed their API using mocks

November 21, 2018

I have already told you that when you are doing TDD with a use case approach, is better to use mocks to design your dependencies instead of been tight to a given interface, because this can help you to decouple from your dependencies following the Dependency Inversion principle.

The problem with using mocks, is that sometimes it could be easy to implement those dependencies and feel confident that you can write them and maintain them without tests… But other times you just don’t feel that confidence, you feel that a little upgrade or change in the code can completely broke you app!

The good news are that is not all or nothing!… You can use mocks in the tests for your use cases, to make your test cases simple and fast!… And test drive your dependencies using integration tests when you feel is needed!… Some people or teams, will prefer to write integration tests for all dependencies and other for just some of them.

But the difference between using mocks first to design your dependencies and directly using a dependency you already have, is that if you start with the use case and design your dependencies there… After finishing of testing your use case, you have already designed an API and maybe an specification for your dependencies… That means that you already now what tests to write!.

This can help you to avoid “just in case code”, because now you know that you don’t need to re-implement a database, you just need to find_all_users_with_articles or that you don’t need to handle the whole API of a Geolocation service, you just need to find_city_for(lat, lng)

Don’t you think that knowing what to test and define the interface of your components is one of the hardest things while doing TDD?… Well now you have one way to avoid this problems!

Now a little example…

This is a simplified version of some real app code…

Imagine that we have the concept of “development projects” that we are going to call projects… And we have the task to show the city for each project in the list of projects.

So we want to be able to do something like this in the view…

projects.map do |project|
  project.city
end

# => [
#   "Monterrey, Nuevo León",
#   "Ciudad de México, México",
#   "Corregidora, Queretaro",
#   ...
# ]

But we don’t have the city stored in the project record, what we have is the latitude and longitude.

To specify and test this in our use case, we can have something like this…

module Projects
  RSpec.describe "Get projects list" do
    class FakeCityGeocoder
      def self.call(lat, lng)
        "City geocoded from lat: #{lat}, long: #{lng}"
      end
    end

    def city_geocoder
      FakeCityGeocoder
    end

    def get_projects_list(store, opts = {})
      Projects.get_projects_list(store, opts)
    end

    #...
    describe "each project" do
      #...

      describe "has the project city..." do
        it "geocoded from the lat-lng" do
          record = project_with(latitude: 1234, longitud: 2345)
          store = projects_store_with([record])
          project = get_project_list(store, city_geocoder: city_geocoder).first
          expect(project.city).to eq "City geocoded from lat: 1234, long: 2345"
        end
      end
    end
  end
end

Here we are proposing a dependency that will give us just what we need, expecting our use case code to only be responsible of calling that dependency with the right latitude and longitude.

Now we can use TDD, to write a “real” CityGeocoder

… For this dependency, in the real app, we wrote some integration tests doing TDD for the “real” CityGeocoder using VCR to capture the responses of Geocoding Service, and we ended with some tests like…

RSpec.describe CityGeocoder do
  POSITIONS = {
    "cdmx" => { lat: "19.4062241", lng: "-99.1733785" },
    "queretaro" => { lat: "20.5510303", lng: "-100.4288009" }
    #...
  }

  def city_for(lat, lng)
    CityGeocoder.call(lat, lng)
  end

  describe "when no lat-lng..." do
    it "returns Ciudad de México" do
      city = city_for(nil, nil)
      expect(city).to eq "Ciudad de México, México"
    end
  end

  describe "when lat-lng is from Ciudad de México..." do
    it "returns the city and the state" do
      VCR.use_cassette("city-geocoder-cdmx") do
        position = POSITIONS["cdmx"]
        city = city_for(position[:lat], position[:lng])
        expect(city).to eq "Ciudad de México, México"
      end
    end
  end

  describe "when lat-lng is from Querétaro..." do
    it "returns the city and the state" do
      VCR.use_cassette("city-geocoder-queretaro") do
        position = POSITIONS["queretaro"]
        city = city_for(position[:lat], position[:lng])
        expect(city).to eq "Corregidora, Querétaro"
      end
    end
  end

  #...
end

And an implementation like this…

class CityGeocoder
  def self.call(lat, lng)
    find_city(geocode(lat, lng))
  end

  private

  def self.find_city(json)
    # do some stuff to return the city
    # as needed
  end

  def self.geocode(lat, lng)
    url = "#{ENV["GEOCODER_URL"]}?latlng=#{lat},#{lng}"
    response = RestClient.get(url_for(lat, lng))
    JSON.parse(response.body)
  end
end

Look how although we are using a Geocoding Service through a REST API, our use case does not need to now all that stuff for now…

If later the Geocoding Service changes their API, as we have integration tests, we are going to be able to check if the upgrade breaks our expected behavior.

So, that’s it… If you were feeling insecure about using mocks and letting some complex dependencies without tests, now you know that once you have designed the expected behavior of your dependencies, you can also write integration tests for them… And maybe it could be easier!

Related articles

Weekly tips and tools for Ruby on Rails developers

I send an email each week, trying to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or All post by topic.