ruby

**Ruby Property-Based Testing with Rantly: Test Code Rules, Not Just Examples**

Discover Ruby property-based testing with Rantly. Learn to test code properties vs examples, catch hidden bugs, and strengthen validation logic. Includes practical code examples for robust testing.

**Ruby Property-Based Testing with Rantly: Test Code Rules, Not Just Examples**

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.

Keywords: property-based testing, Ruby property testing, Rantly gem, software testing Ruby, test automation Ruby, random testing, generative testing, Ruby testing framework, test-driven development Ruby, software quality assurance, Ruby test generators, stateful testing, property testing examples, Ruby unit testing, test data generation, automated testing strategies, Ruby testing best practices, dynamic testing approaches, software verification Ruby, test case generation, Ruby development testing, functional testing Ruby, regression testing automation, Ruby code validation, testing methodologies Ruby, property-based test design, Ruby testing tools, software testing techniques, test coverage improvement, Ruby application testing, quality assurance automation, testing random inputs, Ruby test optimization, software reliability testing, property testing patterns, Ruby testing libraries, test automation frameworks, software testing principles, Ruby testing strategies, advanced testing techniques, test-driven development practices, software testing automation, Ruby testing methodologies, property testing implementation, test case automation, software quality testing, Ruby testing solutions, automated test generation, testing edge cases, software validation techniques, Ruby testing patterns, property testing benefits, test data management, software testing frameworks, Ruby testing approaches, automated testing tools, testing complex systems, software testing best practices, Ruby testing examples, property testing tutorial



Similar Posts
Blog Image
Why Is ActiveMerchant Your Secret Weapon for Payment Gateways in Ruby on Rails?

Breathe New Life into Payments with ActiveMerchant in Your Rails App

Blog Image
Rails Authentication Guide: Implementing Secure Federated Systems [2024 Tutorial]

Learn how to implement secure federated authentication in Ruby on Rails with practical code examples. Discover JWT, SSO, SAML integration, and multi-domain authentication techniques. #RubyOnRails #Security

Blog Image
**Advanced Ruby Testing: 8 Essential Mock and Stub Patterns for Complex Scenarios**

Discover advanced Ruby mocking patterns for complex testing scenarios. Master sequence testing, fault injection, time control & event-driven systems.

Blog Image
Unlock Modern JavaScript in Rails: Webpacker Mastery for Seamless Front-End Integration

Rails with Webpacker integrates modern JavaScript tooling into Rails, enabling efficient component integration, dependency management, and code organization. It supports React, TypeScript, and advanced features like code splitting and hot module replacement.

Blog Image
What's the Secret Sauce Behind Ruby's Metaprogramming Magic?

Unleashing Ruby's Superpowers: The Art and Science of Metaprogramming

Blog Image
7 Ruby Techniques for High-Performance API Response Handling

Discover 7 powerful Ruby techniques to optimize API response handling for faster apps. Learn JSON parsing, object pooling, and memory-efficient strategies that reduce processing time by 60-80% and memory usage by 40-50%.