Rails: Is the Attribute Actually Optional?
Ruby on Rails model attributes are optional by default. That is, unless you specify otherwise, an attribute is not required for a model instance to be valid. I like to test that this is, in fact, the case.
test "weight is not required" do
assert @product.valid?, "product should be valid to begin with"
@product.weight = nil
assert @product.valid?, "product should still be valid"
end
This might seem too trivial to test, or like we're testing core Rails functionality, but an optional attribute is a feature of your application. In other words, it's easy for parts of your app to depend on an attribute being optional, which means it's easy to break something by making that attribute required down the road.
class CreateProducts < ActiveRecord::Migration
def change
create_table :products do |t|
t.text :name
t.decimal :weight
end
end
end
class ProductTest < ActiveRecord::TestCase
test "name is required" do
@product = products(:one)
@product.name = nil
assert !@product.valid?
end
end
class Product < ActiveRecord::Base
validates :name, presence: true
end
Here we have a model with two attributes: name
, which is required, and weight
, which is optional. We also have a test to make sure that name
is required. Now we decide that the weight
attribute shouldn't accept negative numbers, so we add a test and modify the model.
class ProductTest < ActiveRecord::Base
# more tests
test "weight should not be negative" do
@product.weight = -5.4
assert !@product.valid?
end
end
class Product < ActiveRecord::Base
validates :name, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0 }
end
Easy enough, right? Nope! We just make a small but insidious mistake.
First, because we didn't add allow_nil: true
to the numericality
validation, weight
is now required, but that's not what we wanted to do. We just need to make sure weight
isn't a negative number; it should still be optional.
Okay, that's not too bad; we might have caught it if it broke some of our other tests. Something else is much more diconcerting; if we're using a fixture without the weight
attribute set, the "name is required"
test isn't actually testing anything.
one:
name: Cat 5e
class Product < ActiveRecord::Base
# validates :name, presence: true
validates :weight, numericality: { greater_than_or_equal_to: 0 }
end
Boom! Both of our tests still pass, but we're clearly not validating that name
is required. This should strike fear into your heart; we have a reasonable looking situation that completely fails to do what we expect. In other words, we have built our app on top of quicksand. Maybe it's time to tweak our testing methodology.
There are two ways we could have caught this bug. First, we could have validated our fixtures, which is good hygiene but kind of a separate issue. Second, we could have tested that our optional attribute was actually optional. We'll focus on this second one.
test "weight is not required" do
assert @product.valid?
@product.weight = nil
assert @product.valid?
end
Look familiar? This test is how we got this whole blog post started. More importantly, it will start a chain of failing tests that, as we cause them to pass, lead us back to code which does what we expect it to do.
It's kinda wordy for my taste though, and will result in egregious violation of the Don't Repeat Yourself (DRY) principle, especially if we have a bunch of optional attributes. Why not create a helper method so we can test all of our optional attributes in one fell swoop?
class ActiveSupport::TestCase
# some other stuff
def self.test_optional_attribute(fixture_name, *attrs)
attrs.each do |attr|
test "#{attr} is optional" do
fixture = instance_variable_get("@#{fixture_name}")
assert fixture.valid?, "#{fixture} isn't a valid fixture"
fixture.send "#{attr}=", nil
assert fixture.valid?, "#{attr} should be optional"
end
end
end
end
Now we have a nice, succinct way to test all of our optional attributes in a single line of code.
class ProductTest < ActiveSupport::TestCase
# other tests
test_optional_attribute :product, :weight
end
Maybe this all sounds like paranoia to you, but mistakes like this one are shockingly easy to make. Indeed, this is the reason we do any testing at all; we humans, with our inconsistency and tendency toward errors, and computers, with their unforgiving precision, are like oil and water; we don't mix.
And yet, here we are anyway, trying to master computation. Rather than pretend that humans and computers aren't fundamentally incompatible, embrace it. Go all the way. Test every single assumption. Just be smart about it; make it easy and succint. Make it joyful.