Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Write clean, understandable tests in Test::Unit

Fetching latest commit…

Octocat-spinner-32-eaf2f5

Cannot retrieve the latest commit at this time

Octocat-spinner-32 lib
Octocat-spinner-32 test
Octocat-spinner-32 .gitignore
Octocat-spinner-32 .rvmrc
Octocat-spinner-32 .travis.yml
Octocat-spinner-32 Gemfile
Octocat-spinner-32 LICENSE.txt
Octocat-spinner-32 README.rdoc
Octocat-spinner-32 Rakefile
Octocat-spinner-32 clean_test.gemspec
README.rdoc

Clean Tests

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

Install

gem install clean_test

Or, with bundler:

gem "clean_test", :require => false

Overview

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 in When) and which parts are evalulating the results (stuff in Then)

  • 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 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.

  • 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.

More Info

  • 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.

Questions you might have

Why?

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.

But the test methods are longer!

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.

What about mocks?

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.

What about block-based assertions, like 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

My tests require a lot of setup, so I use contexts in shoulda/RSpec. What say you?

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

What did that have to do with this gem?

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

Why Any instead of Faker?

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 than Faker::Lorem.words(1).join(' ')

What about Factory Girl?

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.

Any uses random numbers and strings. Tests aren't repeatable!

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.

What about not using the base class?

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
Something went wrong with that request. Please try again.