Josh Clayton

Property Testing Object Invariants

Modeling concepts with software requires capturing and codifying constraints in a way that ensures both correctness and resistance to invalid states. Testing for correctness, then, benefits from approaches more aligned with defining the rules of behavior, where the test harness pushes at the edges of boundaries automatically.

Object invariants define these rules.

Access Codes

Let's start with an example: access codes.

In this domain, access codes:

  • are case-insensitive ("ABC", "abc", "AbC", and "abC" are equivalent)
  • are not equal if the values are blank / empty / nil ("\n", "", " ", and nil are not equivalent to each other or themselves)
  • should not be susceptible to timing attacks when values are compared

Testing

In this example, I'll be using the Ruby gem PropCheck alongside RSpec.

Defining Data Generators

PropCheck provides a mechanism for generating data within certain constraints; based on our domain, two data construction techniques are required:

def present_string
  alphanumeric_string.where(&:present?)
end

def empty_value
  one_of(constant(nil), constant(""), constant("\n"), constant("      "))
end

Simple Assertions

Let's start with two assertions around AccessCode#blank? returning true for an empty value and false for values that are present.

it "is blank for all empty values" do
  forall(code: empty_value) do |code:|
    expect(AccessCode.new(code)).to be_blank
  end
end

it "is not blank for all present values" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code)).not_to be_blank
  end
end

This introduces the forall method, which accepts a generator and makes instances available to the block.

Next, let's test case-insensitivity:

it "is equal even when casing differs" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code.downcase)).to eq AccessCode.new(code)
    expect(AccessCode.new(code)).to eq AccessCode.new(code.downcase)
    expect(AccessCode.new(code.upcase)).to eq AccessCode.new(code)
    expect(AccessCode.new(code)).to eq AccessCode.new(code.upcase)
  end
end

We'll also ensure if a string hasn't been cast at first, it still checks equality as expected.

it "is equal even if the compared value isn't an access code" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code)).to eq code
  end
end

Finally, we'll round out with a couple of other obvious rules around nil and blank values, and unmodified codes being equal:

it "is not equal when comparing against nil" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code)).not_to eq AccessCode.new(nil)
    expect(AccessCode.new(nil)).not_to eq AccessCode.new(code)
  end
end

it "is equal if the value is present and the same" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code)).to eq AccessCode.new(code)
  end
end

it "does not equal itself when blank" do
  forall(left: empty_value, right: empty_value) do |left:, right:|
    expect(AccessCode.new(left)).not_to eq AccessCode.new(right)
    expect(AccessCode.new(left)).not_to eq AccessCode.new(left)
    expect(AccessCode.new(right)).not_to eq AccessCode.new(right)
  end
end

Object Identity Assertions

More challenging are assertions around object identity. My former coworker Joël discussed this in his post about value object semantics, but let's get a few things in place here:

it "is #equal? IFF object identity is the same" do
  forall(code: present_string) do |code:|
    item = AccessCode.new(code)

    expect(item).to be_equal(item)
    expect(item).not_to be_equal(AccessCode.new(code))
  end
end

it "hashes the same for any empty value" do
  forall(left: empty_value, right: empty_value) do |left:, right:|
    expect(AccessCode.new(left).hash).to eq AccessCode.new(right).hash
    expect(AccessCode.new(left).hash).to eq AccessCode.new(left).hash
    expect(AccessCode.new(right).hash).to eq AccessCode.new(right).hash
  end
end

Finally, we'll assert against hash behavior for "present" values:

it "is represented as a hash correctly" do
  forall(code: present_string) do |code:|
    expect(AccessCode.new(code).hash).to eq AccessCode.new(code).hash
    expect(AccessCode.new(code.downcase).hash).to eq AccessCode.new(code).hash
    expect(AccessCode.new(code).hash).to eq AccessCode.new(code.downcase).hash
    expect(AccessCode.new(code.upcase).hash).to eq AccessCode.new(code).hash
    expect(AccessCode.new(code).hash).to eq AccessCode.new(code.upcase).hash
    expect(AccessCode.new(code).hash).not_to eq AccessCode.new(" #{code}").hash

    hash = {}
    hash[AccessCode.new(code)] = 1
    hash[AccessCode.new(code)] = 2

    expect(hash.keys.count).to eq 1
  end
end

Testing to Reduce Likelihood of Timing Attacks

Within ActiveSupport, we can use secure_compare; rather than property testing, we'll have to stick to some stubbing:

it "uses ActiveSupport::SecurityUtils to compare values" do
  result = double("compare result")
  allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("foo", "bar").and_return(result)

  comparison = AccessCode.new("foo") == AccessCode.new("bar")

  expect(comparison).to eq result
end

This test asserts that:

  • ActiveSupport::SecurityUtils.secure_compare is called with two exact arguments
  • the return value of #== is the outcome of the secure_compare call

While this test is brittle / asserts directly against the implementation, it's a small trade-off to ensure we're interacting with the correct comparison behavior.

Wrapping Up

With this final test, our rules have been properly defined with tests in place to verify.

With property tests, we can assert truths about AccessCode behavior, regardless of what values are used, and have confidence that the implementation is correct for various edge-cases. By capturing these object invariants in our class, constructing an access code from a field in the database or value from params or session within a Rails controller will help enforce case-insensitivity and secure comparison of strings.

An implementation of AccessCode that gets all the tests to pass is:

class AccessCode
  def initialize(value)
    @value = value.to_s.downcase
  end

  def blank?
    value.blank?
  end

  def ==(other)
    if blank? && other.blank?
      false
    else
      if !other.is_a?(AccessCode)
        other = AccessCode.new(other)
      end

      ActiveSupport::SecurityUtils.secure_compare(value, other.value)
    end
  end

  alias_method :eql?, :==

  def hash
    if blank?
      nil.hash
    else
      value.hash
    end
  end

  protected

  attr_reader :value
end

All of the code can be found here.