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
endThe 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: :testBasic 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
endGenerators
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)
}
endShrinking 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)
endHypothesis for Ruby
Hypothesis, originally a Python library, has a Ruby port (hypothesis-specs) with more sophisticated shrinking:
# Gemfile
gem "hypothesis-specs", group: :testrequire "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
endHypothesis 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 stringsTesting 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
endIdentifying 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
endInverse 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
endIdempotency 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
endInvariant 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
endCombining 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
endConfiguration
# 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: 500Summary
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.