Unit Testing our Design Patterns exercise

So, our little exercise in design patterns opens a new window is getting quite messy. Which is ironic, considering it’s an exercise in design patterns.

The reason is that I’m mostly trying to be very focused on the Design Patterns book and just fleshing out the example implementations they provide.

Therefore, in order to organize things, I believe this is the right time to add unit tests. As a plus, I also get to test my little gem in an automated fashion.

Here I’ll only go through the RandomMazeBuilder class since it would be quite lengthy to go through every single file. To see all the other specs, just checkout the repo.

Testing the RandomMazeBuilder

So, our RandomMazeBuilder class looks like this:

module Builders
  class RandomMazeBuilder
    include Directions

    attr_accessor :current_maze, :factories

    def initialize
      @factories = [
        Factories::BombedMazeFactory.new,
        Factories::EnchantedMazeFactory.new
      ]
    end

    def build_maze
      @current_maze = CreationalMaze::Maze.new
    end

    def build_room(room_no)
      raise Errors::InvalidStateError, "Can't build a room without a maze!" if @current_maze.nil?

      return unless @current_maze.room_no(room_no).nil?

      factory = @factories.sample
      room = factory.make_room(room_no)

      factory = @factories.sample
      room.set_side(Directions::NORTH, factory.make_wall)
      room.set_side(Directions::SOUTH, factory.make_wall)
      room.set_side(Directions::EAST, factory.make_wall)
      room.set_side(Directions::WEST, factory.make_wall)

      @current_maze.add_room(room)
    end

    def build_door(from_no, to_no)
      if @current_maze.nil? || @current_maze.rooms.empty?
        raise Errors::InvalidStateError, "Can't build a door without a maze! Call build_maze first."
      end

      room1 = @current_maze.room_no(from_no)
      room2 = @current_maze.room_no(to_no)
      door = Doors::Door.new(room1, room2)

      door_direction = all_directions.sample

      room1.set_side(door_direction, door)
      room2.set_side(opposite_direction(door_direction), door)
    end

    def get_maze
      @current_maze
    end
  end
end

Now, this is a builder class. By what the pattern should be, we know that it has one job: provide methods to build mazes. Buying a random maze builder, it provides these maze parts (rooms and doors) in a random fashion, randomizing the type of maze part. It places things randomly as well, but I was unable to figure out a way to make it place things without it being completely senseless, so I won’t be testing that just yet.

So we have 4 methods we want to make sure that they do what we want them to do: initialize, build_maze, build_room, build_door. We’ll start by defining what initialize should do.

Describing initialization

First thing is to create our spec[1] file:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do

end

RSpec provides us with a few ways to organize our tests. That is the purpose of the context and describe methods. As per RSpec’s docs opens a new window , these methods should be used to describe subclasses of the described class. Here, subclass can mean also a given state we wish to describe. In our case, that state can be “initialization”, for example:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  describe "initialization" do
  end
end

Note that my usage of these methods here is simply trying to keep close to RSpec’s docs, but that’s not the only way (nor the most common) that these methods are used. One could use them to separate specs by class method, for example. Moving on.

So, initialization should work as follows:

  1. Given a new RandomMazeBuilder
  2. When we call RandomMazeBuilder.factories
  3. Then it initializes a non-empty array of factories

Notice that I’m using this “Given, When, Then” structure. Expressing our requirements this way makes it easier to write the necessary test code.

Synthesizing that into a sentence for an it block we can just say:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  describe "initialization" do
    it "provides an array of factories" do
    end
  end
end

So here we just want to translate our 3 statements above into RSpec method calls.

For the first one, all we need to do is effectively initialize a RandomMazeBuilder:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  describe "initialization" do
    it "provides an array of factories" do
      rmb = Builders::RandomMazeBuilder.new
    end
  end
end

Now we just need to check that our instance has the factories it needs:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  describe "initialization" do
    it "provides an array of factories" do
      rmb = Builders::RandomMazeBuilder.new

      expect(rmb.factories).to be_an(Array)
      expect(rmb.factories).not_to be_empty
    end
  end
end

Running the rspec command we get:

$ rspec

Builders::RandomMazeBuilder
  initialization
    provides an array of factories

Finished in 0.00862 seconds (files took 0.76959 seconds to load)
1 example, 0 failures

And that is all. On to the second method.

Building a maze

This one is even simpler than the last. Once again expressing our needs with the “Given, When, Then” format:

  1. Given a RandomMazeBuilder
  2. When we call RandomMazeBuilder.build_maze
  3. It associates a new maze to the builder

In this case, associating means initializing the @creational_maze instance variable. Thus our test becomes:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  describe "initialization" do
    it "provides an array of factories" do
      rmb = Builders::RandomMazeBuilder.new

      expect(rmb.factories).to be_an(Array)
      expect(rmb.factories).not_to be_empty
    end
  end

  describe "#build_maze" do
    it "creates a new maze for the builder" do
      # Given
      rmb = Builders::RandomMazeBuilder.new

      # When
      rmb.build_maze

      # Then
      expect(rmb.current_maze).not_to be_nil
      expect(rmb.current_maze).to be_a(CreationalMaze::Maze)
    end
  end
end

This time I added the comment to make it clear which section of the spec was covered by the each of the “Given, When, Then” statements. As before, there aren’t many requirements for this method other than @current_maze being set and it being an instance of CreationalMaze::Maze.

Running our tests to make sure we’re green we get:

$ rspec

Builders::RandomMazeBuilder
  initialization
    provides an array of factories
  #build_maze
    creates a new maze for the builder

Finished in 0.00838 seconds (files took 0.90918 seconds to load)
2 examples, 0 failures

Trying to add rooms…

Now, the requirements for the build_room method are a bit more complex. Here, I need to make sure the builder is in the correct state. In this case, that means it must have a maze. So we have 2 possible contexts here:

  1. A builder without a maze
  2. A builder with a maze

In RSpec we can organize our specs to reflect that using the aptly named context blocks. Therefore:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_room" do
    context "given there is no current maze" do
    end

    context "given there is a current maze" do
    end
  end
end

Now here we’ll need 2 “Given, When, Then” expressions, one for each context. For the first one:

  1. Given there is no current maze
  2. When I try to build a room
  3. RandomMazeBuilder throws an error

And for the second context:

  1. Given ther is a current maze
  2. When I try to build a room
  3. RandomMazeBuilder adds a room to the current maze.

So let’s work on the first context.

… without a maze.

So the setup here is pretty straightforward, given our previous examples. We first add an it block to express the expected outcome and we just create a RandomMazeBuilder:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_room" do
    context "given there is no current maze" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new
      end
    end

    context "given there is a current maze" do
    end
  end
end

Now, to test that something throws an error, the syntax in RSpec has a slight variation. In it’s simplest form this is how we say we expect a given method call to raise:

# inside an it block
expect { some_object.some_method }.to raise_error

Notice how, instead of passing arguments to expect, we are now passing a block. Which makes sense: we want to execute that block and see if it raises an error, therefore we need to have expect yield to it rather than give it as an argument.

But wait, there’s more!™

We can pass a block to the raise_error method to further test the error that is being raised. This is good, because we don’t want our test to pass should, for some other reason, we get a different kind of error than what we expect. Specifically, we want the error to be an instance of Builders::Errors:InvalidStateError. Therefore, our spec should end up looking like:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_room" do
    context "given there is no current maze" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new

        expect { rmb.build_room(1) }.to raise_error do |error|
          expect(error).to be_a(Builders::Errors::InvalidStateError)
        end
      end
    end

    context "given there is a current maze" do
    end
  end
end

Running our specs to see if everything is ok…

$ rspec

Builders::RandomMazeBuilder
  initialization
    provides an array of factories
  #build_maze
    creates a new maze for the builder
  #build_room
    given there is no current maze
      raises an error

Finished in 0.01279 seconds (files took 0.77251 seconds to load)
3 examples, 0 failures

Awesome! Now we just need to do the same for the other context.

… with a maze.

Here, the setup is the same as before, plus the call to build_maze:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_room" do
    context "given there is no current maze" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new

        expect { rmb.build_room(1) }.to raise_error do |error|
          expect(error).to be_a(Builders::Errors::InvalidStateError)
        end
      end
    end

    context "given there is a current maze" do
      it "adds a room to the current maze" do
        rmb = Builders::RandomMazeBuilder.new
        rmb.build_maze
      end
    end
  end
end

Now we just need to check wether the call to build_room effectively adds one room to current maze’s rooms list:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_room" do
    context "given there is no current maze" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new

        expect { rmb.build_room(1) }.to raise_error do |error|
          expect(error).to be_a(Builders::Errors::InvalidStateError)
        end
      end
    end

    context "given there is a current maze" do
      it "adds a room to the current maze" do
        rmb = Builders::RandomMazeBuilder.new
        rmb.build_maze

        expect { rmb.build_room(1) }.to change { rmb.current_maze.rooms }
      end
    end
  end
end

And, indeed, running the specs:

$ rspec

Builders::RandomMazeBuilder
  initialization
    provides an array of factories
  #build_maze
    creates a new maze for the builder
  #build_room
    given there is no current maze
      raises an error
    given there is a current maze
      adds a room to the current maze

Finished in 0.01341 seconds (files took 0.98407 seconds to load)
4 examples, 0 failures

All that is left is to test our build_door method

Making doors

This is the most complex method in the class. The issue is that doors aren’t added in just any old way and, despite the fact that the current logic doesn’t connect rooms in a way that really makes sense, which I intend to fix.

The main thing here is that we have in mind the whole “Given, When, Then” structure for this spec. And it is quite lengthy. The first scenario is with no current maze:

  1. Given there is no current maze
  2. When I add a door
  3. Then it throws an error

Now, given we do have a maze, there are 2 possibilities: it has no rooms or it has at least two rooms. Technically there’s the case where it has just one room, but I’ll skip it here for brevity’s sake since it would just be the same case as no rooms. Thus:

  1. Given there is a maze with no rooms
  2. When I try to add a door
  3. Then it throws an error

and

  1. Given a maze with at least two rooms
  2. When I try to add a door
  3. Then it adds a door to both rooms

Finally, within this context we need to check that the rooms lead back to each other, which is to say that the door is located in the common wall of the rooms:

  1. Given a maze with at least two rooms
  2. When I add a door
  3. The door is added to the common wall

There really isn’t much of a mystery as to how to translate this based on the previous examples. That being said, there might be different ways to organize the specs in context blocks. In my case, I decided to put the last test case as a nested context block within the third test case, but that isn’t required, you could write them all seperately. Therefore I’ll skip here all the translation process and just show you how I wrote it:

require "spec_helper"

RSpec.describe Builders::RandomMazeBuilder do
  # The previous specs
  .
  .
  .

  describe "#build_door" do
    context "given there is no current maze" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new

        expect { rmb.build_door(Rooms::EnchantedRoom.new(1), Rooms::RoomWithABomb.new(2)) }.to raise_error do |error|
          expect(error).to be_a(Builders::Errors::InvalidStateError)
        end
      end
    end

    context "given the maze has no rooms" do
      it "raises an error" do
        rmb = Builders::RandomMazeBuilder.new
        rmb.build_maze

        expect { rmb.build_door(Rooms::EnchantedRoom.new(1), Rooms::RoomWithABomb.new(2)) }.to raise_error do |error|
          expect(error).to be_a(Builders::Errors::InvalidStateError)
        end
      end
    end

    context "given a maze with at least two rooms" do
      it "adds a door to both rooms" do
        rmb = Builders::RandomMazeBuilder.new
        rmb.build_maze
        rmb.build_room(1)
        rmb.build_room(2)
        rmb.build_door(1, 2)

        room1 = rmb.current_maze.room_no(1)
        room2 = rmb.current_maze.room_no(2)

        expect(room1.sides.values).to include(a_kind_of(Doors::Door))
        expect(room2.sides.values).to include(a_kind_of(Doors::Door))
      end

      context "given the door was added" do
        let(:rmb) { Builders::RandomMazeBuilder.new }

        before :each do
          rmb.build_maze
          rmb.build_room(1)
          rmb.build_room(2)
          rmb.build_door(1, 2)
        end

        it "adds the door to a common wall" do
          room1 = rmb.current_maze.room_no(1)
          room2 = rmb.current_maze.room_no(2)

          door_to_2 = room1.get_doors.first
          door_to_1 = room2.get_doors.first
          direction_of_room2 = room1.get_direction(door_to_2)
          direction_of_room1 = room2.get_direction(door_to_1)

          expect(direction_of_room2).to eq(rmb.opposite_direction(direction_of_room1))
        end
      end
    end
  end
end

Running the full suite to see that everything is green:

$ rspec

Builders::RandomMazeBuilder
  initialization
    provides an array of factories
  #build_maze
    creates a new maze for the builder
  #build_room
    given there is no current maze
      raises an error
    given there is a current maze
      adds a room to the current maze
  #build_door
    given there is no current maze
      raises an error
    given the maze has no rooms
      raises an error
    given a maze with at least two rooms
      adds a door to both rooms
      given the door was added
        adds the door to a common wall

Finished in 0.02238 seconds (files took 0.75552 seconds to load)
8 examples, 0 failures

Golden. Or green, I should say.

Conclusion

So, it’s easy to see that the process to write these tests, even after the code was written, is basically the same as a TDD workflow, with the difference that we don’t go through the proper TDD cycle because our code is already written. It’s more an exercise on thinking about what your test cases are.

In my case, I just wanted to speed up my testing process, because the project was growing rapidly in number of classes and it was taking too long to test just about any change. Even with my code already in place, it helped to step out of it, think about what I wanted my classes and methods to do, write the spec and refactor my code to follow the spec rather than have the spec follow the code.

Of course, I might’ve not been able to do so perfectly, but I’m satisfied with the results so far.

I intend to revisit this project in order to continue my study into design patterns, but I’ll probably take a detour into data structures, just to help with the whole maze building process.