Property-Based Testing in Ruby with Rantly and Hypothesis

Property-Based Testing in Ruby with Rantly and Hypothesis

Example-based testing asks "does this specific input produce this specific output?" Property-based testing asks "does this output have this characteristic for all possible inputs?" Property testing generates hundreds of random examples and finds the edge cases you wouldn't think to write manually. This guide covers the Ruby ecosystem for property-based testing.

The Core Concept

Consider testing a sort function:

# Example-based — tests specific inputs
it "sorts an array" do
  expect([3, 1, 2].sort).to eq([1, 2, 3])
  expect([5, 5, 5].sort).to eq([5, 5, 5])
  expect([].sort).to eq([])
end

# Property-based — tests properties for any input
property("sorting is idempotent") do
  array = array_of(integer)
  sorted = array.sort
  expect(sorted.sort).to eq(sorted)  # sorting again doesn't change result
end

property("sorted array is non-decreasing") do
  array = array_of(integer)
  sorted = array.sort
  sorted.each_cons(2) do |a, b|
    expect(a).to be <= b
  end
end

The property tests run with 100+ randomly generated arrays, including empty arrays, single-element arrays, arrays with duplicates, and large arrays.

Rantly

Rantly is the most common property testing library for Ruby:

# Gemfile
gem "rantly", group: :test

Basic Usage with RSpec

require "rantly/rspec_extensions"

RSpec.describe "Integer arithmetic" do
  it "addition is commutative" do
    property_of {
      a = integer
      b = integer
      [a, b]
    }.check { |a, b|
      expect(a + b).to eq(b + a)
    }
  end

  it "multiplication distributes over addition" do
    property_of {
      a = integer(100)  # integers in range [-100, 100]
      b = integer(100)
      c = integer(100)
      [a, b, c]
    }.check { |a, b, c|
      expect(a * (b + c)).to eq((a * b) + (a * c))
    }
  end
end

Generators

Rantly provides generators for common types:

property_of {
  integer          # Any integer
  integer(10)      # Integer in [-10, 10]
  float            # Any float
  string           # Random string
  string(10)       # String of max length 10
  boolean          # true or false
  choose(1, 2, 3)  # One of the given values
  literal(:foo)    # Always :foo (use for fixed values in tuples)
}

Collections

property_of {
  # Array of N elements
  array(5) { integer }

  # Array of random length
  sized(rand(10)) { array { string } }

  # Hash with string keys and integer values
  hash { { string => integer } }
}

Custom Generators

Build generators for domain objects:

def email_generator
  Rantly { "#{string(10, /[a-z]/)}_#{integer(999)}@#{string(5, /[a-z]/)}.com" }
end

def price_generator
  Rantly { float.abs.round(2) }
end

def product_generator
  Rantly {
    {
      name:     string(20),
      price:    call(price_generator),
      quantity: range(0, 1000),
      sku:      string(8, /[A-Z0-9]/)
    }
  }
end

# Use in tests
it "total is sum of line items" do
  property_of {
    products = array(5) { call(product_generator) }
    quantities = products.map { |_| range(1, 10) }
    [products, quantities]
  }.check { |products, quantities|
    order = Order.new
    products.zip(quantities).each { |product, qty| order.add(product, qty) }

    expected_total = products.zip(quantities).sum { |p, q| p[:price] * q }
    expect(order.total).to be_within(0.01).of(expected_total)
  }
end

Shrinking in Rantly

When a property fails, you want the smallest input that reproduces the failure. Rantly has basic shrinking:

property_of {
  array { integer }
}.check(100, 2) do |array|
  # 2 = shrink factor — tries to find smaller failing input
  expect(array.uniq.sort).to eq(array.sort.uniq)
end

Hypothesis for Ruby

Hypothesis, originally a Python library, has a Ruby port (hypothesis-specs) with more sophisticated shrinking:

# Gemfile
gem "hypothesis-specs", group: :test
require "hypothesis"
require "hypothesis/extras/rspec"

RSpec.describe "String utilities" do
  include Hypothesis
  include Hypothesis::Possibilities

  it "reversing twice returns original" do
    hypothesis do
      str = any(strings)
      expect(str.reverse.reverse).to eq(str)
    end
  end

  it "length is non-negative" do
    hypothesis do
      str = any(strings)
      expect(str.length).to be >= 0
    end
  end
end

Hypothesis Strategies

# Built-in possibilities
any(integers)
any(integers(min: 0, max: 100))
any(strings)
any(floats)
any(booleans)
any(arrays_of(integers))
any(arrays_of(integers, min_size: 1))
any(hashes_of(strings, integers))

# Filtering
any(integers.filter { |n| n > 0 })    # positive integers
any(strings.filter { |s| s.length > 3 })

# Mapping
any(integers.map { |n| n.abs })        # non-negative integers
any(strings.map(&:downcase))            # lowercase strings

Testing Business Logic with Properties

Properties shine for business rules that should hold universally:

RSpec.describe "Pricing engine" do
  include Hypothesis
  include Hypothesis::Possibilities

  describe "discount rules" do
    it "discounted price is always less than or equal to original" do
      hypothesis do
        price = any(floats(min: 0.01, max: 9999.99).map { |f| f.round(2) })
        discount_pct = any(integers(min: 0, max: 100))

        discounted = PricingEngine.apply_discount(price, discount_pct)
        expect(discounted).to be <= price
      end
    end

    it "100% discount results in zero" do
      hypothesis do
        price = any(floats(min: 0.01, max: 9999.99).map { |f| f.round(2) })

        discounted = PricingEngine.apply_discount(price, 100)
        expect(discounted).to eq(0.0)
      end
    end

    it "0% discount does not change price" do
      hypothesis do
        price = any(floats(min: 0.01, max: 9999.99).map { |f| f.round(2) })

        discounted = PricingEngine.apply_discount(price, 0)
        expect(discounted).to eq(price)
      end
    end
  end

  describe "tax calculation" do
    it "tax is always non-negative" do
      hypothesis do
        price = any(floats(min: 0, max: 10_000).map { |f| f.round(2) })
        tax_rate = any(floats(min: 0, max: 0.5))

        expect(PricingEngine.calculate_tax(price, tax_rate)).to be >= 0
      end
    end

    it "total with tax is greater than or equal to base price" do
      hypothesis do
        price = any(floats(min: 0.01, max: 10_000).map { |f| f.round(2) })
        tax_rate = any(floats(min: 0, max: 0.5))

        total = PricingEngine.total_with_tax(price, tax_rate)
        expect(total).to be >= price
      end
    end
  end
end

Identifying Good Properties

Properties fall into a few common patterns:

Roundtrip Properties

What goes in comes back out:

it "serialization is a roundtrip" do
  hypothesis do
    user = any(users)
    json = user.to_json
    restored = User.from_json(json)
    expect(restored).to eq(user)
  end
end

Inverse Properties

The inverse operation undoes the original:

it "encryption and decryption are inverses" do
  hypothesis do
    plaintext = any(strings(min_size: 1))
    key = any(strings(exactly: 32))

    ciphertext = Cipher.encrypt(plaintext, key)
    expect(Cipher.decrypt(ciphertext, key)).to eq(plaintext)
  end
end

Idempotency Properties

Applying the operation twice gives the same result as once:

it "normalization is idempotent" do
  hypothesis do
    text = any(strings)
    normalized = TextNormalizer.normalize(text)
    expect(TextNormalizer.normalize(normalized)).to eq(normalized)
  end
end

Invariant Properties

A characteristic that must always hold:

it "cart total is always non-negative" do
  hypothesis do
    items = any(arrays_of(cart_items, min_size: 0))
    cart = Cart.new(items: items)
    expect(cart.total).to be >= 0
  end
end

Combining with Example-Based Tests

Property tests complement, not replace, example-based tests:

RSpec.describe EmailValidator do
  # Known good examples — document the intended behavior
  describe "valid emails" do
    %w[user@example.com user+tag@sub.example.co.uk].each do |email|
      it "accepts #{email}" do
        expect(EmailValidator.valid?(email)).to be true
      end
    end
  end

  # Known bad examples — document what should fail
  describe "invalid emails" do
    %w[notanemail @nodomain missing@].each do |email|
      it "rejects #{email}" do
        expect(EmailValidator.valid?(email)).to be false
      end
    end
  end

  # Property — verifies structural invariants
  it "never raises exceptions on any string input" do
    property_of { string }.check do |random_string|
      expect { EmailValidator.valid?(random_string) }.not_to raise_error
    end
  end
end

Configuration

# Control number of test cases per property
Rantly.default_count = 200  # default: 100

# Hypothesis timeout
Hypothesis.settings(max_examples: 500, timeout: 30)

For CI, increase the count for thorough validation:

env:
  HYPOTHESIS_MAX_EXAMPLES: 500

Summary

Property-based testing finds the cases you didn't imagine writing. It won't replace example-based tests — those document intent and cover known edge cases. But properties catch the bugs that fall through the gaps: off-by-one errors, integer overflow, empty collection handling, and unexpected interactions between values. The investment is writing generators for your domain types. Once those exist, property tests take one or two lines and explore a much larger input space than any hand-written test suite.

Start now free