Show HN: PBT – A property-based testing library for Ruby

Property-Based Testing in Ruby

Gem Version
Build Status
RubyDoc

A property-based testing tool for Ruby with experimental features that allow you to run test cases in parallel.

PBT stands for Property-Based Testing.

As for the results of the parallelization experiment, please refer the talk at RubyKaigi 2024: Unlocking Potential of Property Based Testing with Ractor.

What’s Property-Based Testing?

Property-Based Testing is a testing methodology that focuses on the properties a system should always satisfy, rather than checking individual examples. Instead of writing tests for predefined inputs and outputs, PBT allows you to specify the general characteristics that your code should adhere to and then automatically generates a wide range of inputs to verify these properties.

The key benefits of property-based testing include the ability to cover more edge cases and the potential to discover bugs that traditional example-based tests might miss. It’s particularly useful for identifying unexpected behaviors in your code by testing it against a vast set of inputs, including those you might not have considered.

For a more in-depth understanding of Property-Based Testing, please refer to external resources.

Installation

Add this line to your application’s Gemfile and run bundle install.

Off course you can install with gem intstall pbt.

Basic Usage

Simple property

# Let's say you have your own sort method.
def sort(array)
  return array if array.size <=2 # Here's a bug! It should be 1.
  pivot, *rest = array
  left, right = rest.partition { |n| n <=pivot }
  sort(left) + [pivot] + sort(right)
end

Pbt.assert do
  # The given block is executed 100 times with different arrays with random numbers.
  # Besides, if you set `worker: :ractor` option to `assert` method, it runs in parallel using Ractor.
  Pbt.property(Pbt.array(Pbt.integer)) do |numbers|
    result = sort(numbers)
    result.each_cons(2) do |x, y|
      raise "Sort algorithm is wrong." unless x <=y
    end
  end
end

# If the method has a bug, the test fails and it reports a minimum counterexample.
# For example, the sort method doesn't work for [0, -1].
#
# Pbt::PropertyFailure:
#   Property failed after 23 test(s)
#   seed: 43738985293126714007411539287084402325
#   counterexample: [0, -1]
#   Shrunk 40 time(s)
#   Got RuntimeError: Sort algorithm is wrong.

Explain The Snippet

The above snippet is very simple but contains the basic components.

Runner

Pbt.assert is the runner. The runner interprets and executes the given property. Pbt.assert takes a property and runs it multiple times. If the property fails, it tries to shrink the input that caused the failure.

Property

The snippet above declared a property by calling Pbt.property. The property describes the following:

  1. What the user wants to evaluate. This corresponds to the block (let’s call this predicate) enclosed by do end
  2. How to generate inputs for the predicate — using Arbitrary

The predicate block is a function that directly asserts, taking values generated by Arbitrary as input.

Arbitrary

Arbitrary generates random values. It is also responsible for shrinking those values if asked to shrink a failed value as input.

Here, we used only one type of arbitrary, Pbt.integer. There are many other built-in arbitraries, and you can create a variety of inputs by combining existing ones.

Shrink

In PBT, If a test fails, it attempts to shrink the case that caused the failure into a form that is easier for humans to understand.
In other words, instead of stopping the test itself the first time it fails and reporting the failed value, it tries to find the minimal value that causes the error.

When there is a test that fails when given an even number, a counterexample of [0, -1] is simpler and easier to understand than any complex example like [-897860, -930517, 577817, -16302, 310864, 856411, -304517, 86613, -78231].

Arbitrary

There are many built-in arbitraries in Pbt. You can use them to generate random values for your tests. Here are some representative arbitraries.

Primitives

“aagjZfao”

Pbt.boolean.generate(rng) #=> true or false
Pbt.constant(42).generate(rng) #=> 42 always”>

rng = Random.new

Pbt.integer.generate(rng)                  #=> 42
Pbt.integer(min: -1, max: 8).generate(rng) #=> Integer between -1 and 8

Pbt.symbol.generate(rng)                   #=> :atq

Pbt.ascii_char.generate(rng)               #=> "a"
Pbt.ascii_string.generate(rng)             #=> "aagjZfao"

Pbt.boolean.generate(rng)                  #=> true or false
Pbt.constant(42).generate(rng)             #=> 42 always

Composites

rng = Random.new

Pbt.array(Pbt.integer).generate(rng)                        #=> [121, -13141, 9825]
Pbt.array(Pbt.integer, max: 1, empty: true).generate(rng)   #=> [] or [42] etc.

Pbt.tuple(Pbt.symbol, Pbt.integer).generate(rng)            #=> [:atq, 42]

Pbt.fixed_hash(x: Pbt.symbol, y: Pbt.integer).generate(rng) #=> {x: :atq, y: 42}
Pbt.hash(Pbt.symbol, Pbt.integer).generate(rng)             #=> {atq: 121, ygab: -1142}

Pbt.one_of(:a, 1, 0.1).generate(rng)                        #=> :a or 1 or 0.1

See ArbitraryMethods module for more details.

What if property-based tests fail?

Once a test fails it’s time to debug. Pbt provides some features to help you debug.

How to reproduce

When a test fails, you’ll see a message like below.

Pbt::PropertyFailure:
  Property failed after 23 test(s)
  seed: 43738985293126714007411539287084402325
  counterexample: [0, -1]
  Shrunk 40 time(s)
  Got RuntimeError: Sort algorithm is wrong.
  # and backtraces

You can reproduce the failure by passing the seed to Pbt.assert.

Pbt.assert(seed: 43738985293126714007411539287084402325) do
  Pbt.property(Pbt.array(Pbt.integer)) do |number|
    # your test
  end
end

Verbose mode

You may want to know which values pass and which values fail. You can enable verbose mode by passing verbose: true to Pbt.assert.

Pbt.assert(verbose: true) do
  Pbt.property(Pbt.array(Pbt.integer)) do |numbers|
    # your failed test
  end
end

The verbose mode prints the results of each tested values.

Encountered failures were:
- [-897860, -930517, 577817, -16302, 310864, 856411, -304517, 86613, -78231]
- [310864, 856411, -304517, 86613, -78231]
- [-304517, 86613, -78231]
(snipped for README)
- [0, -3]
- [0, -2]
- [0, -1]

Execution summary:
. × [-897860, -930517, 577817, -16302, 310864, 856411, -304517, 86613, -78231]
. . √ [-897860, -930517, 577817, -16302, 310864]
. . √ [-930517, 577817, -16302, 310864, 856411]
. . √ [577817, -16302, 310864, 856411, -304517]
. . √ [-16302, 310864, 856411, -304517, 86613]
. . × [310864, 856411, -304517, 86613, -78231]
(snipped for README)
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [-2]
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ []
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . × [0, -1]
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [0]
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [-1]
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ []
. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . √ [0, 0]

Configuration

You can configure Pbt by calling Pbt.configure before running tests.

Pbt.configure do |config|
  # Whether to print verbose output. Default is `false`.
  config.verbose = false

  # The concurrency method to use. `:ractor`, `:thread`, `:process` and `:none` are supported. Default is `:none`.
  config.worker = :none

  # The number of runs to perform. Default is `100`.
  config.num_runs = 100

  # The seed to use for random number generation.
  # It's useful to reproduce failed test with the seed you'd pick up from failure messages. Default is a random seed.
  config.seed = 42

  # Whether to report exceptions in threads.
  # It's useful to suppress error logs on Ractor that reports many errors. Default is `false`.
  config.thread_report_on_exception = false

  # Whether to allow RSpec expectation and matchers in Ractor. It's quite experimental! Default is `false`.
  config.experimental_ractor_rspec_integration = false
end

Or, you can pass the configuration to Pbt.assert as an argument.

Pbt.assert(num_runs: 100, seed: 42) do
  # ...
end

Concurrency methods

One of the key features of Pbt is its ability to rapidly execute test cases in parallel or concurrently, using a large number of values (by default, 100) generated by Arbitrary.

For concurrent processing, you can specify any of the three workers—:ractor, :process, or :thread—using the worker option. Alternatively, choose :none for serial execution.

Pbt supports 3 concurrency methods and 1 sequential one. You can choose one of them by setting the worker option.

Be aware that the performance of each method depends on the test subject. For example, if the test subject is CPU-bound, :ractor may be the best choice. Otherwise, :none shall be the best choice for most cases. See benchmarks.

Ractor

:ractor worker is useful for test cases that are CPU-bound. But it’s experimental and has some limitations as described below. If you encounter any issues due to those limitations, consider using :process as workers whose benchmark is the most similar to :ractor.

Pbt.assert(worker: :ractor) do
  Pbt.property(Pbt.integer) do |n|
    # ...
  end
end

Limitation

Please note that Ractor support is an experimental feature of this gem. Due to Ractor’s limitations, you may encounter some issues when using it.

For example, you cannot access anything out of block.

a = 1

Pbt.assert(worker: :ractor) do
  Pbt.property(Pbt.integer) do |n|
    # You cannot access `a` here because this block is executed in a Ractor and it doesn't allow implicit sharing of objects.
    a + n #=> Ractor::RemoteError (can not share object between ractors)
  end
end

You cannot use any methods provided by test frameworks like expect or assert because they are not available in a Ractor.

it do
  Pbt.assert(worker: :ractor) do
    Pbt.property(Pbt.integer) do |n|
      # This is not possible because `self` if a Ractor here.
      expect(n).to be_an(Integer) #=> Ractor::RemoteError (cause by NoMethodError for `expect` or `be_an`)
    end
  end
end

If you’re a challenger, you can enable the experimental feature to allow using RSpec expectations and matchers in Ractor. It works but it’s quite experimental and could cause unexpected behaviors.

Please note that this feature depends on prism gem. If you use Ruby 3.2 or prior, you need to install the gem by yourself.

it do
  Pbt.assert(worker: :ractor, experimental_ractor_rspec_integration: true) do
    Pbt.property(Pbt.integer) do |n|
      # Some RSpec expectations and matchers are available in Ractor by hack.
      # Other features like `let`, `subject`, `before`, `after` that access out of block are still not available.
      expect(n).to be_an(Integer)
    end
  end
end

Process

If you’d like to run test cases that are CPU-bound and :ractor is not available, :process becomes a good choice.

Pbt.assert(worker: :process) do
  Pbt.property(Pbt.integer) do |n|
    # ...
  end
end

If you want to use :process, you need to install the parallel gem.

Thread

You may not need to run test cases with multi-threads.

Pbt.assert(worker: :thread) do
  Pbt.property(Pbt.integer) do |n|
    # ...
  end
end

If you want to use :thread, you need to install the parallel gem.

None

For most cases, :none is the best choice. It runs tests sequentially (without parallelism) but most test cases finishes within a reasonable time.

Pbt.assert(worker: :none) do
  Pbt.property(Pbt.integer) do |n|
    # ...
  end
end

TODOs

Once this project finishes the following, we will release v1.0.0.

  • Implement basic primitive arbitraries
  • Implement composite arbitraries
  • Support shrinking
  • Support multiple concurrency methods
    • Ractor
    • Process
    • Thread
    • None (Run tests sequentially)
  • Documentation
    • Add better examples
    • Arbitrary usage
    • Configuration
  • Benchmark
  • Rich report by verbose mode
  • (Partially) Allow to use expectations and matchers provided by test framework in Ractor if possible.
    • It’d be so hard to pass assertions like expect, assert to a Ractor.
  • Implement frequency arbitrary
  • Statistics feature to aggregate generated values
  • Decide DSL
  • Try Fiber
  • Stateful property-based testing

Development

Setup

bin/setup
bundle exec rake # Run tests and lint at once

Test

Lint

bundle exec rake standard:fix

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/ohbarye/pbt. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

The gem is available as open source under the terms of the MIT License.

Credits

This project draws a lot of inspiration from other testing tools, namely

Code of Conduct

Everyone interacting in the Pbt project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.

Note: This article have been indexed to our site. We do not claim legitimacy, ownership or copyright of any of the content above. To see the article at original source Click Here

Related Posts
Tony Staffieri appointed indefinite CEO of Rogers Communications thumbnail

Tony Staffieri appointed indefinite CEO of Rogers Communications

The change comes months after a power struggle enveloped the company Tony Staffieri is now the permanent president and CEO of Rogers. Staffieri was filling the role as interim leader since late November while a permanent leader was being picked. He was in the running for the permanent position since the beginning. Staffieri took over…
Read More
A bare light bulb may actually be fashionable.Cafe-style lighting is handmade and reasonably priced thumbnail

A bare light bulb may actually be fashionable.Cafe-style lighting is handmade and reasonably priced

吊るすだけなので簡単。テーブルやソファなどの家具にこだわってみても、照明がイマイチだと部屋が全体的にイケてない感じがしませんか? とはいえ、ヴィンテージ感のある照明とか、シーリングファンがついた照明とかってカッコいいけど高いんですよね…。予算をおさえてなんとかオシャレにできないものか…と考えたところ、思い浮かんだのがカフェの照明。コード付きのソケットと電球(ヴィンテージっぽい電球)だけのシンプルな照明のカフェって結構多いですよね。しかも、それが意外にオシャレだったり。ということで、早速Amazonや楽天で探してみました。うちは照明がライティングレール(ダクトレール)なので、そこに取り付けられるコード付きソケットを選び、電球はこちらにしました。ソケットは真鍮製でLED電球は「エジソン球」タイプとヴィンテージ風に揃えてみました。コード付きソケットと電球を合わせても1セットで2,500円ほどなので、オシャレなペンダントライトやシーリングライトを買うよりはかなりリーズナブルにできたのではないでしょうか。そのまま取り付けるとコードが長すぎる場合が多いので、切断したり結んだりしてコードを短くする必要があります。切断するのはなかなか大変そうなので、以下の動画を参考にして結んでみました。これだけだとまだ少し長いので、2箇所を輪っかに結んでみました。すべてを均等な長さにするのは結構面倒ですが、あえて不揃いにするのも味があっていいんじゃないかなと思います。3つ同じような種類の電球で揃えてみました。セットするとこんな感じになりました。一番左だけ以前購入した違う電球ですが、点灯してしまえばそこまで違和感はありませんでした。明かりをつけるとこんな感じ。電球の色も暖色系なので心が落ち着きますね。カフェ風というかバー風のような感じともいえますかね。今回は同じ種類の電球を並べて配置しましたが、全部違う種類の電球でもいい感じだったかもしれないですね。電球を付け替えるだけで雰囲気が結構変わると思うので、飽きたらいろいろと試してみたいと思います。Photo: もぎひでみ
Read More
The best dash cams of 2024 thumbnail

The best dash cams of 2024

A quality dash cam can be a valuable tool for new and experienced drivers by helping to keep an eye on the road as well as the interior of your vehicle. By recording the road in front of your vehicle, a dash cam can provide important information for authorities and insurance companies in the event
Read More
Index Of News