3

Note: the question "should I test private methods or only public ones?" is a great reference to what I'm asking.

My question is: what is the most practical TDD process for building out a single, bulletproof reliable public method with complex private methods?

I best learn through examples, so here goes:


Chapter 1) Test coverage

Say I have a ruby class that only does one thing, it gives me bacon.

It would probably look something like this:

class Servant
  def gimme_bacon
    # a bunch of complicated private methods go here
  end

  private

  # all of the private methods required to make the bacon
end  

Now I can call servant = Servant.new; servant.gimme_bacon. Awesome, that's all I care about. All I want is my bacon.

But say my servant kind of sucks. That's because he doesn't have any of his private methods yet so gimme_bacon just returns nil. Alright, no problem, I'm a developer, I will give the Servant class all of the right private methods so he can finally gimme_bacon.

In my pursuit for a reliable servant, I want to TDD all of his methods. But wait, all I care about is that he is going to gimme_bacon. I really don't care about all of the steps he has to take as long as I get my bacon at the end of the day. After all, gimme_bacon is the only public method.

So, I write my test like this:

RSpec.describe Servant do
  let(:servant) { Servant.new }

  it "should give me bacon when I tell it to!" do
    expect(servant.gimme_bacon).to_not be_nil
  end
end

Nice. I only tested the public method. Perfect, 100% test coverage. I move on to further develop the gimme_bacon capability with full confidence that it is being tested.


Chapter 2) Writing moar private methods

After some development (unfortunately, not TDD because I'm adding private methods) I might have something like this (in pseudo code):

class Servant
  attr_reader :bacon

  def initialize(whats_in_the_fridge)
    @bacon = whats_in_the_fridge[:bacon]
  end

  def gimme_bacon(specifications)
    write_down_specifications(specifications)
    google_awesome_recipes
    go_grocery_shopping if bacon.nil?
    cook_bacon
    serve
  end

  private

  attr_reader :specifications, :grocery_list

  def write_down_specifications(specifications)
    @specifications = specifications
  end

  def google_awesome_recipes
    specifications.each do |x|
      search_result = google_it(x)
      add_to_grocery_list if looks_yummy?(search_result)
    end
  end

  def google_it(item)
    HTTParty.get "http://google.com/#q=#{item}"
  end

  def looks_yummy?(search_result)
    search_result.match(/yummy/)
  end

  def add_to_grocery_list
    @grocery_list ||= []
    search_result.each do |tasty_item|
      @grocery_list << tasty_item
    end
  end

  def go_grocery_shopping
    grocery_list.each { |item| buy_item(item) }
  end

  def buy_item
    1_000_000 - item.cost
  end

  def cook_bacon
    puts "#{bacon} slices #{bacon_size_in_inches} inch thick on skillet"
    bacon.cooked = true
  end

  def bacon_size_in_inches
    case specifications
    when "chunky" then 2
    when "kinda chunky" then 1
    when "tiny" then 0.1
    else
      raise "wtf"
    end
  end

  def serve
    bacon + plate
  end

  def plate
    "---"
  end
end

Conclusion:

In hindsight, that's a lot of private methods.

There could be multiple points of failure because I didn't really TDD any of them. The above is a simple example, but what if the servant had to make decisions, say on which grocery store to go to depending on my specifications? What if the internet was down and he couldn't google, etc.

Yes, you could say that I should perhaps make a subclass, but I'm not so sure. All I want is one class that has one public method.

For future reference, what could I have done better in my TDD process?

binarymason
  • 1,351
  • 1
  • 14
  • 31

2 Answers2

2

I'm not sure why you think because they are private methods they can't be TDD'd. The fact that they are private methods (or 50 different classes) is an implementation detail to the testing of the desired behaviour of your bacon servant.

In order to do all the stuff in the private methods your class must have either

  • dependencies
  • inputs

otherwise it'll just return some bacon as in the first example.

these inputs and dependencies are the key to driving out the tests when you TDD it, even if these inputs result in private methods. You'll still only test though the public interface

So in your second example you have some specifications which you are passing to your class in the gimme_bacon method (ruby not my thing, so excuse any misunderstandings). Your tests might then look like:

When I ask for chunky bacon I should get bacon that's 2" thick
When I ask for kinda chunky bacon I should get bacon that's 1" thick
When I ask for tiny bacon I should get bacon thats 0.1" thick
When I ask for an unsupported bacon chunkyness I should get an error telling me 'wtf'

you can implement this functionality incrementally as you add the tests that define what the desired behaviour of your bacon provider is

when you have to go external to google stuff then you have an interaction with a dependency. Your class should allow these dependencies to be switched (something I believe is simple in ruby) so you can easily test what happens at the boundary of your class. So in your example you might have a recipe finder. You pass this to your class and in your tests you give it

  • one that finds recipes
  • one which doesn't find any
  • one which errors
  • etc
  • etc

each time you write a test stating what you expect the behaviour of your class to be when its dependency behaves in a certain way. Then you create a dependency which behaves in that way and implement the desired behaviour in your class.

All TDD'd, irrespective of if those methods are private.

Sam Holder
  • 32,535
  • 13
  • 101
  • 181
  • I really like how you boiled everything down to either dependencies and inputs. Regarding TDD, I guess the red to green time is just longer. If I were to start programming the Servant class, I could make those expectations about the bacon and whether it meets the specifications. The time between writing that test and actually having a passing implementation would be quite lengthy (as opposed to the quick response cycle of public methods). Am I understanding this correctly? – binarymason Sep 10 '16 at 18:06
  • Say if I had an instance where instead of external dependencies the Servant did a bunch of data manipulation and calculations based off of an input via private methods. I would write a simple test first, but it might take me quite a few minutes to have a passing implementation, – binarymason Sep 10 '16 at 18:08
  • How long before you have a passing implementation depends on how complicated the implementation is. But you tend to do these tests one at a time and add to the implementation incrementally, so usually it's not long periods of time. Often the private methods only appear during the refactoring stage anyway. In the implementing (green) stage you're more focused on getting this working than how is going to look at the end – Sam Holder Sep 10 '16 at 19:47
1

When a class gets very complicated, it's probably time to break it up by delegating pieces to some subordinate classes. Think about the Single Responsibility Principle. The main class has the responsibility to orchestrate the bacon process, there's a class to look up recipes, etc. Each subordinate class can be TDD'ed through a public method that includes all the different variations of its behavior. For the main class I'd just do a few integration tests to make sure everything is wired together properly.

Mike Stockdale
  • 5,256
  • 3
  • 29
  • 33
  • Whilst generally this is sound is be wary about individually testing the classes you break out. Doing this too much gives you very brittle tests. A unit is not necessarily a single class, but can be many classes which work together to provide some functionality. My general rule of thumb for this is only write specific tests for a piece that had been refactored into a separate class if I'm going to reuse it in another place. If it's only needed for this current piece of functionality then I'll leave it being tested by the tests for the current bit of functionality. YMMV. – Sam Holder Sep 10 '16 at 20:50