Lets write some classes from scratch using RSpec. This is great way to start learning about definition of classes and test driven development. We will start by making a directory, cd-ing into it, initializing a git repository, initializing bundler, putting some gems in our gemfile, and bundling. Whew!
$ mkdir dice_roller
$ cd dice_roller
$ git init
$ bundle init
Open this project in your favorite text editor (RubyMine is really growing on me, btw) and open the Gemfile created by the bundle init. Go to http://rubygems.org/, search for rspec, click on the “Exact Match” rspec, scroll down to the “Gemfile” and copy this text. Paste this line into your gemfile, delete the comments in the file, save, and run bundler.
$ bundle install
You can just enter bundle
instead, but for some reason I really enjoy being super specific. I also enjoy arranging jelly beans in color order before eating them in color order, so take that into consideration.
Now we will create two folders, a spec
folder and a lib
folder. The spec folder will house your spec files, and the lib will house your library of class files… well named! Create a dice_roller_spec.rb
file in your spec folder. This file will drive the test and thus the development of your class. Lets start writing a test in that dice_roller_spec.rb
file!
describe DiceRoller do
end
Then, in terminal, run your test on the project.
$ rspec
Oh dear, what was that error?
uninitialized constant DiceRoller (NameError)
Rspec doesn’t know where that DiceRoller class is… at the top of the rspec file, add the following:
require 'dice_roller'
Now we have a new error…
cannot load such file -- dice_roller (LoadError)
Of course! We have to add that file. Make a new file in the lib directory named dice_roller.rb
. If you run rspec again, we still have that first error… That’s because we don’t have a class named DiceRoller
in there yet. Let’s add that class to the dice_roller.rb
file.
class DiceRoller
end
Oh hey, did you notice that the name of the class and the name of the file is almost the same? This is really good practice. The name of the folder will be in snake_case while the name of your class will be in CamelCase. Alrighty, let’s run that rspec again.
No examples found.
Great! Looks like everything is loading correctly and we are ready to write the contents of our first test. This is the part where you start to think about what you want your brand new class to do. Lets say we want to specify the number of sides at initialization, defaulting to 6. How do the heck do we tell this to our computer?
require 'dice_roller'
describe DiceRoller do
it 'initializes dice with default of six sides' do
dice = DiceRoller.new
actual = dice.sides
expected = 6
expect(actual).to eq(expected)
end
end
Whoa, what does all that mean? Well, there is a block of code in there after the it 'stuff' do
, and before the first end
. Lets walk trough that. In the first line of that block, we are instanciating a new “DiceRoller”, that is: making a new instance of our DiceRoller class. We said we wanted the default number of sides to be six, so hopefully that happened at the same time. In the second line, we are saying that we want there to be an instance method on our object dice that is called sides. So our actual number of sides should match our expected, which is 6. Ok, what happens when we run rspec now?
Failure/Error: actual = dice.sides
NoMethodError:
undefined method `sides' for #<DiceRoller:0x007fdc120a7468>
Looks like we did make an object which is an instance of the class DiceRoller, but we don’t yet have a method for it in our dice_roller.rb
file. Let’s make one!
class DiceRoller
def sides
6
end
end
Now run our rspec. Yaaaay! It passed! Lets make a commit.
$ git add Gemfile Gemfile.lock spec/dice_roller_spec.rb lib/dice_roller.rb
$ git commit -m "User can initialize a dice, see that it has 6 sides."
But look at that code. Is that really what we meant by defaulting to 6? We really wanted to be able to put in any number of sides, but default to 6 sides if the argument wasn’t listed at initialization. How can we write another test to “make sure” we are writing the right code?
require 'dice_roller'
describe DiceRoller do
it 'initializes dice with default of six sides' do
dice = DiceRoller.new
actual = dice.sides
expected = 6
expect(actual).to eq(expected)
end
it 'initializes dice with 12 sides' do
dice = DiceRoller.new(12)
actual = dice.sides
expected = 12
expect(actual).to eq(expected)
end
end
And let’s run rspec. Oh no, everything is ruined! What did our error say?
Failure/Error: dice = DiceRoller.new(12)
ArgumentError:
wrong number of arguments (1 for 0)
Looks like we have an argument error. What does this mean? Well, we never wrote a def initialize method. Lets do that.
class DiceRoller
def initialize(sides = 6)
end
def sides
6
end
end
Great! So now, the second we instanciate a new DiceRoller class we get the number of sides… either by specifying them or by defaulting to six. What does rspec say?
Failure/Error: expect(actual).to eq(expected)
expected: 12
got: 6
(compared using ==)
Oh yeah, we totally hardcoded that sides method. Lets have sides get it’s number of sides from when we instanciate the object.
class DiceRoller
def initialize(sides = 6)
@sides = sides
end
def sides
@sides
end
end
What the heck is that @ thingie? Well, to access that “sides” variable, it cannot be an local variable. An instance variable works great for this. How is our rspec test now?
..
Finished in 0.00098 seconds
2 examples, 0 failures
Looks great! See those two dots? They are our passing tests, one dot for the first test and one for the second. Our code is coming right along, and we have passing tests. Lets do another commit!
$ git add Gemfile Gemfile.lock spec/dice_roller_spec.rb lib/dice_roller.rb
$ git commit -m "User can initialize a DiceRoller with a stated number of sides."
We also want to have a method called “roll” along with a argument that says how many dice we want to roll. We should get an array of results from our roll, never less than 1, never more than the initialized face number. Lets write this test!
it 'can roll a 6 dice' do
dice = DiceRoller.new
roll = dice.roll(6)
actual = roll.size
expected = 6
expect(actual).to eq(expected)
end
Our error from rspec shouldn’t be too surprising. There is not yet a method .roll
for our instance of the object DiceRoller
. And oh, look! the object is telling us it has 6 sides!
Failure/Error: roll = dice.roll(6)
NoMethodError:
undefined method `roll' for #<DiceRoller:0x007feee1936160 @sides=6>
Your object location information will look a little different as it is a different object… in fact, if you run rspec again and compare that hexidecimal location to the previous, you will see that each object gets it’s on place. Lets define that new method roll
.
def roll
end
Running rspec now, we get the not enough arguments error… let’s put an argument in there.
def roll(number_dice)
end
Sensing a pattern? Create the spec well enough, and it will tell you exactly what you do next. The next error is interesting…
Failure/Error: actual = roll.size
NoMethodError:
undefined method `size' for nil:NilClass
What does that mean? Well, we were expecting the method .roll
to give us an array of numbers (ranging from 1 to the max number of sides). Then we were wanting to measure the size of that array. Well, apparently .size
doesn’t work too well on nil
. Which is apparently what the method roll
currently returns. Lets give .size
something to chew on.
def roll(number_dice)
[1,2,3,4,5,6]
end
And all at least looks right in the world! The method .size
has something to work with, rspec is happy, and we are ready to write more tests. Lets write some that forces us to actually make code that works. We will add another few tests… one that forces us to roll an exact number of dice, and two that makes sure we get random numbers only from 1 to the number of sides specified.
it 'can roll a 4 dice' do
dice = DiceRoller.new
roll = dice.roll(4)
actual = roll.size
expected = 4
expect(actual).to eq(expected)
end
it 'generates random numbers from 1 to 6' do
dice = DiceRoller.new
roll = dice.roll(100)
actual = roll.minmax
expected = [1,6]
expect(actual).to eq(expected)
end
it 'generates random numbers from 1 to 9' do
dice = DiceRoller.new(9)
roll = dice.roll(200)
actual = roll.minmax
expected = [1,9]
expect(actual).to eq(expected)
end
So now we really can’t hard code that roll method. Since we are ‘testing’ the random number generation and then making sure those dice only go from 1 to the number of sides, I had rspec roll 100 or 200 dice at a time. That way, the chances of rolling the minimum and maximum number is VERY high, and we can make sure that we don’t roll something that doesn’t make sense. Run rspec again get a “good fail”, and let’s write that method for the real.
def roll(number_dice)
roll_result = Array.new
number_dice.times do
roll_result << rand(1..@sides)
end
roll_result
end
Lets break it down! We want to generate a random number and save it exactly number_of_dice
times. We do have to set up the roll_result
as an Array
object before we just start shoveling things in there. And the rspec tests look great! Let’s commit this thing!
$ git add lib/dice_roller.rb spec/dice_roller_spec.rb
$ git commit -m "User can roll an exact number of dice."
Hmmmm… should we make it so that if we don’t specify the number if dice, it defaults to one? The test is…
it 'assumes rolling only 1 dice if not specified' do
dice = DiceRoller.new
roll = dice.roll
actual = roll.size
expected = 1
expect(actual).to eq(expected)
end
Run that rspec to get a “good fail”, and then edit the def roll
line as follows:
def roll(number_dice = 1)
Rspec is happy again. Let’s make one more commit.
$ git add lib/dice_roller.rb spec/dice_roller_spec.rb
$ git commit -m "Number of dice rolled defaults to 1."
That is a pretty good looking class! We could even use IRB to play dice now. A game of 10,000?
$ IRB
> require '<path to project folder>/dice_roller/lib/dice_roller.rb'
=> true
> dice = DiceRoller.new
=> #<DiceRoller:0x007fb89b816ef0 @sides=6>
> dice.roll(6)
=> [2, 1, 4, 6, 3, 1]
I’ll keep one of the 1’s for 100 pts.
> dice.roll(5)
=> [5, 2, 1, 3, 4]
Ugh, not looking good. I’ll keep another 1, total 200 pts.
> dice.roll(4)
=> [2, 6, 1, 5]
Well, I guess I’ll keep the 1 and the 5 for 350 total.
> dice.roll(2)
=> [2, 6]
Oh! Busted. Your turn!