Fixrb

brutal

Writes plain Ruby test files from what the code does.

A manifest, in YAML, gives a subject, contexts to combine and actuals to observe. Brutal runs every combination in a child process, records what the code returns, and writes a test file that checks each of these values with eql?, without any framework: ruby test_greeter.rb runs it. The test file is a picture of what the code does, not of what it should do: read it before you trust it, as you would read any code under review.

A pixel-art lumberjack splits a ruby on a tree stump with an axe; red chips fly off.
Brutal-driven development: the lumberjack of brutal’s README at work.
Version
2.0.0
Ruby
3.3 or later, with fork(2): CRuby on Linux and macOS
Dependencies
aw 1.0
License
MIT

Install

Add gem "brutal", require: false to your Gemfile, or run gem install brutal for the command alone. Brutal runs the code in a child process forked through aw: Windows and JRuby are not supported.

Usage

Given this class:

class Greeter
  def initialize(name)
    @name = name.capitalize
  end

  def salute
    "Hello #{@name}!"
  end
end

a manifest is a file named NAME_brutal.yaml, here greeter_brutal.yaml, next to it:

header: |
  require_relative "greeter"

subject: |
  Greeter.new(%{name})

contexts:
  name:
    - '"Alice"'
    - '"world"'

actuals:
  - "%{subject}.salute"
  - "%{subject}.salute.length"
bundle exec brutal test/greeter_brutal.yaml

Brutal writes test_greeter.rb next to the manifest:

# Generated by brutal from greeter_brutal.yaml: edit the manifest, then run brutal again.

require_relative "greeter"

# ------------------------------------------------------------------------------

actual = begin
  Greeter.new("Alice")
end

::Kernel.raise "name = \"Alice\": actual.salute" unless ("Hello Alice!").eql?((
  actual.salute
))
::Kernel.raise "name = \"Alice\": actual.salute.length" unless (12).eql?((
  actual.salute.length
))

# ------------------------------------------------------------------------------

actual = begin
  Greeter.new("world")
end

::Kernel.raise "name = \"world\": actual.salute" unless ("Hello World!").eql?((
  actual.salute
))
::Kernel.raise "name = \"world\": actual.salute.length" unless (12).eql?((
  actual.salute.length
))
ruby test/test_greeter.rb

A check that fails raises a RuntimeError that names the example and the actual, and the rest of the file does not run. Any test runner that loads files runs it too.

The manifest

Seven sections, all but contexts holding Ruby code: header, run once before the first example; before, run before each example; subject, the code of each example, with a %{name} for each context; contexts, a list of code fragments for each name; actuals, the expressions to observe, with %{subject} for the value of the subject; after, run after each example; footer, run once after the last. Only subject, contexts and actuals are required. There is one example for every combination of the contexts, in the order of the file.

The values are read as text, never converted to YAML types: 1,2, yes and ~ stay the Ruby code 1,2, yes and ~. A fragment is code, so a string needs its quotes, as '"Alice"' above. A value of several lines is a literal block (|). Each section, each subject once its fragments are in place, and each actual must be complete code on its own, so that no part can open a block, a string or a comment that another part closes, and so skip or swallow a check. Anchors, aliases, tags, unknown, duplicate or empty sections, unused contexts and placeholders outside the subject and the actuals are refused.

How the code runs

The code runs in a child process, as ruby will run the test file: at the top level, with self as main, the local variables of each section visible to the next ones, and __dir__ the directory of the test file. It runs from the current directory, with your rights: run brutal on your own manifests only. What the code does to the process stays in the child; what it does outside, to files or other processes, happens.

Then brutal runs the test file with the Ruby that runs brutal, in a new process, before it writes it, and writes it only if it passes. This catches what differs between brutal’s child and a real run: a constant the child inherited from brutal, an object id, a random number. So the code runs twice. An unchanged test file is run again too; --check runs the code once and writes nothing.

Each observed value is written as a Ruby literal, the same whatever the version of Ruby and the locale: nil, true, false, an Integer, a finite Float, a String in UTF-8, in binary or with ASCII characters only, a Symbol, and an Array or a Hash of these, by their exact class. Anything else is refused, with the example and the actual, never written as its inspect: observe a projection instead, such as .to_s, .to_a, .to_h or .size. The check is (expected).eql?((actual)), the expected value receiving the call: 1 and 1.0 differ, and an object that claims to equal everything does not pass.

The command

brutal PATH... writes test_NAME.rb next to each NAME_brutal.yaml given, or found in a directory given and its subdirectories, hidden entries and symbolic links to directories left out. Each manifest that fails is reported on the standard error with its path and the reason, and the others still run. The exit status is 0 when every manifest succeeded, 1 when one failed or a path designates no manifest, and 2 for a usage error.

A test file is written whole or not at all, through a temporary file renamed over it, and only over a file brutal wrote, whose first line says so: a test file written by hand, or by brutal 1, is an error. brutal --check PATH... generates every test file in memory and compares it with the one in place: run it in CI, so that a test file cannot drift from its manifest.

Limits

The README details the YAML rules, what the code sees in the child, the literals, the command, and the changes in 2.0: a test file written by brutal 1 must be deleted once, %% becomes %, and contexts combine in the order of the file.

Documentation