So Totally Random

I keep hearing that testing randomness is really hard. Maybe I got the insider scoop too quickly.. it’s totally easy! I’ll show you some cool tricks to make totally random totally your ally.

Let’s make a little dice game that we can play with. How an oversimplified game of 10,000? Let’s say the rules are:

  • 1’s are 100 points
  • 5’s are 50 points

That sounds like plenty of rules for a simple game… you can even level it up later to a full fledged game if you want. Let’s fork and clone the repo from a previous blog post, Test Driving with RSpec (Github repo link). I added a tag to that starting commit to the repo being built along with this post called random_testing_start.

Now, we will make a new class that will run our game. Of course we start by writing our tests. Create a new file in the spec directory game_turn_spec.rb, and put the following inside:

require 'game_turn'

describe GameTurn do
  it 'initializes the game with current score 0' do
    game_turn = GameTurn.new
    actual = game_turn.score
    expected = 0
    expect(actual).to equal(expected)
  end
end

Follow the failing tests to implement code, and you may end up with something like this:

class GameTurn

  attr_reader :score

  def initialize
    @score = 0
  end

end

By the way, the code attr_reader :score is functionally identical to the following code within the class:

def score
  @score
end

Commit, and let’s write the next test. Our pre-existing DiceRoller is initialized with 6 sides, so that works for us. Now the spec file will look like this:

require 'game_turn'

describe GameTurn do

  it 'initializes the game with current score 0' do
    game_turn = GameTurn.new
    actual = game_turn.score
    expected = 0
    expect(actual).to equal(expected)
  end

  it 'allows user to roll dice and score them' do
    game_turn = GameTurn.new
    actual = game_turn.roll(6)
    expected = "You rolled [1, 2, 3, 4, 4, 6] for 100 points."
    expect(actual).to eq(expected)
  end

end

And we will go straight to code implementation for our lib file to look like this (for more about proper TDD/BDD please see this blog post):

require_relative 'dice_roller'

class GameTurn

  attr_reader :score

  def initialize
    @score = 0
    @dice = DiceRoller.new
  end

  def roll(num_dice)
    rolled = @dice.roll(num_dice).sort
    points = rolled.count(1) * 100
    "You rolled #{rolled} for #{points} points."
  end

end

Now if you run the test, you will notice that each time we run that test, we get a different roll. Let’s “rig” the game by making sure we roll the same thing every time. We will “seed” the random number generator so we get the same results every time, and then “un-seed” it with srand(Random.new_seed). Now the second test in our spec file will look like this:

it 'allows user to roll dice and score them for the 1s' do
  srand(1)
  game_turn = GameTurn.new
  actual = game_turn.roll(6)
  expected = "You rolled [1, 2, 3, 4, 4, 6] for 100 points."
  expect(actual).to eq(expected)
  srand(Random.new_seed)
end

If you run this test a few times, you will see that you keep getting the same roll. But it’s not really what we wanted… How do we find the correct seed to get this roll? Let’s add a new “test” below the one above:

it 'finds a certain roll' do
  actual_roll = ""
  i = 0
  while actual_roll != "You rolled [1, 2, 3, 4, 4, 6] for 100 points."
    srand(i)
    game_turn = GameTurn.new
    actual_roll = game_turn.roll(6)
    p "srand #{i} - #{actual_roll}"
    i+=1
  end
end

When you run this “test”, you will get a print out of all the seeds and rolls until the one you are wanting gets printed. Use this seed in your test above. I do like printing everything, as sometimes you accidentally forget something and get an infinite loop and can see that you are going nowhere… and you can see how to change your code to get it right. DON’T forget the awesome command CTRL-C for stopping a process… it is necessary in those cases of accidental infinite loop.

My seed was 118… what is yours? Let’s add that seed to our test and run the test again, commenting out our “seed finder”. Pass! Let’s commit and write our next test.

it 'allows user to roll dice and score them for the 1s and 5s' do
  srand(1)
  game_turn = GameTurn.new
  actual = game_turn.roll(6)
  expected = "You rolled [1, 2, 3, 5, 5, 6] for 200 points."
  expect(actual).to eq(expected)
  srand(Random.new_seed)
end

Change the roll method to:

def roll(num_dice)
  rolled = @dice.roll(num_dice).sort
  points = rolled.count(1) * 100 + rolled.count(5) * 50
  "You rolled #{rolled} for #{points} points."
end

Run that test, which will MOST LIKELY not pass. To find the proper seed, uncomment the ‘finds a certain roll’ test, replace the roll with what you are looking to find, and get that seed. My seed was 121… comment the seed finder back out and run all your tests. Pass! Commit, and try your little scorer in the terminal.

± |master ✗| → irb
2.1.1 :001 > require_relative 'lib/game_turn.rb'
 => true 
2.1.1 :002 > game=GameTurn.new
 => #<GameTurn:0x007fbe7b9e4d00 @score=0, @dice=#<DiceRoller:0x007fbe7b9e4cb0 @sides=6>> 
2.1.1 :003 > game.roll(6)
 => "You rolled [1, 1, 1, 2, 4, 5] for 350 points." 
2.1.1 :004 > game.roll(2)
 => "You rolled [5, 5] for 100 points." 
2.1.1 :005 > game.roll(6)
 => "You rolled [1, 3, 5, 5, 5, 6] for 250 points."

I think I’ll stay. Your turn!

Great tips:
  • Testing is often run in random order.
  • Make sure and seed each test individually, and reset the seed after.

Check it out on GitHub!