Screencast: Testing Basics
[Screencast coming later this evening (Saturday), I hope.]
In this screencast, I’m going to show you how to write a program where we write the tests first. Along the way I’ll go over the way testing assertions work, which you will use in your one-liner.
First reader Chapter 13, “Unit Testing,” in the Pickaxe.
To start, let’s imagine that we have a little program that can play the game of “hangman” (http://en.wikipedia.org/wiki/Hangman_%28game%29).
Imagine how the game would work. We would show a line of dashes with the same number of dashes as there are letters in the secret word. As letters are guessed, we would replace dashes with the appropriate letters. We would also show the letters guessed so far. We won’t write code to print a little hangman figure; we’ll just show the guesses.
The interaction might look like this:
jgn:testing-simple jgn$ ruby hangman_game.rb Guessed already: (none) Guess? e --------e Guessed already: e Guess? t --------e Guessed already: e, t Guess? s (much removed) saxopho-e Guessed already: a, e, h, o, p, s, t, x Guess? n You won! The word was: saxophone
Let’s write the program first. Here’s my draft (in the final version, I’ll add some code to handle a Control C (to end the game early) and we’ll use a more canonical “require” parameter, as I discussed in lecture.
h = Hangman.new('saxophone')
begin
puts
puts h.hint
puts "Guessed already: #{h.guessed}"
print 'Guess? '; h.guess(gets)
end until h.won?
puts "You won! The word was: #{h.hint}"
Notice how I’m using the print method to output the prefix ‘Guess? ‘ without following it with a newline. That way, the input will be on the same line. The gets method gets one line from the console.
With this sample code, let’s write down all of the instance methods that are used, and define what they do:
initialize (called by the class method new): We pass into initialize a String representing the secret word; this should save the secret word for future reference so that guesses can be assessed.
hint: Returns a String of dashes and letters representing the results of the guesses so far. When a letter has been guessed correctly, the dash at the positions for that letter are replaced by the letter. So if I guessed ‘a’ for the word ’saxaphone’, then hint should be ‘-a-a—–’. If I then guess ’s’, the hint would be ’sa-a—–’, etc.
guessed: Returns a String of comma-separated values representing the letters guessed so far. If no letters have been guessed, return the String ‘(none)’. Let’s have the letters be sorted.
guess(letter): Remember that this letter has been guessed.
won?: Return true if the word is completed; otherwise false.
In what you see above, I’ve tried not to talk about implementation. I’ve talked about what we pass into methods, and what then return, but I haven’t said whether the guesses should be recorded in an Array or a Hash or a Set or whatever. In other words, I’ve talked about the behavior only.
There are also some gaps here. For instance, what if we guess the same letter twice? What if we type in more than one character for a guess? We say above that we should remember that this letter has been guessed; do we need to remember that it has been guesseed twice? Maybe that’s an error condition?
With all this in mind, let’s create our test.
To write a unit test, we want to extend the class Test::Unit::TestCase. We will need to require ‘test/unit’ to bring this in. Also, we will need to define a minimalist Hangman class, and require it.
Therefore, in lib/hangman.rb, we want:
class Hangman end
And in test/hangman_test.rb, we will have:
require 'test/unit' require 'lib/hangman.rb' class HangmanTest < Test::Unit::TestCase end
Notice the naming conventions and file locations. We are putting all of our code for classes in lib/ and are naming the files after the class names. The filenames are lower-cased with underscores separating the words. For each class we define in lib/ we will define a test class in test/ where the class name of the test is <Classname>Test. So for the class Hangman,
our test class with be HangmanTest in the file test/hangman_test.rb. In our test code, the require goes to ‘lib/hangman’ — this is because we will run our tests from the root of the project (in Chapter 13 of the Pickaxe, see the section “Where to Put Tests”; in the download, I’ve changed the require statement so that I can run the tests from anywhere. In any case, follow the conventions in the Pickaxe. They are very similar to what we will see in Rails.
Now let’s add a test method in HangmanTest to test every one of the methods in our Hangman class (which we haven’t written yet!) that we’ve defined above. In every case, we’re going to call the method flunk, which will force the test to fail:
require 'test/unit'
require 'lib/hangman'
class HangmanTest < Test::Unit::TestCase
def test_initialize
flunk
end
def test_hint
flunk
end
def test_guessed
flunk
end
def test_guess
flunk
end
def test_won?
flunk
end
end
Now we’ll do ruby test/hangman_test.rb from the root:
jgn:testing-simple jgn$ ruby test/hangman_test.rb Loaded suite test/hangman_test Started FFFFF Finished in 0.001145 seconds. 1) Failure: test_guess(HangmanTest) [test/hangman_test.rb:15]: Epic Fail! 2) Failure: test_guessed(HangmanTest) [test/hangman_test.rb:12]: Epic Fail! 3) Failure: test_hint(HangmanTest) [test/hangman_test.rb:9]: Epic Fail! 4) Failure: test_initialize(HangmanTest) [test/hangman_test.rb:6]: Epic Fail! 5) Failure: test_won?(HangmanTest) [test/hangman_test.rb:18]: Epic Fail! 5 tests, 5 assertions, 5 failures, 0 errors, 0 skips jgn:testing-simple jgn$
Notice the line that says “FFFFF” — all of our tests failed. (Good; now we will make them all pass.)
Now we start writing implementations of each test. Let’s write some code for test_initialize. Notice that our spec above is rather light on what happens right after our instance of Hangman is initialized. Therefore, the test is going to be pretty simple. We may add to it later.
def test_initialize
h = Hangman.new("pizza")
end
Notice that we’re not even asserting anything. We just want to see if the code will run without raising any exceptions. Let’s run the test:
jgn:testing-simple jgn$ ruby test/hangman_test.rb
Loaded suite test/hangman_test
Started
FFFEF
Finished in 0.001085 seconds.
1) Failure:
test_guess(HangmanTest) [test/hangman_test.rb:18]:
Epic Fail!
2) Failure:
test_guessed(HangmanTest) [test/hangman_test.rb:15]:
Epic Fail!
3) Failure:
test_hint(HangmanTest) [test/hangman_test.rb:12]:
Epic Fail!
4) Error:
test_initialize(HangmanTest):
ArgumentError: wrong number of arguments(1 for 0)
test/hangman_test.rb:6:in `initialize'
test/hangman_test.rb:6:in `new'
test/hangman_test.rb:6:in `test_initialize'
5) Failure:
test_won?(HangmanTest) [test/hangman_test.rb:21]:
Epic Fail!
5 tests, 4 assertions, 4 failures, 1 errors, 0 skips
Aha. The summary line says “FFFEF” – All failures, except for the “E” which means error. Notice as well that the tests ran in alphabetical order. Each test should be self-contained. And of course it doesn’t work! We haven’t written Hangman#initialize yet, so the initialization fails! So let’s write a method for the Hangman class. In the spec, it says that we want to do something with the word later, so I will go ahead and squirrel the word away in an instance variable.
def initialize(word)
@word = word
end
Just as it stands this test tells us something: If it doesn’t result in an error, then our Hangman#initialize method does work with one argument, as we wish. So we’ll just leave it as is. If we wanted to, we could test calls such as Hangman.new (with no parameters) and Hangman.new(”foo”, “bar”) and then assert that the proper exception is raised.
Let’s run the test again:
jgn:testing-simple jgn$ ruby test/hangman_test.rb Loaded suite test/hangman_test Started FFF.F Finished in 0.001407 seconds. 1) Failure: test_guess(HangmanTest) [test/hangman_test.rb:15]: Epic Fail! 2) Failure: test_guessed(HangmanTest) [test/hangman_test.rb:12]: Epic Fail! 3) Failure: test_hint(HangmanTest) [test/hangman_test.rb:9]: Epic Fail! 4) Failure: test_won?(HangmanTest) [test/hangman_test.rb:18]: Epic Fail! 5 tests, 4 assertions, 4 failures, 0 errors, 0 skips
Now the summary line says “FFF.F”: The dot (”.”) means that a test did not result in a failure or error.
Alright, let’s make test_hint pass. In our little specification above, we said that: “Returns a String of dashes and letters representing the results of the guesses so far. When a letter has been guessed correctly, the dash at the positions for that letter are replaced by the letter. So if I guessed ‘a’ for the word ’saxaphone’, then hint should be ‘-a-a—–’. If I then guess ’s’, the hint would be ’sa-a—–’, etc.”
So after a Hangman object h is created, the result of h.hint should be a String of dashes, as long as the original String. Here’s my test:
def test_hint
word = "pizza"
h = Hangman.new(word)
assert_equal "-" * word.size, h.hint
end
We create a word, initialize a Hangman object with the word, and then assert the equality of a String of dashes of the length of word, and the result of h.hint. (I could have said “assert_equal ‘—–’, h.hint” but I’ve written enough tests to know I want something with a bit more flexibility, because I’m going to rewrite this test in a bit.)
About assertions: The assert methods are about comparing expected results with actual results. For assertions that take two parameters, the expected value is always the first parameter! This is important, because otherwise the test reports won’t make sense because the expected and actual values will be backwards. Please take a look at Figure 13.1 in the Pickaxe for a list of assertions. Notice that many take a message. This is an additional String you can provide to give additional information. The message String typically includes values and provide some clarifications. I’ll show this below.
We could run this now and expect an error (because hint isn’t even written), but let’s take a shot at an implementation. All we want to do is get the test to pass, so this implementation should surprise you:
def hint
"-" * @word.size
end
And run the test. Now we get two passes (”FF..F”).
At this point it gets interesting. We want to test guest. How would we do that? We want to take a guess, and then check the hint. So, for example, for “pizza,” we might guess “p” and expect “p—-”. Here’s my test:
def test_guess
word = "pizza"
h = Hangman.new(word)
h.guess('p')
assert_equal 'p----', hint, "dash not being replaced with good guess"
end
Notice the additional message String.
Run it and it fails. Now for an implementation of Hangman#guess. Hmm. Well, every time I get a guess I would to put it somewhere, so I think I’ll change the initialize method so that an instance variables @guesses is created that is set to a new (and empty) Array. Then every time a guess comes in, I’ll take it on to the @guesses Array. Finally, I will now have to take Hangman#hint seriously. What I’m going to do is, every time a guess comes in, I’m going to compute a value for an instance variable hint. It’s going to go through the letters in the @word, and if the letter is in the list of guesses, it will put in the letter; otherwise it will put in the dash:
def initialize(word)
@word = word
@guesses = []
end
def guess(letter)
@guesses << letter.strip
end
def hint
@word.split('').inject("") do |m, e|
m << (@guesses.include?(e) ? e : '-')
end
end
Cool? Run the test. And . . . “.F..F”!
Now you just keep writing tests, modifying your implementations in Hangman, until all of your tests pass. If you write your implementations in keeping with the specification described above, you should be able to run the hangman_game.rb program and play.
Testing Your One-Liner
Now that you’ve seen this walk-through of test-first development, you should think about how to write some good tests for your one liner.
Here’s a nice one liner. Given an Array of Strings a, and a String b, return an Array containing two Arrays. The first Array should be an Array of Strings from a that contain characters in b. The second Array should be the other Strings from a.
So, for example, if a = [ 'John', 'Amy', 'Keith, 'Jonathan', 'Antony' ], and b = ‘jk’, we should get [ [ 'John', 'Keith', 'Jonathan' ], [ 'Amy', 'Antony' ] ]. Let’s come up with a few more inputs and outputs. How about a = [ 'John', 'Keith' ], and b = ”. According to the rules above, we should get [ [], [ 'John', 'Keith' ] ]. If a = [ 'John', 'Keith' ] and b = ‘h’, then we get [ [ 'John', 'Keith' ], [] ].
To test your one-liner thoroughly, you should have a lot of cases, especially cases that test extreme conditions. Testing with hard-coded data is good, but you can also write code to create inputs and then verify them against your method.
So for what I’ve written above, the test would be:
def test_student_one_liner
solution_set = CLASS_TO_TEST.new
a = [ 'John', 'Amy', 'Keith', 'Jonathan', 'Antony' ]
b = 'jk'
assert_equal [ [ 'John', 'Keith', 'Jonathan' ], [ 'Amy', 'Antony' ] ], solution_set.student_one_liner(a, b)
end
Run it, and it will fail. (In your case, you may have already written your one-liner, and some great tests, and your test will pass.)
Now let’s write that one-liner. In this one-liner, I’m going to use the Symbol.to_proc trick (see PDF, pp. 379-380; printed book, pp. 368-369) to keep the code concise:
def student_one_liner(a, b)
a.partition { |e| (e.split('').map(&:downcase) & b.split('').map(&:downcase)).size != 0 }
end
This is actually a quite straight-forward one-liner; the primary insight is to use Array#partition to create two Arrays from the one Array, based on some rule. The rule in inside the block. The secondary insight is to use Array#&, which does set intersection. If the result of the intersection of the two Arrays is non-zero, we want the result to go into the first Array for the partition. The .map is to get the Array Strings to be lowercase, and the .split is to convert the individual characters into Arrays, suitable for the “&” comparison.
It passes. Now I would go back to my test, and re-write it to test all of my cases. Notice carefully in what’s below that the input_output Array has the data I describe above.
If I had time, I would write some code to generate cases.
def test_student_one_liner
solution_set = CLASS_TO_TEST.new
input_output = [
{
:a => [ 'John', 'Amy', 'Keith', 'Jonathan', 'Antony' ],
:b => 'jk',
:expected => [ [ 'John', 'Keith', 'Jonathan' ], [ 'Amy', 'Antony' ] ]
},
{
:a => [ 'John', 'Keith' ],
:b => '',
:expected => [ [], [ 'John', 'Keith' ] ]
},
{
:a => [ 'John', 'Keith' ],
:b => 'h',
:expected => [ [ 'John', 'Keith' ], [] ]
}
]
input_output.each do |io|
assert_equal io[:expected], solution_set.student_one_liner(io[:a], io[:b])
end
end
Questions?
This is awesome. I am going to run through this and do it with my original submission if I get the time on Sunday. Test, Test, Test!!!
Can we put a message in our anonymous objects where you create an array, as to print out a custom message for a specific case, or would this be a bad idea in general? Also, would you be likely to run across this normally in the professional world? i.e.
input_output = [ { :a => 'param1', :b => 'param2', :expected => 'expected result', :message => 'My specific case testing blah' }, {...}, ... ] input_output.each do |io| assert_equal io[:expected], solution_set.student_one_liner(io[:a], io[:b]), io[:message] end@Jeff Ancel
If you had a lot of tests where you wanted to check functionality with constant data, you would probably have that data in the form of "fixtures" in external files. We'll talk about that with regard to Rails testing. But this is really the same idea, and there's nothing especially wrong with it.
Chapter 13 mentions several different testing frameworks -- Test::Unit, MiniTest::Unit, RSpec, Shoulda. Which one(s) will we be using for the remainder of this class?
@Ron Newman
Primarily Test::Unit, because it is ubiquitous. We may use Shoulda a bit, and we may see a "mock" generator. Note that Rails uses a subclass of Test::Unit, but the assertions are mostly the same.
How does Test::Unit arrange to call all of the test_ methods when there is no 'main' code, just a bunch of method definitions? Is it relying one of the obscure hooks like extended() or included() to learn that the methods exist and can be called?
@Ron Newman
Yes. But it is sordid (IMHO).
Here's how it works (more or less). Put the first of these snippets into a file called test_foo.rb, and the second in testy.rb, both in the same directory.
You have a class which subclasses some other class:
require 'testy' class CallMethods < Testy def test_m1 puts "testing m1" end def test_m2 puts "testing m2" end endIn what I'm showing you, the Testy class (in testy.rb) does all the work:
class Testy @@k = nil def self.inherited(k) @@k = k end def self.go Dir["test_*.rb"].each do |f| require f end methods = @@k.public_instance_methods.grep(/^test_/) k = @@k.new methods.each do |m| k.send(m) end end end Testy.goNotice that the last line "Testy.go" runs when the testy.rb file is itself required.
Then it looks in the current directory and requires all test_*.rb files (ahem) in order to make its own inherited method trigger from the load of the subclass. Then it finds all methods starting test_. Then it instantiates the class, and uses send to run all of the instance methods.
This is obviously limited: It's only handling one test file and class. The real Test::Unit (and MiniTest, the class used by Test::Unit in Ruby 1.9.1) is more tricky.
Thanks. And I see that it works if I run test_foo.rb (which is similar to how Test::Unit seems to work), but not if I run testy.rb . If I do the latter, I think I have a case of A requires B which requires A, which re-runs the line "@@k=nil" and causes later death.
@Ron Newman
Right -- You wouldn't run the Test::Unit class by itself anyway -- the code is just to give you the idea of how it was wired up.
Is this the total screencast? Is there more coming?
@Mike Byrne
Yes, but I've been a bit snowed under. The screencast "script" (above), is virtually self-standing, so I think you can get a lot out of it by reading it.