Let’s talk about testing, but not the way you might be used to. I often write tests by saying, “If I give this function these specific inputs, I expect this exact output.” It’s comforting. It’s predictable. But it’s also limited. What about all the inputs I didn’t think of? That’s where property-based testing comes in, and in the Ruby world, Rantly is a fantastic tool for the job.
Instead of checking examples, you check rules. You state truths about your code that should always hold, no matter what random, valid data you throw at it. It’s like stress-testing the core logic of your program. The first time a property test found a bug I had missed for weeks, I was sold.
Here’s the basic idea. You don’t test sort with [3, 1, 2]. You test properties: the sorted list should have the same length as the original, each element should be less than or equal to the next, and it should contain the same elements. Let’s see how that looks.
require 'rantly'
require 'rantly/rspec_extensions'
RSpec.describe Array do
it 'maintains properties when sorting integers' do
property_of {
Rantly { array(range(0, 50)) { integer } }
}.check { |random_array|
sorted = random_array.sort
# Property 1: Size remains constant
expect(sorted.size).to eq(random_array.size)
# Property 2: Order is non-decreasing
expect(sorted.each_cons(2).all? { |a, b| a <= b }).to be true
# Property 3: It's a permutation of the original
expect(sorted.sort).to eq(random_array.sort)
}
end
end
When you run this, Rantly generates many random arrays—maybe an empty array, a huge array, arrays with negative numbers. It’s testing the property of sorting, not a single case. If any of these properties fail for any generated input, you’ve found a problem.
Moving to business logic, example tests can be verbose. You might test a validation rule with five, ten, twenty examples. A property test can express the entire rule in one go. Consider a simple order validator.
class OrderValidator
CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY'].freeze
MAX_AMOUNT = 1_000_000
def self.valid_amount?(amount, currency)
amount > 0 &&
amount <= MAX_AMOUNT &&
CURRENCIES.include?(currency)
end
end
RSpec.describe OrderValidator do
it 'accepts all positive amounts up to the limit in valid currencies' do
property_of {
amount = Rantly { integer(range(1, OrderValidator::MAX_AMOUNT)) }
currency = Rantly { choose(*OrderValidator::CURRENCIES) }
[amount, currency]
}.check { |amount, currency|
expect(OrderValidator.valid_amount?(amount, currency)).to be true
}
end
it 'rejects amounts that are zero, negative, or too large, or invalid currencies' do
property_of {
# Generate "bad" data. Could be a bad amount, a bad currency, or both.
bad_amount = Rantly { frequency( [3, integer(range(-1000, 0))],
[1, integer(range(OrderValidator::MAX_AMOUNT + 1, OrderValidator::MAX_AMOUNT + 10000))] ) }
bad_currency = Rantly { sized(4) { string(:alnum) }.such_that { |c| !OrderValidator::CURRENCIES.include?(c) } }
# Randomly pick which to make bad, or both
use_bad_amount = boolean
use_bad_currency = boolean
amount = use_bad_amount ? bad_amount : integer(range(1, OrderValidator::MAX_AMOUNT))
currency = use_bad_currency ? bad_currency : choose(*OrderValidator::CURRENCIES)
[amount, currency]
}.check { |amount, currency|
# The property is that this combination should NOT be valid
expect(OrderValidator.valid_amount?(amount, currency)).to be false
}
end
end
This approach is powerful. The second test, for invalid cases, is more complex because the space of “invalid” is huge. I have to guide the generator to produce data likely to fail: negative amounts, overly large amounts, and gibberish currencies. The frequency method lets me weigh the chances, making the test more efficient.
Real systems have state. They’re not just functions that take input and return output; they have memory. Testing a cache is a classic example. You need to check that sequences of operations behave correctly.
class DataProcessor
def initialize
@cache = {}
end
def process(key, value)
return @cache[key] if @cache.key?(key)
processed = value * 2 # A "complex" operation
@cache[key] = processed
processed
end
end
RSpec.describe DataProcessor do
it 'returns the cached result for the same key, regardless of operation order' do
property_of {
Rantly {
# Generate a sequence of unique keys and their values
num_operations = range(1, 20)
keys = array(num_operations) { string(:alnum) }.such_that { |arr| arr.uniq.size == arr.size }
values = array(num_operations) { integer }
# Create a list of operations, possibly with repeats
operations = array(num_operations) { [choose(*keys), choose(*values)] }
operations
}
}.check(50) { |operations|
processor = DataProcessor.new
first_results = {}
# First pass: process each operation, record the first result for each key
operations.each do |key, value|
result = processor.process(key, value)
first_results[key] ||= result
end
# Property: Any subsequent call with the same key must match the first result
operations.each do |key, value|
expect(processor.process(key, value)).to eq(first_results[key])
end
}
end
end
This is a stateful property test. I generate a random sequence of operations (key-value pairs), run them through the processor, and then assert that the cache’s behavior is consistent. The test verifies the system’s behavior over time, not just at a single point.
As your domain gets more complex, your generators should too. You can build custom generators that understand what valid data looks like in your app.
module UserGenerators
def valid_email_generator
-> {
local_part = Rantly { "#{string(:lower, range(1,10))}.#{string(:lower, range(1,10))}" }
domain = Rantly { choose('example.com', 'test.co.uk', 'company.org') }
"#{local_part}@#{domain}"
}
end
def valid_user_attributes
-> {
name = Rantly { string(:alpha, range(1, 50)) }
email = valid_email_generator.call
age = Rantly { integer(range(18, 120)) }
{
name: name,
email: email,
age: age
}
}
end
end
RSpec.describe User do
include UserGenerators
it 'is valid with a wide range of correctly structured attributes' do
property_of {
Rantly(&valid_user_attributes)
}.check(100) { |attributes|
user = User.new(attributes)
expect(user.valid?).to be true
}
end
end
By wrapping my domain knowledge—what makes a valid email, acceptable name length, plausible age—into generators, my tests become more robust and easier to read. The test is a direct translation of a business rule: “A user with valid attributes should always be valid.”
One of the best features of tools like Rantly is called “shrinking.” When your property fails on a huge, messy random input, the tool doesn’t just shout “Failed on [ -194832, 0, 42, ... ]”. It tries to find the smallest, simplest input that still causes the failure. This turns a confusing bug report into a precise diagnosis.
Imagine a string calculator that parses custom delimiters, but has a subtle bug.
class StringCalculator
def add(numbers_string)
return 0 if numbers_string.empty?
delimiter = ','
if numbers_string.start_with?('//')
delimiter_line, numbers_string = numbers_string.split("\n", 2)
delimiter = delimiter_line[2..-1] # Assume delimiter is a single char
end
numbers_string.split(/[#{delimiter}\n]/).map(&:to_i).sum
end
end
RSpec.describe StringCalculator do
it 'correctly sums numbers separated by a custom delimiter' do
property_of {
delimiter = Rantly { string(:printable, range(1, 1)) } # Let's start with a single-char delimiter
numbers = Rantly { array(range(1, 5)) { integer(range(0, 100)) } }
[delimiter, numbers]
}.check { |delimiter, numbers|
input = "//#{delimiter}\n#{numbers.join(delimiter)}"
result = StringCalculator.new.add(input)
expect(result).to eq(numbers.sum)
}
end
end
This test might pass for a while. But what if the delimiter is a pipe | or a dot .? These are special characters in regular expressions. The line split(/[#{delimiter}\n]/) will fail because it creates an invalid regex character class like /[|\n]/ or /[.\n]/ (where the dot has special meaning). If this fails on the input "//.\n1.2.3", Rantly will try to shrink the input. It might find that the simplest failing case is "//.\n0". Immediately, you see the problem: the delimiter . is not being escaped in the regex. Shrinking points you straight at the root cause.
You don’t have to choose between property and example tests. They work best together. Use example tests for documented requirements, edge cases, and typical examples. Use property tests to explore the vast space of valid inputs and verify overarching rules.
class ShoppingCart
attr_reader :items
def initialize
@items = []
end
def add_item(product_id, quantity)
raise ArgumentError, 'Quantity must be positive' unless quantity.positive?
@items << { product_id: product_id, quantity: quantity }
end
def total_unique_items
@items.map { |i| i[:product_id] }.uniq.count
end
end
RSpec.describe ShoppingCart do
# Example-based: Document specific, important behavior
it 'raises an error when adding an item with zero quantity' do
cart = ShoppingCart.new
expect { cart.add_item('PROD1', 0) }.to raise_error(ArgumentError)
end
it 'raises an error when adding an item with negative quantity' do
cart = ShoppingCart.new
expect { cart.add_item('PROD1', -5) }.to raise_error(ArgumentError)
end
# Property-based: Explore general behavior
it 'correctly counts unique product IDs for any sequence of valid additions' do
property_of {
Rantly {
# Generate a list of operations: each is a valid product_id and a positive quantity
num_adds = range(1, 10)
array(num_adds) {
[string(:alnum, range(2, 10)), integer(range(1, 10))]
}
}
}.check { |operations|
cart = ShoppingCart.new
operations.each { |pid, qty| cart.add_item(pid, qty) }
# The property: total_unique_items should equal the count of unique product_ids we added
expected_unique_ids = operations.map(&:first).uniq.count
expect(cart.total_unique_items).to eq(expected_unique_ids)
}
end
end
The example tests are your specification. They say, “This exact thing must happen.” The property test is your safety net. It says, “And for all the other valid stuff, this general rule must hold.”
Finally, you can apply this to higher levels, like testing API endpoints. This is where it becomes incredibly valuable, as it can simulate many different client behaviors.
RSpec.describe 'Products API', type: :request do
it 'creates a product successfully for a variety of valid payloads' do
property_of {
Rantly {
{
product: {
name: string(:printable, range(1, 255)).such_that { |s| !s.strip.empty? },
price_cents: integer(range(1, 1000000)), # Store price in cents to avoid float issues
sku: string(:alnum, range(3, 20)),
# Generate a status that exists in our enum
status: choose('draft', 'active', 'archived')
}
}
}
}.check(15) { |payload| # Limit iterations for speed
post '/api/products', params: payload, headers: { 'Authorization' => 'Bearer test' }, as: :json
# Property 1: We get a successful creation response
expect(response).to have_http_status(:created)
# Property 2: The response body matches what we sent (for relevant fields)
response_json = JSON.parse(response.body).deep_symbolize_keys
expect(response_json[:product][:name]).to eq(payload[:product][:name])
expect(response_json[:product][:price_cents]).to eq(payload[:product][:price_cents])
# Property 3: The record is persisted with correct data
db_product = Product.find_by(sku: payload[:product][:sku])
expect(db_product).to be_present
expect(db_product.name).to eq(payload[:product][:name])
}
end
end
This kind of test can find issues with database constraints, serialization logic, or enum validations that you might not have considered with just a few hand-written examples.
In my experience, starting small is key. Pick a pure function in your code—a validation method, a formatter, a calculator—and write one property for it. Run it a few hundred times. You’ll be surprised at what you find. It’s not about replacing all your tests; it’s about adding a new, powerful way to think about what “correct” means for your code. It moves you from checking specific answers to verifying the underlying principles, which is a much stronger guarantee of quality.