rspock 2.4.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ ---
2
+ name: rspock
3
+ description: >-
4
+ MUST be used when writing or modifying Minitest tests in any repo using
5
+ rspock (look for transform!(RSpock::AST::Transformation) in test files or
6
+ rspock in the Gemfile). RSpock rewrites test semantics via AST
7
+ transformation — code that looks like a no-op statement is an assertion,
8
+ and Minitest habits produce silently wrong tests.
9
+ ---
10
+
11
+ # RSpock: writing tests
12
+
13
+ RSpock is a Spock-inspired testing framework on top of Minitest. Tests are
14
+ valid Ruby syntax with **different semantics**, applied by AST
15
+ transformation at load time. Do not reason about these files as plain
16
+ Minitest.
17
+
18
+ ## The invariant that must never be violated
19
+
20
+ **Inside a `transform!(RSpock::AST::Transformation)` class, every bare
21
+ statement in a `Then`/`Expect` block IS an assertion. Outside one, it is
22
+ NOT — it evaluates and silently discards.**
23
+
24
+ Consequences:
25
+
26
+ - Inside a `transform!` class, write expression assertions — `a == b` in a
27
+ Then/Expect block. The transform compiles them to assertions with proper
28
+ failure messages; the Minitest assert API is not the dialect here.
29
+ - In a plain Minitest class, use the assert API (`assert_equal` and
30
+ friends). A bare comparison there evaluates and discards — a silently
31
+ green test.
32
+ - When editing a test file, first check for the `transform!` line at each
33
+ class definition; it decides which dialect that class speaks. Both
34
+ styles may legitimately coexist in one file (see `strict: false` below)
35
+ — match the dialect of the class you are in.
36
+
37
+ ## Boilerplate
38
+
39
+ ```ruby
40
+ require "test_helper"
41
+
42
+ transform!(RSpock::AST::Transformation)
43
+ class MyThingTest < Minitest::Test
44
+ test "descriptive name" do
45
+ # code blocks here
46
+ end
47
+ end
48
+ ```
49
+
50
+ The application must install the hook once (usually in the test helper):
51
+ `require "ast_transform"; ASTTransform.install`. That is the whole setup —
52
+ Rails apps need nothing extra (backtraces and debuggers are source-true by
53
+ construction; there is no backtrace cleaner to configure). Mixed files can
54
+ use `transform!(RSpock::AST::Transformation.new(strict: false))` to allow
55
+ plain Minitest tests alongside — this exists to ease gradual migration,
56
+ so treat mixed files as normal, not as something to unify.
57
+
58
+ The transform is an abstraction — trust it. If you ever need to see the
59
+ compiled Ruby (debugging only, never as routine verification), the
60
+ transformed files are written under `tmp/ast_transform/<relative path>`;
61
+ they are emitted line-aligned, so their line numbers match your source
62
+ exactly.
63
+
64
+ ## Code blocks and their order
65
+
66
+ `Given` (setup) → `When` (stimulus) → `Then` (response), or `Expect`
67
+ (stimulus+response in one), plus `Cleanup` (always runs; code defensively
68
+ with `&.`) and `Where` (data table, last in source but evaluated first).
69
+ Every block takes an optional description string. A `When` is always
70
+ followed by a `Then`. Use When+Then for side-effecting code, Expect for
71
+ pure functions.
72
+
73
+ ## Assertion forms (Then/Expect)
74
+
75
+ ```ruby
76
+ Then "the walk produced the right state"
77
+ actual == expected # binary operators: == != =~ !~ > < >= <=
78
+ list.include?(x) # bare boolean expression asserts
79
+ !cart.empty? # negation asserts
80
+ name = actual.first # assignments pass through (not assertions)
81
+ ```
82
+
83
+ LHS is actual, RHS is expected. Exception assertions live in Then, apply
84
+ to the preceding When, one per block:
85
+
86
+ ```ruby
87
+ Then "a parse error names the token"
88
+ e = raises JSON::ParserError # capture optional
89
+ e.message.include?("unexpected token")
90
+ ```
91
+
92
+ `raises` is not supported in Expect blocks.
93
+
94
+ ## Where tables (data-driven)
95
+
96
+ ```ruby
97
+ test "adding #{a} and #{b} gives #{c}" do
98
+ Expect
99
+ a + b == c
100
+
101
+ Where
102
+ a | b | c
103
+ -1 | 1 | 0
104
+ 0 | 0 | 0
105
+ 1 | 2 | 3
106
+ end
107
+ ```
108
+
109
+ Header names become local variables and interpolate into the test name.
110
+ The table is evaluated in class scope — it cannot see instance methods or
111
+ test-local variables. Order rows like a truth table; rightmost column is
112
+ the expected result.
113
+
114
+ To generate an exhaustive table instead of writing it by hand:
115
+
116
+ ```
117
+ rake rspock:truth_table -- a=-1,0,1 b=-1,0,1 expected_result="'?'"
118
+ ```
119
+
120
+ It emits the formatted cross-product (fill the `'?'` column manually).
121
+ Escape commas inside a value with `\,` (e.g. `b="gen(1\, 2)","gen(3\, 4)"`).
122
+ Non-Rails projects must load the gem's Rakefile once to get the task —
123
+ see the README's installation section.
124
+
125
+ ## Interaction mocking (Then only)
126
+
127
+ ```ruby
128
+ Then
129
+ 1 * subscriber.receive("hello") # exactly one call
130
+ 0 * mailer.deliver # must never be called
131
+ (1..3) * poller.tick # between one and three
132
+ (1.._) * poller.tick # at least once
133
+ (_..3) * poller.tick # at most three times
134
+ _ * cache.fetch("key") >> cached # any count, stubbed return
135
+ 1 * repo.find(42) >> raises(RecordNotFound) # stubbed exception
136
+ 1 * ui.frame("Build", &my_block) # block-identity check
137
+ ```
138
+
139
+ Declared in Then but installed before When runs — declare naturally,
140
+ RSpock handles ordering. Compiles to Mocha. Inline blocks (`{ }` /
141
+ `do...end`) are not allowed in interactions — use a named proc with `&`.
142
+ Mocks never yield blocks by design: needing that signals the unit under
143
+ test is doing too much — restructure so the mock boundary sits between
144
+ responsibilities.
145
+
146
+ ## Debugging failures
147
+
148
+ - Backtraces AND debugger display are source-true: transformed code is
149
+ emitted with every statement on its original source line, so line
150
+ numbers point at the file you wrote with no mapping layer. Trust them;
151
+ don't second-guess against `tmp/ast_transform/`.
152
+ - `break file:line` binds on user statements; interactive debuggers show
153
+ your real source. One documented oddity: interaction setups execute
154
+ before the When body, so stepping through a test with interactions
155
+ jumps from the interaction lines back up to the When line once.
156
+ - To isolate a Where row: the generated test name embeds the row's index
157
+ and source line (e.g. `... 1 line 15`). For a newly failing row, copy
158
+ the rerun command printed with the failure and add a plain
159
+ `binding.pry`. For a chosen row, run `-n /line_15/` with the line from
160
+ the editor gutter, or break conditionally on the column locals
161
+ themselves (`binding.pry if input == "not json"`). A source-line
162
+ breakpoint on a data row cannot isolate its run — the table evaluates
163
+ once, in class scope; name-selection is the mechanism.
164
+
165
+ ## Pitfalls (wrong → right)
166
+
167
+ ```ruby
168
+ # WRONG: Minitest API inside an RSpock class
169
+ assert_equal 3, add(1, 2)
170
+ # RIGHT
171
+ Expect
172
+ add(1, 2) == 3
173
+ ```
174
+
175
+ ```ruby
176
+ # WRONG: bare comparison in a class without transform! — silently green
177
+ class FooTest < Minitest::Test
178
+ test("x") { compute == 42 }
179
+ end
180
+ # RIGHT: add transform!(RSpock::AST::Transformation) above the class,
181
+ # or use assert_equal in plain Minitest
182
+ ```
183
+
184
+ ```ruby
185
+ # WRONG: Where table using an instance method for column data
186
+ Where
187
+ input | expected
188
+ helper_val | 1 # NameError: class scope
189
+ # RIGHT: use class methods or literals in Where rows
190
+ ```
191
+
192
+ ```ruby
193
+ # WRONG: expecting a mocked method to yield
194
+ 1 * ui.with_spinner("work") { drain(io) }
195
+ # RIGHT: restructure — mock boundary between responsibilities
196
+ success = drain(io) # test with a real StringIO
197
+ 1 * ui.ok("work") # simple expectation, no block
198
+ ```
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspock
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jean-Philippe Duchesne
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-28 00:00:00.000000000 Z
11
+ date: 2026-07-24 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -114,14 +114,14 @@ dependencies:
114
114
  requirements:
115
115
  - - "~>"
116
116
  - !ruby/object:Gem::Version
117
- version: '2.0'
117
+ version: '3.0'
118
118
  type: :runtime
119
119
  prerelease: false
120
120
  version_requirements: !ruby/object:Gem::Requirement
121
121
  requirements:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
- version: '2.0'
124
+ version: '3.0'
125
125
  - !ruby/object:Gem::Dependency
126
126
  name: minitest
127
127
  requirement: !ruby/object:Gem::Requirement
@@ -150,34 +150,6 @@ dependencies:
150
150
  - - ">="
151
151
  - !ruby/object:Gem::Version
152
152
  version: '1.0'
153
- - !ruby/object:Gem::Dependency
154
- name: parser
155
- requirement: !ruby/object:Gem::Requirement
156
- requirements:
157
- - - ">="
158
- - !ruby/object:Gem::Version
159
- version: '3.0'
160
- type: :runtime
161
- prerelease: false
162
- version_requirements: !ruby/object:Gem::Requirement
163
- requirements:
164
- - - ">="
165
- - !ruby/object:Gem::Version
166
- version: '3.0'
167
- - !ruby/object:Gem::Dependency
168
- name: unparser
169
- requirement: !ruby/object:Gem::Requirement
170
- requirements:
171
- - - ">="
172
- - !ruby/object:Gem::Version
173
- version: '0.6'
174
- type: :runtime
175
- prerelease: false
176
- version_requirements: !ruby/object:Gem::Requirement
177
- requirements:
178
- - - ">="
179
- - !ruby/object:Gem::Version
180
- version: '0.6'
181
153
  description: Data-driven testing framework.
182
154
  email:
183
155
  - jpduchesne89@gmail.com
@@ -188,9 +160,11 @@ files:
188
160
  - ".claude/CLAUDE.md"
189
161
  - ".cursor/rules/base.mdc"
190
162
  - ".cursor/rules/best-practices.mdc"
163
+ - ".github/CODEOWNERS"
191
164
  - ".github/workflows/ci.yml"
192
165
  - ".github/workflows/release.yml"
193
166
  - ".gitignore"
167
+ - ".ruby-version"
194
168
  - CHANGELOG.md
195
169
  - CODE_OF_CONDUCT.md
196
170
  - Gemfile
@@ -202,15 +176,13 @@ files:
202
176
  - bin/setup
203
177
  - block_graph
204
178
  - block_graph.png
179
+ - dependencies.rb
180
+ - dev.yml
205
181
  - lib/Rakefile
206
- - lib/generators/rspock/install_generator.rb
207
- - lib/generators/templates/rspock_initializer.rb
208
- - lib/minitest/rspock_plugin.rb
209
182
  - lib/rspock.rb
210
183
  - lib/rspock/ast/header_nodes_transformation.rb
211
184
  - lib/rspock/ast/interaction_to_block_identity_assertion_transformation.rb
212
185
  - lib/rspock/ast/interaction_to_mocha_mock_transformation.rb
213
- - lib/rspock/ast/method_call_to_lvar_transformation.rb
214
186
  - lib/rspock/ast/node.rb
215
187
  - lib/rspock/ast/parser/block.rb
216
188
  - lib/rspock/ast/parser/cleanup_block.rb
@@ -227,15 +199,14 @@ files:
227
199
  - lib/rspock/ast/test_method_dstr_transformation.rb
228
200
  - lib/rspock/ast/test_method_transformation.rb
229
201
  - lib/rspock/ast/transformation.rb
230
- - lib/rspock/backtrace_filter.rb
231
202
  - lib/rspock/declarative.rb
232
203
  - lib/rspock/helpers/block_capture.rb
233
- - lib/rspock/minitest/backtrace_filter.rb
234
204
  - lib/rspock/railtie.rb
235
205
  - lib/rspock/tasks/rspock.rake
236
206
  - lib/rspock/tasks/truth_table.rb
237
207
  - lib/rspock/version.rb
238
208
  - rspock.gemspec
209
+ - skills/rspock/SKILL.md
239
210
  homepage: https://github.com/rspockframework/rspock
240
211
  licenses:
241
212
  - MIT
@@ -248,7 +219,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
248
219
  requirements:
249
220
  - - ">="
250
221
  - !ruby/object:Gem::Version
251
- version: '3.2'
222
+ version: '3.3'
252
223
  required_rubygems_version: !ruby/object:Gem::Requirement
253
224
  requirements:
254
225
  - - ">="
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # 's' in Rspock is lowercase on purpose, so generator name is +rspock+ instead of +r_spock+
4
- module Rspock
5
- module Generators
6
- class InstallGenerator < Rails::Generators::Base
7
- source_root File.expand_path("../../templates", __FILE__)
8
- desc "Creates RSpock initializer for your application"
9
-
10
- def copy_initializer
11
- template "rspock_initializer.rb", "config/initializers/rspock.rb"
12
-
13
- puts "Install complete!"
14
- end
15
- end
16
- end
17
- end
@@ -1,2 +0,0 @@
1
- # frozen_string_literal: true
2
- Rails.backtrace_cleaner.add_filter { |line| RSpock::BacktraceFilter.new.filter_string(line) }
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'rspock/minitest/backtrace_filter'
3
-
4
- module Minitest
5
- def self.plugin_rspock_init(_options)
6
- unless defined?(Rails)
7
- Minitest.backtrace_filter = RSpock::Minitest::BacktraceFilter.new
8
- end
9
- end
10
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'ast_transform/abstract_transformation'
3
-
4
- module RSpock
5
- module AST
6
- class MethodCallToLVarTransformation < ASTTransform::AbstractTransformation
7
- def initialize(*method_symbols)
8
- @method_call_nodes = method_symbols.map { |method_sym|
9
- s(:send, nil, method_sym)
10
- }
11
- end
12
-
13
- def on_send(node)
14
- return super unless method_call_node?(node)
15
-
16
- node.updated(:lvar, [node.children[1]])
17
- end
18
-
19
- def method_call_node?(node)
20
- @method_call_nodes.include?(node)
21
- end
22
- end
23
- end
24
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'ast_transform/source_map'
3
-
4
- module RSpock
5
- class BacktraceFilter
6
- # Constructs a new BacktraceFilter instance.
7
- #
8
- # @param source_map_provider [::ASTTransform::SourceMap] The source map provider to be used.
9
- def initialize(source_map_provider: ::ASTTransform::SourceMap)
10
- @source_map_provider = source_map_provider
11
- end
12
-
13
- # Filters the backtrace of the given +exception+ and applies the filtered backtrace to the exception.
14
- #
15
- # @param exception [Exception] The exception to be filtered.
16
- #
17
- # @return [void]
18
- def filter_exception(exception)
19
- exception.set_backtrace(source_mapped_backtrace(exception))
20
- end
21
-
22
- # Filters the given location.
23
- #
24
- # @param location [String] A location string.
25
- #
26
- # @return [String] The filtered location.
27
- def filter_string(location)
28
- file_path, lineno = location.match(/([\S]+):(\d+)/).captures
29
- lineno = lineno.to_i
30
- absolute_path = File.expand_path(file_path)
31
-
32
- source_map = @source_map_provider.for_file_path(absolute_path)
33
- return location unless source_map
34
-
35
- line_number = source_map.line(lineno) || '?'
36
- location.sub("#{file_path}:#{lineno}", "#{source_map.source_file_path}:#{line_number}")
37
- end
38
-
39
- private
40
-
41
- def source_mapped_backtrace(e)
42
- e.backtrace_locations&.map(&method(:location_builder))
43
- end
44
-
45
- def location_builder(location)
46
- source_map = @source_map_provider.for_file_path(location.absolute_path || location.path)
47
- return location.to_s unless source_map
48
-
49
- line_number = source_map.line(location.lineno) || '?'
50
- "#{source_map.source_file_path}:#{line_number}"
51
- end
52
- end
53
- end
@@ -1,16 +0,0 @@
1
- # frozen_string_literal: true
2
- require 'rspock/backtrace_filter'
3
-
4
- module RSpock
5
- module Minitest
6
- class BacktraceFilter
7
- def initialize
8
- @backtrace_filter = RSpock::BacktraceFilter.new
9
- end
10
-
11
- def filter(backtrace)
12
- backtrace.map { |line| @backtrace_filter.filter_string(line) }
13
- end
14
- end
15
- end
16
- end