Unit Testing our Design Patterns exercise
So, our little exercise in design patterns 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 , 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:
- Given a new
RandomMazeBuilder
- When we call
RandomMazeBuilder.factories
- 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:
- Given a
RandomMazeBuilder
- When we call
RandomMazeBuilder.build_maze
- 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:
- A builder without a maze
- 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:
- Given there is no current maze
- When I try to build a room
RandomMazeBuilder
throws an error
And for the second context:
- Given ther is a current maze
- When I try to build a room
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:
- Given there is no current maze
- When I add a door
- 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:
- Given there is a maze with no rooms
- When I try to add a door
- Then it throws an error
and
- Given a maze with at least two rooms
- When I try to add a door
- 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:
- Given a maze with at least two rooms
- When I add a door
- 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.