- Author
-
Dave Copeland (davetron5000 at g mail dot com)
- Copyright
-
Copyright © 2012 by Dave Copeland
- License
-
Distributes under the Apache License, see LICENSE.txt in the source distro
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:
-
Understanding what part of a test method is setup, test, and evaluation
-
Understanding what elements of a test are relevant to the test, and which are arbitrary placeholders
-
Removing the requirement that your tests are method names
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" do Given { @circle = Circle.new(10,any_string) } When { @area = @circle.area } Then { assert_equal 314,@area } end test_that "to_s includes the name" do Given { @name = "foo" @circle = Circle.new(any_int,@name) } When { @string = @circle.to_s } Then { assert_match /#{@name}/,@string } end end
What’s going on here?
-
We can clearly see which parts of our test are setting things up (stuff inside
Given
), which parts are executing the code we’re testing (stuff inWhen
) and which parts are evalulating the results (stuff inThen
) -
We can see which values are relevant to the test - only those that are literals. In the first test, the
name
of our circle is not relevant to the test, so instead of using a dummy value like"foo"
, we useany_string
, which makes it clear that the value does not matter. Similarly, in the second test, the radius is irrelevant, so we useany_int
to signify that it doesn’t matter. -
Our tests are clearly named and described with strings, but we didn’t need to bring in active support.
-
A side effect of this structure is that we use instance vars to pass data between Given/When/Then blocks. This means that instance vars “jump out” as important variables to the test; non-instance vars “fade away” into the background.
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.
-
Clean::Test::TestCase is the base class that gives you everything
-
Clean::Test::GivenWhenThen provides the Given/When/Then construct
-
Clean::Test::TestThat provides
test_that
-
Clean::Test::Any provides the
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.
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:
-
We aren’t faking values, we’re using arbitrary values. There’s a difference semantically, even if the mechanics are the same
-
Faker requires too much typing to get arbitrary values. I’d rather type
any_string
thanFaker::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