How to build a ruby development environment

How to build a ruby development environment

The beta release date for project Feijoa was moved up 4 weeks. So that gives Kaya and the project Feijoa team only 6 more weeks to complete the beta. C01t, Nick, and Vijay, from the JAM team will pitch to help meet the new deadline. The programming language for project Feijoa is ruby. Since C01t has no experience with ruby, Kaya is his buddy for the first week on the project. “I’ll show you how the project is organized and how the ruby development environment works”, Kaya tells C01t. “Then you can take the rest of the day to setup and poke around the code.”

“Like all other teams here at Guava Orchards, we have fully Docker-ized our build and development environment”, explains Kaya. ruby projects use rake as the build utility instead of make. However, since rake requires ruby to run, project Feijoa has a Makefile wrapper to execute rake tasks inside the devbox container. C01t decides to try out this magical wrapper. So he types make -- -T in the terminal. As a result, the rake -T command executes inside the container and displays all tasks define in the Rakefile. This is awesome. C01t can run unit-tests and lint tools on his changes without having to install ruby natively.

“We use RSpec to write all our tests, simplecov to collect code coverage numbers, and RuboCop for linting and coding style enforcement”, Kaya informs C01t. First C01t gets a quick overview of the tools by reading the “Getting started guide” for project Feijoa. Then he checks out the configuration files at root of the project. C01t notices that .simplecov sets the minimum coverage threshold to 95%. In addition, he spots that .rspec randomizes specs execution order. Finally, he discovers that .rubocop.yml disables the BlockLenght cop for spec files.

“Another important tidbit of information for you to know is, that we use unbuilt gems to organize the codebase of project Feijoa into loosely coupled components”, Kaya tells him. Each top level folder is a component. Therefore, the file and folder structure inside a top level folder looks like a typical gem folder. There is a *.gemspec file and a Gemfile. There is a lib folder, a spec, and sometimes a bin folder.


x/
   .rspec
   .rubocop.yml
   .simplecov
   Gemfile
   Gemfile.lock
   Makefile
   Rakefile
   chairman/
      lib/
      spec/
      chairman.gemspec
      Gemfile
   dev/
      ...
   housekeeper/
      bin/
      lib/
      spec/
      housekeeper.gemspec
      Gemfile
   ...

The ruby development environment

Let us explore how create a ruby development environment for a project using un-built gems. The code samples are representative of what the files would look like for project Feijoa.

Dockerize

Above all we want to enable development of a ruby project like Guava Orchards project Feijoa without requiring a native installation of ruby. Therefore, we aim execute all specs, lint-ing tools and the service or app inside Docker containers. To create the scripts that build and run the containers we will follow the steps in our post: The case of the missing development environment. Therefore, we create the same structure under the dev folder.


dev
  docker
     docker-compose.yml
     devbox
       Dockerfile
       ...

However, this time we use ruby:latest as the base image instead of python:3.6.

rake via make

Because, every ruby development environment should employ rake as its build utility, we have to provide an easy way to execute rake tasks. Running rake requires ruby. Hence all tasks must run in the container. On the other hand, running make only needs the binary to be in a folder on your path. Therefore, we built a simple Makefile executes rake tasks inside of the devbox container instead of its own targets.


DEVBOX_CONTAINER=x_devbox

ifdef NO_DOCKER
  CMD = $(1)
else
  CMD = docker exec -t $(DEVBOX_CONTAINER) bash -c "cd /src && $(1)" 
endif

.DEFAULT:
  $(call CMD, bundle)
  $(call CMD, rake $@)

.PHONY: bundle
bundle:
  $(call CMD, bundle)

The value assigned to DEVBOX_CONTAINER is the name of the container started by the docker-compose.yaml file you created in the dev folder. Furthermore, the devbox container must mount the root of the project folder under /src.

So now if you have a rake task called unit-test, you can execute it via the command make unit-test. (If you copy your make binary and rename it to rake you can run the command as rake unit-test.) Furthermore, you can pass flags to rake by inserting -- before the flags you want passed on. So to see all rake task you would run make -- -T.

Finally, you define rake tasks as you normally would. You can implement them directly in the Rakefile or in *.rake files.

Un-built gems

To effectively work with the un-built gems we create a Gemfile in the root of the project and add all components as dependent gems. We also need to add the test only dependencies of the components explicitly into the root Gemfile. Therefore, the Gemfile in the root folder of project Feijoa would look something like:


source "http://rubygems.org"

group :development, :test do
  path "." do
    gem "chairman"
    gem "housekeeper"
  end

  gem "rake"
  gem "rspec"
  ...
end

Next we define a couple of rake tasks to run specs and lint tools from the root folder. For RuboCop we can use the task defined in the gem:


require "rubocop/rake_task"

desc "Default RuboCop task"
RuboCop::RakeTask.new(:rubocop)

Since all specs are implemented in folders nested inside the component folders, running those specs from the root is a tad more tricky. However, we can still make use of the task implementation from the RSpec gem:


require "rspec/core/rake_task"

components = %i[chairman housekeeper]
namespace :unittest do
  components.each do |component|
    desc "RSpec task for #{component}"
    RSpec::Core::RakeTask.new(component) do |t|
      test_dir = Rake.application.original_dir
      t.rspec_opts = [
        "-I#{test_dir}/#{component}/spec",
        "-I#{test_dir}/#{component}/spec/support",
        "-I#{test_dir}/#{component}/lib",
        "{test_dir}/#{component}",
      ]
    end
  end
  
  # other tasks in namespace unittest
end

Finally, with the tasks in place configuring RuboCop, RSpec, and simplecov is straight forward. We can just drop the .rubocop.yml, .rspec, and .simplecov files into the root folder.

Congratulations. You should now have a functioning ruby development environment.

Let me tell you why I love RSpec

Let me tell you why I love RSpec

Carefully balancing his tray so as not to spill the cup of soup, C01t walks slowly over to his teams lunch table. As he sets the tray down on the table he hears Kaya say: “Ian, let me tell you why I love rspec and why I look for similar test frameworks in other programming languages!” C01t wonders what this is all about. Leaning over so as to not disturb the ongoing conversation he asks Nick: “What is rspec?”. “rspec is the BDD test framework for Ruby“, replies Nick. C01t settles into his seat, as Kaya starts to lay out her argument. This is one debate he does not want to miss.

Nested test groups

rspec‘s DSL is a powerful tool for organizing test cases, aka examples. You can declare test groups with the methods describe and context. Use test groups to associate tests that verify related functionality or share the same execution context. Furthermore, you can nest test groups. Nested groups are essentially sub classes of the outer groups and provide the expected inheritance semantics. There is no limit on the depth of the nesting.

To illustrate how to best use the describe and context, let’s consider the following class:


class Froyo
  def add_toppings(toppings)
    ...
  end

  def price(coupon)
    ...
  end
end

First you define a describe with the Froyo as the parameter to identify the class under test. Then, for each method of Froyo you add a nested describe with a string containing the method name as the parameter. (Instance method names should be preceded by "#". Class method names should be preceded by ".".) Finally, we define a nested context for each relevant scenario.

You can implement tests inside any of the describe or context blocks. As a result, when reading this spec you can quickly identify what functionality and scenario is being tested. Additionally, when you need to add additional tests for any of the methods it is obvious where to insert them.

The complete spec file for the Froyo class would look something like:


describe Froyo do
  describe "#add_toppings" do
    # add scenarios and test cases
  end

  describe "#price" do
    it "returns a number >= 0" do
      # some test code
    end

    context "with no toppings" do
      it "costs $4.5" do
        # some test code
      end
    end

    context "with 2 toppings" do
      it "costs $5.0" do
        # some test code
      end

      it "cost $4.5 with free toppings coupon" do
        # some test code
      end
    end

    # more contexts
  end
end

Hierarchical before and after

Like most other test frameworks, rspec provides before and after hooks for performing setup and tear down. And similarly, you can scope the hooks to either a single test case (before(:each) and after(:each)), a group of test cases (before(:all) and after(:all)), or the entire run (before(:suite) and after(:suite)).

But what distinguishes rspec from most other test frameworks, is that you can define hooks inside any test group as well as in a rspec configure (a global configuration section). And for a given test, rspec will find and execute all applicable setup and tear down methods. Therefore, you can decompose test setup and tear down cleanly between the nested test groups.

Finally, consider the following example spec:


describe Froyo do
  before(:all) { puts "Froyo one-time setup" }
  after(:all) { puts "Froyo one-time tear down" }

  before(:each) { puts "Froyo setup" }
  after(:each) { puts "Froyo tear down" }

  describe "#price" do
    before(:all) { puts "price one-time setup" }
    after(:all) { puts "price one-time tear down" }

    before(:each) { puts "price setup" }
    after(:each) { puts "price tear down" }

    it "costs at least $0" do
      puts "costs at least $0"
    end
   
    it "validates the type of the coupon parameter" do
      puts "validates the type of the coupon parameter"
    end 

    # more scenarios and tests
  end
end

If you run the above spec file, you will get the following output:

Froyo one-time setup
price one-time setup

Froyo setup
price setup
costs at least $0
price teardown
Froyo tear down

Froyo setup
price setup
validates the type of the coupon parameter
price teardown
Froyo tear down

price one-time teardown
Froyo tear down

rspec invoked all relevant setup and tear down blocks, without blocks nested in the same group as the tests explicitly referring to blocks in the parent groups.

Memoized subject and let helpers

You can use subject and let declarations inside test groups to replace local test variables with methods whose return values are memoized. The values returned by the subject and let declarations are allocated on first use. Since the return values are memoized, the methods can be used repeatedly within a test.


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { %w[sprinkles 'gummy bears'] }

  it "costs $4.50 without toppings" do 
    expect(froyo.price).to eq(4.50)
  end

  it "costs $5.00 with 2 toppings" do
    froyo.add_toppings toppings
    expect(froyo.price).to eq(5.00)
  end
end

So the first test above does not incur the penalty of allocating the toppings array. Furthermore, the second test calls froyo twice and received the same object.

Additionally, both declarations play well with nested test groups, and before(:each) and after(:each) hooks. When executing a test or a before/after hook referencing a method declared via subject or let, rspec searches the test group hierarchy and invokes the method closest to the test.


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  it "costs $4.50 without toppings" do 
    expect(froyo.price).to eq(4.50)
  end

  context "with 2 toppings" do
    let(:toppings) { %w[sprinkles 'gummy bears'] }

    it "costs $5.00 with 2 toppings" do
      expect(froyo.price).to eq(5.00)
    end
  end

  # more scenarios and tests
end

First, you used froyo and toppings inside a before(:each) hook. Then you overwrote let(:toppings) inside the context "with 2 toppings". And during execution, for the test inside context "with 2 toppings" when the before hook in the parent group was executed the overridden value of toppings was used.

Finally, putting it all together you can DRY test code using subject and let:


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  describe "#price" do
    let(:coupon) { "FREE_TOPPINGS" }

    context "with no toppings" do
      it "costs $4.50" do
        expect(froyo.price).to eq(4.50)
      end

      it "costs $4.50 with a free toppings coupon" do
        expect(froyo.price(coupon)).to eq(4.50)
      end
    end

    context "with 2 toppings" do
      let(:toppings) { %w[sprinkles 'gummy bears'] }

      it "costs $5" do
        expect(froyo.price).to eq(5.00)
      end

      it "costs $4.50 with a free toppings coupon" do
        expect(froyo.price(coupon)).to eq(4.50)
      end
    end

    # more scenarios and tests 
  end
end

Test case reuse with shared_examples

Finally, you can use the method shared_examples to define test groups that can be nested into multiple other test groups. The shared test groups are scoped based of where they are defined. Therefore, they are available to for inclusion in the group they were defined in or child groups, but not in sibling or parent groups.

You include a shared test group to be evaluated in the context of another test group using the it_behaves_like method.

Consequently, you can use shared groups to execute a common set of tests for each scenario of the price method of the Froyo class:


describe Froyo do
  subject(:froyo) { described_class.new }
  let(:toppings) { [] }

  before { froyo.add_toppings toppings }

  describe "#price" do
    shared_examples "real price" do
      it "costs at least $0" do
        expect(froyo.price).to be >= 0.0
      end
    end

    context "with no toppings" do
      it_behaves_like "real price"
    end

    context "with 2 toppings" do
      let(:toppings) { %w[sprinkles 'gummy bears'] }

      it_behaves_like "real price"
    end

    # more scenarios and tests 
  end
end

Additional Resources

Probably the two most useful resources when using rspec are:
the official documentation and better specs. Most of all, you should review better specs before writing any tests.

In addition all the code in this post is available in our lab-ruby repository.