Jeff Kreeftmeijer

Isolated testing for custom validators in Rails 3

2011-09-19

It’s International Talk Like A Pirate Day today, so you might want to add a custom validation to check if comments submitted in your application actually sound like they were written by a pirate. Right? Right. I thought so. Anyway, let’s create a validator with specs that don’t need to require the model every time they run, allowing them to be blazingly fast. Or, at least faster than what you did before.

Pirate

Since we care about keeping our test suite nice and fast, we’ll try not to load the Comment model and anything else we don’t really need. Instead of throwing the tests for our validator in the Comment’s model spec, we’ll create a new one in spec/validators/pirate_validator_spec.rb and put a mock model named Validatable in there to test with:

class Validatable
  include ActiveModel::Validations
  validates_with PirateValidator
end

https://gist.github.com/1226439/8d730b...

Running it right now (yes, without any actual tests) would end us up with a NameError, telling us ActiveModel is uninitialized. We’ll need to require it:

require 'activemodel'
https://gist.github.com/1226439/66dc63...

When running it again, we quickly find out the PirateValidator is uninitialized, since we didn’t create and require it yet. Let’s put an empty validator in app/validators/piratevalidator.rb (and don’t forget to require it in the spec):

class PirateValidator < ActiveModel::Validator
end
https://gist.github.com/1226439/b5a45c...

Now the spec actually runs without stumbling on any errors, so we can start writing our first test:

describe PirateValidator do

subject { Validatable.new }

context 'with a comment that sounds like a pirate' do

<span class="n">before</span> <span class="p">{</span> <span class="n">subject</span><span class="o">.</span><span class="n">stub</span><span class="p">(</span><span class="ss">:comment</span><span class="p">)</span><span class="o">.</span><span class="n">and_return</span><span class="p">(</span><span class="s1">'Ahoy, matey!'</span><span class="p">)</span> <span class="p">}</span>

<span class="n">it</span> <span class="p">{</span> <span class="n">should</span> <span class="n">be_valid</span> <span class="p">}</span>

end

end

https://gist.github.com/1226439/be72a9...

Running the spec again, we get a NotImplementedError:

NotImplementedError:
  Subclasses must implement a validate(record) method.

Ah, our PirateValidator doesn’t have a validate method yet, so we’ll just add an empty one:

class PirateValidator < ActiveModel::Validator
  def validate(document)
  end
end

https://gist.github.com/1226439/a1c73c...

Wait, what? Our first spec passes, since it asserts the Validatable object to be valid and our validator doesn’t do anything yet. Let’s add another test to give it some actual functionality:

context 'with a comment that sounds like a dinosaur' do

before { subject.stub(:comment).and_return('ROOOAAAR!') }

it { should have(1).error_on(:comment) }

end

https://gist.github.com/1226439/d29923...

Which causes another NoMethodError:

NoMethodError:
  undefined method `error_on' for #<Validatable:0x007faa43462ec8>

That’s because we use should have(1).error_on(:comment) in our spec, and error_on comes with rspec-rails and we haven’t included that yet. error_on is in RSpec::Rails::Extensions, so let’s just require that:

require 'rspec/rails/extensions'

https://gist.github.com/1226439/3c5f4b...

If we run our tests again, we notice that they’re quite a bit slower now. We could solve that by not using the erroron method and not requiring RSpec::Rails::Extensions, but I prefer using erroron instead of having to do assertions on the subject.errors array, but that’s completely up to you.

Update: If you don’t want to load up RSpec::Rails::Extensions, but do want to use error_on, just put this validations support file in spec/support/validations.rb and require 'support/validations' instead of rspec/rails/extensions. This is saving me about 2 seconds.

After requiring RSpec::Rails::Extensions, our spec starts running again and fails, because we haven’t implemented the actual validation yet. So let’s do that now:

class PirateValidator < ActiveModel::Validator
  def validate(document)
    unless document.comment.include? 'matey'
      document.errors[:comment] << 'does not sound like a pirate'
    end
  end
end

https://gist.github.com/1226439/7a79aa...

And our test passes! We successfully implemented a model validator without actually loading the model in the specs. Now, getting it running in your model is up to you, but that shouldn’t be more difficult than getting it to run in Validatable.

If you have any questions or suggestions about this approach to test validators, be sure to let me know in the comments.. Matey.