Organic Test Driven Development

Sign reading "organic and free range" on a stand behind a carton of eggs. Inside each egg is an application.
Image by Annie Ruygt

Test-driven development sounds like a really “heavy”, dogmatic way of building applications, but it can be surprisingly organic and lightweight starting with “does it run?” as the first test, followed by more formal unit test verification.

When I set out to write a new form builder library for Rails called Superform, I started with what I wanted the code to look like to the developer who would be building Rails forms with it, so I wrote a little piece of code.

Superform :user, object: Object.new do |form|
  form.field(:name)
  form.field(:email)
  form.collection(:addresses) do |address|
    address.field(:street)
    address.field(:city)
    address.field(:state)
  end
end

I save that to superform.rb and run it with my first test framework, Ruby!

$ ruby superform.rb
# Superform class not defined

Turns out a unit testing framework is not needed for the very first iterations of test-driven development. If the compiler or interpreter doesn’t run it, the test failed and the compiler or interpreter will say why. If it runs, the tests pass.

Minimal Viable Executable Product

Once I have what I want the code to look like, I add above it just enough code to get it running in Ruby. In this case I create a classes and a few methods that runs inside Ruby without raising a runtime error.

class Superform
  def initialize(key, object:)
    @key = key
    @object = object
    yield self if block_given?
  end

  def field(...)
  end

  def collection(...)
  end
end

def Superform(key, **kwargs, &)
  Superform.new(key, **kwargs, &)
end

form = Superform :user, object: Object.new do |form|
  form.field(:name)
  form.field(:email)
  form.collection(:addresses) do |address|
    address.field(:street)
    address.field(:city)
    address.field(:state)
  end
end

p form

I run it again from my console.

$ ruby superform.rb
#<Superform:0x0000000102fb47c8 @key=:user, @object=#<Object:0x0000000102fb48b8>>

No errors! OK, you get the point. You can get pretty far by using the runtime, compiler, or interpreter as the initial test runner.

There will be a point where running Ruby isn’t enough to verify the the code being written does what it is suppose to do—when this point is reached its a good idea to reach for a unit testing framework.

Units tests, dependencies, README, and implementation in one file

When I get Ruby running without error, I add a few things to my file to streamline my next set of iterations.

  1. Dependencies - The equivalent of a Gemfile can be included in the same file as the unit tests. In my example, I include RSpec. I would also include other gems my library depends on if it were applicable.

  2. Implementation - The code I started out with above ends up between the dependencies and unit tests. Order matters here since the file is evaluated from top to bottom.

  3. Unit tests - I prefer RSpec, but you can use any runner that supports running tests from the same file as the implementation. The unit tests should be all of the code that calls your implementation. You can think of it as the public API that demonstrates how others should be using your code.

    I also am getting more specific with the data I’m passing to my implementation by changing the object I pass into the object: parameter from Object.new to Struct.new(:name, :email).new("Brad", "brad@example.com"). The Date class in Ruby 3.2 is an excellent way to make objects more concrete.

  4. Problem statement - At the very bottom of a Ruby file the __END__ directive tells Ruby, “this is the end of the file, stop here!”. Below that I like to write the problem my library is trying to solve as clearly as possible. I find if I start going down a tangential rabbit hole, this can bring me back and keep me focused.

Here’s what it looks like when I put my gem dependencies, implementation, and unit tests together in the same file.

# Inline bundler resolves dependencies before the application runs.
require 'bundler/inline'

gemfile do
  source 'https://rubygems.org'
  gem 'rspec'
end

require "rspec/autorun"

# The implementation of the library I'm working on.
class Superform
  def initialize(key, object:)
    @key = key
    @object = object
    yield self if block_given?
  end

  def field(...)
  end

  def collection(...)
  end
end

def Superform(key, **kwargs, &)
  Superform.new(key, **kwargs, &)
end

# Unit tests that describe how my library should work.
RSpec.describe Superform do
  let(:user) { Struct.new(:name, :email).new("Brad", "brad@example.com") }
  subject do
    Superform :user, object: user do |form|
      form.field(:name)
      form.field(:email)
      form.collection(:addresses) do |address|
        address.field(:street)
        address.field(:city)
        address.field(:state)
      end
    end
  end
  it { is_expected.to be_instance_of(Superform) }
end

# Problems the library solves, which forms the basis of my README.

__END__

Superform addresses various limitations of Rails form builders including:

1. Superform can permit its own parameters - you don't need to use Strong Parameters if you ...

2. Customize with Phlex components - Rails form builders are somewhat limited in their abilities and force you to constantly switch between Erb and classes...

Just like before, I run the file with ruby.

$ ruby superform.rb

This time I get RSpec output.

$ ruby superform.rb

Superform
  example at superform.rb:42 (FAILED - 1)

Failures:

  1) Superform
     Failure/Error:
       def initialize(key, object:)
         @key = key
         @object = object
         yield self if block_given?
       end

     ArgumentError:
       missing keyword: :object
     # superform.rb:11:in `initialize'
     # superform.rb:21:in `new'
     # superform.rb:21:in `collection'
     # superform.rb:35:in `block (3 levels) in <main>'
     # superform.rb:14:in `initialize'
     # superform.rb:26:in `new'
     # superform.rb:26:in `Superform'
     # superform.rb:32:in `block (2 levels) in <main>'
     # superform.rb:42:in `block (2 levels) in <main>'

Finished in 0.00056 seconds (files took 0.03982 seconds to load)
1 example, 1 failure

Failed examples:

rspec superform.rb:42 # Superform

Despite failing tests, this is progress! The dependencies for the project and test framework were installed and we now see the output of the tests. The documentation or README at the bottom will keep me focused on the problem I originally set out to solve.

There’s still a lot of work ahead of us to develop the remaining functionality of this library, but we have some awesome guard rails in place that are not too cumbersome.

Interactive development with a REPL

What’s a REPL? Why it’s a Read-eval-print-loop of course! Which explains nothing.

A REPL is a fancy way of saying, “you can interact with your program from a console”. Ruby comes with IRB, which stands for “interactive Ruby”, which is an excellent way to stop your program while running it and see what’s going on with it. Pry is another excellent Ruby REPL, but it has to be installed as a seperate gem.

Anytime you want a REPL to play with the object or debug something, add binding.irb to the context. Let’s add it to line 21 in our program since the spec is failing there.

  def collection(...)
    binding.irb
    self.class.new(...)
  end

The binding keyword is the context of a program. In this case, we could expand it out to self.binding, which is a way of saying, “give me the context of this program for this instance”. The irb at the end of that tells Ruby to give us an interactive Ruby prompt for the instance. Let’s run it and see what happens.

$ ruby superform.rb

Superform

From: superform.rb @ line 21 :

    16:
    17:   def field(...)
    18:   end
    19:
    20:   def collection(...)
 => 21:     binding.irb
    22:     self.class.new(...)
    23:   end
    24: end
    25:
    26: def Superform(key, **kwargs, &)

Ruby shows us where we are in the source code. Let’s try running self.class.new(…) to see what’s wrong.

irb(#<Superform:0x00000001052bd5f8>):001:0> self.class.new(...)
superform.rb:11:in `initialize': missing keyword: :object (ArgumentError)
  from /Users/bradgessler/Projects/sitepress/sitepress/superform.rb:1:in `new'
  from /Users/bradgessler/Projects/sitepress/sitepress/superform.rb:1:in `collection'
  from <internal:prelude>:5:in `irb'
  from superform.rb:21:in `collection'
  from superform.rb:36:in `block (3 levels) in <main>'
  from superform.rb:14:in `initialize'
  from superform.rb:27:in `new'
  from superform.rb:27:in `Superform'
  from superform.rb:33:in `block (2 levels) in <main>'
  from /Users/bradgessler/.rbenv/versions/3.2.1/lib/ruby/gems/3.2.0/gems/rspec-core-3.12.2/lib/rspec/core/memoized_helpers.rb:343:in `block (2 levels) in let'
  ... 20 levels...

Looks like we’re missing the object: parameter when creating a new instance of the class. Let’s just pass it a generic Object.new for now so we can move on. We might have to come back and change it later.

irb(#<Superform:0x00000001052bd5f8>):002:0> self.class.new(:test, object: Object.new)
=> #<Superform:0x0000000108674388 @key=:test, @object=#<Object:0x00000001086745b8>>

Yup! That works. Now I update the code.

  def collection(key, object: Object.new)
    self.class.new(key, object: object)
  end

Then run it to see if the tests pass.

ruby superform.rb

Superform
  is expected to be an instance of Superform

Finished in 0.00089 seconds (files took 0.04426 seconds to load)
1 example, 0 failures

Success! I now have RSpec running tests inside of a Ruby script and am in a spot where I can write more tests to get a feel for the public APIs.

# ... our file ...

RSpec.describe Superform do
  let(:user) { Struct.new(:name, :email).new("Brad", "brad@example.com") }
  subject do
    Superform :user, object: user do |form|
      form.field(:name)
      form.field(:email)
      form.collection(:addresses) do |address|
        address.field(:street)
        address.field(:city)
        address.field(:state)
      end
    end
  end
  it { is_expected.to be_instance_of(Superform) }
  describe "root" do
    it "assigns object" do
      expect(subject.object).to eql user
    end
    it "assigns key" do
      expect(subject.key).to eql :user
    end
    it "has 2 fields"
    it "has 1 collection"
  end
end

When I run it I see some tests are passing and others are pending.

ruby superform.rb

Superform
  is expected to be an instance of Superform
  root
    has 2 fields (PENDING: Not yet implemented)
    has 1 collection (PENDING: Not yet implemented)
    has user object

Pending: (Failures listed here are expected and do not affect your suite's status)

  1) Superform root has 2 fields
     # Not yet implemented
     # superform.rb:46

  2) Superform root has 1 collection
     # Not yet implemented
     # superform.rb:47


Finished in 0.00125 seconds (files took 0.04051 seconds to load)
4 examples, 0 failures, 2 pending

Now it’s a matter of going back-and-forth between the specs and the implementation to get the whole thing working.

Avoid sunk-cost bias by only writing a few tests, then implementing

At this point it’s tempting to write a bunch of pending or failing tests for the entirety of your application or library, but when you do that you’ll have a much harder time deleting them if you find the design of your code goes in a different direction. Slowly, the test suite will start to feel more like a burden than it is helpful.

To avoid that sunk-cost bias, write a few tests, implement your code against it until the tests pass, commit your work, then rinse and repeat. You’ll find that it’s much easier to pivot the design of your code and do small experiments to see if you like your changes. When you don’t like the changes or direction you wanted to go, its much easier to throw it out and start over by writing a few new tests and implementing against it.

Breaking up the file

Eventually the code needs to be distributed, which means the tests, dependency manifest, and documentation need to be moved out of the same file as the implementation.

In my case I created a gem by running bundle gem superform, then moved the inline RSpec tests into files in the ./spec directory, the inline Bundler manifest into the Gemfile and superform.gemspec, and the problem statement into the opening of the README.md file.

The end result is the Superform Github Repo and Ruby Gem, where I can continue my iteration by using the gem in my own applications and getting feedback from others who use the gem.

Wrap-up

The beauty of this approach is you’re not constantly switching between different files. Everything stays in one place when you start out, which keeps things simple and iterations tight.

It’s also a boon for collaboration. If you get stuck and need some help, you have the dependencies, implementation, tests, and problem statement all in one file, which means you could throw it in a gist and post it on Reddit, Mastodon, X, or whatever to get feedback and help.

When you start writing unit tests, only write enough per iteration that you’d be comfortable throwing out if you find your code is heading in a direction you don’t like. Avoid writing massive test suites that you’d find painful to throw out if you find your code is heading in the wrong direction.

As complexity grows and you feel like you’ve nailed an API, you can move the dependencies into its own Gemfile, keep the implementation in its current file, and move the specs out into its own file in the ./spec directory.