Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [2.6.0] - 2026-07-20

### Added

- `skills/rspock/SKILL.md` ships in the gem: an agent skill teaching the RSpock dialect (the transformed-assertion invariant, code blocks, Where tables, interaction mocking, pitfalls). Installed automatically by tooling that scans gems for skills (e.g. d3mlabs/dev); consumable by any skills-aware agent.

## [2.5.0] - 2026-02-28

### Added
Expand Down Expand Up @@ -152,7 +158,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Initial release.

[Unreleased]: https://github.com/rspockframework/rspock/compare/v2.5.0...HEAD
[Unreleased]: https://github.com/rspockframework/rspock/compare/v2.6.0...HEAD
[2.6.0]: https://github.com/rspockframework/rspock/compare/v2.5.0...v2.6.0
[2.5.0]: https://github.com/rspockframework/rspock/compare/v2.4.0...v2.5.0
[2.4.0]: https://github.com/rspockframework/rspock/compare/v2.3.1...v2.4.0
[2.3.1]: https://github.com/rspockframework/rspock/compare/v2.3.0...v2.3.1
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
rspock (2.5.0)
rspock (2.6.0)
ast_transform (~> 2.0)
minitest (~> 5.0)
mocha (>= 1.0)
Expand Down
2 changes: 1 addition & 1 deletion lib/rspock/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module RSpock
VERSION = "2.5.0"
VERSION = "2.6.0"
end
193 changes: 193 additions & 0 deletions skills/rspock/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
name: rspock
description: >-
MUST be used when writing or modifying Minitest tests in any repo using
rspock (look for transform!(RSpock::AST::Transformation) in test files or
rspock in the Gemfile). RSpock rewrites test semantics via AST
transformation — code that looks like a no-op statement is an assertion,
and Minitest habits produce silently wrong tests.
---

# RSpock: writing tests

RSpock is a Spock-inspired testing framework on top of Minitest. Tests are
valid Ruby syntax with **different semantics**, applied by AST
transformation at load time. Do not reason about these files as plain
Minitest.

## The invariant that must never be violated

**Inside a `transform!(RSpock::AST::Transformation)` class, every bare
statement in a `Then`/`Expect` block IS an assertion. Outside one, it is
NOT — it evaluates and silently discards.**

Consequences:

- Inside a `transform!` class, write expression assertions — `a == b` in a
Then/Expect block. The transform compiles them to assertions with proper
failure messages; the Minitest assert API is not the dialect here.
- In a plain Minitest class, use the assert API (`assert_equal` and
friends). A bare comparison there evaluates and discards — a silently
green test.
- When editing a test file, first check for the `transform!` line at each
class definition; it decides which dialect that class speaks. Both
styles may legitimately coexist in one file (see `strict: false` below)
— match the dialect of the class you are in.

## Boilerplate

```ruby
require "test_helper"

transform!(RSpock::AST::Transformation)
class MyThingTest < Minitest::Test
test "descriptive name" do
# code blocks here
end
end
```

The application must install the hook once (usually in the test helper):
`require "ast_transform"; ASTTransform.install`. In a Rails app, also run
`rails g rspock:install` once — it adds an initializer registering
RSpock's filter with `Rails.backtrace_cleaner`, without which failure
backtraces point into transformed code instead of your source lines
(non-Rails projects need nothing; a bundled Minitest plugin handles it).
Mixed files can use
`transform!(RSpock::AST::Transformation.new(strict: false))` to allow
plain Minitest tests alongside — this exists to ease gradual migration,
so treat mixed files as normal, not as something to unify.

The transform is an abstraction — trust it. If you ever need to see the
compiled Ruby (debugging only, never as routine verification), the
transformed files are written under `tmp/ast_transform/<relative path>`.

## Code blocks and their order

`Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect`
(stimulus+response in one), plus `Cleanup` (always runs; code defensively
with `&.`) and `Where` (data table, last in source but evaluated first).
Every block takes an optional description string. A `When` is always
followed by a `Then`. Use When+Then for side-effecting code, Expect for
pure functions.

## Assertion forms (Then/Expect)

```ruby
Then "the walk produced the right state"
actual == expected # binary operators: == != =~ !~ > < >= <=
list.include?(x) # bare boolean expression asserts
!cart.empty? # negation asserts
name = actual.first # assignments pass through (not assertions)
```

LHS is actual, RHS is expected. Exception assertions live in Then, apply
to the preceding When, one per block:

```ruby
Then "a parse error names the token"
e = raises JSON::ParserError # capture optional
e.message.include?("unexpected token")
```

`raises` is not supported in Expect blocks.

## Where tables (data-driven)

```ruby
test "adding #{a} and #{b} gives #{c}" do
Expect
a + b == c

Where
a | b | c
-1 | 1 | 0
0 | 0 | 0
1 | 2 | 3
end
```

Header names become local variables and interpolate into the test name.
The table is evaluated in class scope — it cannot see instance methods or
test-local variables. Order rows like a truth table; rightmost column is
the expected result.

To generate an exhaustive table instead of writing it by hand:

```
rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'"
```

It emits the formatted cross-product (fill the `'?'` column manually).
Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`).
Non-Rails projects must load the gem's Rakefile once to get the task —
see the README's installation section.

## Interaction mocking (Then only)

```ruby
Then
1 * subscriber.receive("hello") # exactly one call
0 * mailer.deliver # must never be called
(1..3) * poller.tick # between one and three
(1.._) * poller.tick # at least once
(_..3) * poller.tick # at most three times
_ * cache.fetch("key") >> cached # any count, stubbed return
1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception
1 * ui.frame("Build", &my_block) # block-identity check
```

Declared in Then but installed before When runs — declare naturally,
RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` /
`do...end`) are not allowed in interactions — use a named proc with `&`.
Mocks never yield blocks by design: needing that signals the unit under
test is doing too much — restructure so the mock boundary sits between
responsibilities.

## Debugging failures

- Backtraces are source-mapped: line numbers point at the file you wrote,
not the transformed output. Trust them; don't second-guess against
`tmp/ast_transform/`.
- In Where-driven tests, the generated test name embeds the failing row's
test index and source line number — read those from the failure output
to identify the row. The same values are available in test scope as
`_test_index_` (zero-based) and `_line_number_`, e.g. for a conditional
`binding.pry if _line_number_ == 15` when debugging interactively.
Comparisons against them in Then/Expect blocks are not transformed into
assertions.

## Pitfalls (wrong → right)

```ruby
# WRONG: Minitest API inside an RSpock class
assert_equal 3, add(1, 2)
# RIGHT
Expect
add(1, 2) == 3
```

```ruby
# WRONG: bare comparison in a class without transform! — silently green
class FooTest < Minitest::Test
test("x") { compute == 42 }
end
# RIGHT: add transform!(RSpock::AST::Transformation) above the class,
# or use assert_equal in plain Minitest
```

```ruby
# WRONG: Where table using an instance method for column data
Where
input | expected
helper_val | 1 # NameError: class scope
# RIGHT: use class methods or literals in Where rows
```

```ruby
# WRONG: expecting a mocked method to yield
1 * ui.with_spinner("work") { drain(io) }
# RIGHT: restructure — mock boundary between responsibilities
success = drain(io) # test with a real StringIO
1 * ui.ok("work") # simple expectation, no block
```
Loading