Write clean, understandable tests in Test::Unit
Get your Test::Unit test cases readable and fluent, without RSpec, magic, or crazy meta-programming.
This library is a set of small, simple tools to make your Test::Unit test cases easy to understand. This isn't a massive change in how you write tests, but simply some helpful things will make your tests easier to read.
The main problems this library solves are:
gem install clean_test
Or, with bundler:
gem "clean_test", :require => false
class Circle
attr_reader :name
attr_reader :radius
def initialize(radius,name)
@radius = radius
@name = name
end
def area
@radius * @radius * 3.14
end
def to_s
"circle of radius #{radius}, named #{name}"
end
end
require 'clean_test/test_case'
class CircleTest < Clean::Test::TestCase
test_that "area is computed correctly" {
Given {
@circle = Circle.new(10,any_string)
}
When {
@area = @circle.area
}
Then {
assert_equal 314,@area
}
}
test_that "to_s includes the name" {
Given {
@name = "foo"
@circle = Circle.new(any_int,@name)
}
When {
@string = @circle.to_s
}
Then {
assert_match /#{@name}/,@string
}
}
end
What's going on here?
Given), which parts are executing the code we're testing (stuff in When) and which parts are evalulating the results (stuff in Then)name of our circle is not relevant to the test, so instead of using a dummy value like "foo", we use any_string, which makes it clear that the value does not matter. Similarly, in the second test, the radius is irrelevant, so we use any_int to signify that it doesn't matter.But, don't fret, this is not an all-or-nothing proposition. Use whichever parts you like. Each feature is in a module that you can include as needed, or you can do what we're doing here and extend Clean::Test::TestCase to get everything at once.
test_that
any_string and friends.I'm tired of unreadable tests. Tests should be good, clean code, and it shoud be easy to see what's being tested. This is especially important when there is a lot of setup required to simulate something.
I also don't believe we need to resort to a lot of metaprogramming tricks just to get our tests in this shape. RSpec, for example, creates strange constructs for things that are much more straightforward in plain Ruby. I like Test::Unit, and with just a bit of helper methods, we can make nice, readable tests, using just Ruby.
And? I don't mind a test method that's a bit longer if that makes it easy to understand. Certainly, a method like this is short:
def test_radius
assert_equal 314,Circle.new(10).radius
end
But, we rarely get such simple methods and this test method isn't very modifiable; everything is on one line and it doesn't encourage re-use. We can do better.
Mocks create an interesting issue, because the "assertions" are the mock expectations you setup before you call the method under test. This means that the "then" side of things is out of order.
class CircleTest < Test::Unit::Given::TestCase
test_that "our external diameter service is being used" do
Given {
@diameter_service = mock()
@diameter_service.expects(:get_diameter).with(10).returns(400)
@circle = Circle.new(10,@diameter_service)
}
When {
@diameter = @circle.diameter
}
Then {
// assume mocks were called
}
end
end
This is somewhat confusing. We could solve it using two blocks provided by this library, the_test_runs, and mocks_shouldve_been_called, like so:
class CircleTest < Test::Unit::Given::TestCase
test_that "our external diameter service is being used" do
Given {
@diameter_service = mock()
}
When the_test_runs
Then {
@diameter_service.expects(:get_diameter).with(10).returns(400)
}
Given {
@circle = Circle.new(10,@diameter_service)
}
When {
@diameter = @circle.diameter
}
Then mocks_shouldve_been_called
end
end
Although both the_test_runs and mocks_shouldve_been_called are no-ops, they allow our tests to be readable and make clear what the assertions are that we are making.
Yes, this makes our test a bit longer, but it's much more clear.
assert_raises
Again, things are a bit out of order in a class test case, but you can clean this up without this library or any craziness, by just using Ruby:
class CircleTest < Clean::Test::TestCase
test_that "there is no diameter method" do
Given {
@circle = Circle.new(10)
}
When {
@code = lambda { @circle.diameter }
}
Then {
assert_raises(NoMethodError,&@code)
}
end
end
Duplicated setup can be tricky. A problem with heavily nested contexts in Shoulda or RSpec is that it can be hard to piece together what all the "Givens" of a particular test actually are. As a reaction to this, a lot of developers tend to just duplicate setup code, so that each test "stands on its own". This makes adding features or changing things difficult, because it's not clear what duplicated code is the same by happenstance, or the same because it's supposed to be the same.
To deal with this, we simply use Ruby and method extraction. Let's say we have a Salutation class that takes a Person and a Language in its constructor, and then provides methods to "greet" that person
class Salutation
def initialize(person,language)
raise "person required" if person.nil?
raise "language required" if language.nil?
end
# ... methods
end
To test this class, we always need a non-nil person and language. We might end up with code like this:
class SalutationTest << Clean::Test::TestCase
test_that "greeting works" do
Given {
person = Person.new("David","Copeland",:male)
language = Language.new("English","en")
@salutation = Salutation.new(person,language)
}
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, David!",@salutation.greeting
}
end
test_that "greeting works for no first name" do
Given {
person = Person.new(nil,"Copeland",:male)
language = Language.new("English","en")
@salutation = Salutation.new(person,language)
}
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, Mr. Copeland!",@salutation.greeting
}
end
end
In both cases, the language is the same, and the person is slightly different. Method extraction:
class SalutationTest << Clean::Test::TestCase
test_that "greeting works" do
Given {
@salutation = Salutation.new(male_with_first_name("David"),english)
}
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, David!",@salutation.greeting
}
end
test_that "greeting works for no first name" do
Given {
@salutation = Salutation.new(male_with_no_first_name("Copeland"),english)
}
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, Mr. Copeland!",@salutation.greeting
}
end
private
def male_with_first_name(first_name)
Person.new(first_name,any_string,:male)
end
def male_with_no_first_name(last_name)
Person.new(nil,last_name,:male)
end
def english; Language.new("English","en"); end
end
Nothing. That's the point. You have the power already. That being said, Given and friends can take a symbol representing the name of a method to call, in lieu of a block:
class SalutationTest << Clean::Test::TestCase
test_that "greeting works" do
Given :english_salutation_for,male_with_first_name("David")
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, David!",@salutation.greeting
}
end
test_that "greeting works for no first name" do
Given :english_salutation_for,male_with_no_first_name("Copeland")
When {
@greeting = @salutation.greeting
}
Then {
assert_equal "Hello, Mr. Copeland!",@salutation.greeting
}
end
private
def male_with_first_name(first_name)
Person.new(first_name,any_string,:male)
end
def male_with_no_first_name(last_name)
Person.new(nil,last_name,:male)
end
def english_salutation_for(person)
@salutation = Salutation.new(person,Language.new("English","en"))
end
end
Faker is used by Any under the covers, but Faker has two problems:
any_string than Faker::Lorem.words(1).join(' ')
Again, FactoryGirl goes through metaprogramming hoops to do something we can already do in Ruby: call methods. Factory Girl also places factories in global scope, making tests more brittle. You either have a ton of tests depending on the same factory or you have test-specific factories, all in global scope. It's just simpler and more maintainable to use methods and modules for this. To re-use "factories" produced by simple methods, just put them in a module.
Further, the Any module is extensible, in that you can do stuff like any Person, but you can, and should, just use methods. Any helps out with primitives that we tend to use a lot: numbers and strings. It's just simpler and, with less moving parts, more predictable. This means you spend more time on your tests than on your test infrastructure.
You can make them repeatable by explicitly setting the random seed to a literal value. Also, including Any will record
the random seed used and output it. You can then set RANDOM_SEED in the environment to re-run he tests using that seed.
Keep in mind that if any value will work, random values shouldn't be a problem.
To use Any on its own:
require 'clean_test/any'
class MyTest < Test::Unit::TestCase
include Clean::Test::Any
end
To use GivenWhenThen on its own:
require 'clean_test/given_when_then'
class MyTest < Test::Unit::TestCase
include Clean::Test::GivenWhenThen
end
To use TestThat on its own:
require 'clean_test/test_that'
class MyTest < Test::Unit::TestCase
include Clean::Test::TestThat
end