Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

All notable changes to ActionFigure will be documented in this file.

## [0.7.0] - 2026-06-29

### Added

- **Contract assertions** — test an action's **`params_schema`**/**`rules`** in isolation, without invoking the action body. Minitest: **`assert_valid_params(action_class, params)`** and **`assert_invalid_params(action_class, params, on: :field)`**. RSpec: **`accept_params(params)`** and **`reject_params(params).with_error_on(:field)`** (subject is the action class). These are formatter-agnostic. Both raise a clear **`ArgumentError`** for actions without a **`params_schema`**.
- **`assert_action_json`** / **`refute_action_json`** Minitest assertions — partial match on **`result[:json]`**, mirroring the RSpec **`have_action_json`** matcher. Nested Hashes match as subsets and **`Regexp`** values match against strings.
- Negated Minitest status assertions: **`refute_Ok`**, **`refute_Created`**, … for every status (parity with RSpec **`not_to be_Ok`**).

### Changed

- Status helpers for both adapters are now generated from a single **`ActionFigure::Testing::STATUSES`** registry (including **`NoContent`**), so the Minitest and RSpec lists can no longer drift. Replaces the RSpec-only **`ActionFigure::Testing::RSpec::MATCHERS`** constant.
- Status assertions/matchers now fail with a clear "expected an ActionFigure result hash" message when given a non-Hash, instead of raising **`NoMethodError`**.

### Known limitation

- There is **no** formatter-agnostic assertion for **non-validation** error bodies (e.g. **`NotFound`**/**`Conflict`** with a custom **`errors:`** payload). Each formatter stores errors differently (**`json[:errors]`** vs **`json[:data]`** vs a JSON:API array) and the result hash carries no formatter identity. Assert those with a format-specific **`assert_action_json`** / **`have_action_json`**. Validation errors are best tested via the contract assertions above.

## [0.6.2] - 2026-06-25

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ Every action class has three responsibilities:
| [Actions](docs/actions.md) | Automatic entry point discovery, context injection via keyword arguments, per-class API versioning, and `entry_point` for disambiguation. |
| [Configuration](docs/configuration.md) | Global defaults for response format, parameter strictness, and API version. All overridable per-class. |
| [Notifications](docs/activesupport-notifications.md) | Opt-in `ActiveSupport::Notifications` events for every action call. Emits action class, outcome status, and duration on the `process.action_figure` event. |
| [Testing](docs/testing.md) | Minitest assertions (`assert_Ok`, `assert_Created`, ...) and RSpec matchers (`be_Ok`, `be_Created`, ...) for expressive status checks. |
| [Testing](docs/testing.md) | Status assertions/matchers (`assert_Ok`/`be_Ok`, ... plus negated `refute_Ok`), body matchers (`assert_action_json`/`have_action_json`), and formatter-agnostic contract assertions (`assert_valid_params`/`accept_params`, `assert_invalid_params`/`reject_params`). |
| [Integration Patterns](docs/integration-patterns.md) | Recipes for serializers (Blueprinter, Alba, Oj Serializers), authorization (Pundit, CanCanCan), and pagination (cursor, Pagy). |

## Design Philosophy
Expand Down
59 changes: 59 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ end
| `assert_Conflict(result)` | `:conflict` |
| `assert_PaymentRequired(result)` | `:payment_required` |

Each status assertion has a negated counterpart — **`refute_Ok`**, **`refute_Created`**, … — that passes when the status is anything *other* than the named one.

These helpers compare **only `result[:status]`** against the Rack-style symbol Rails uses in **`render`** — they **do not** assert on **`[:json]`** keys, payloads, or error message text. Combine them with assertions on **`result[:json]`** (or matchers on the body your formatter produces) whenever shape matters.

All assertions accept an optional second argument for a custom failure message:
Expand All @@ -50,6 +52,18 @@ When a status assertion fails, the default message shows the expected and actual
Expected result status to be :ok, but got :unprocessable_content
```

### Asserting on the body

Use **`assert_action_json`** to match a (possibly nested) subset of **`result[:json]`** — the Minitest counterpart to RSpec's **`have_action_json`**. Nested Hashes match as subsets, and **`Regexp`** values match against strings:

```ruby
assert_action_json(result, status: "success")
assert_action_json(result, status: "success", data: { name: "Jane" })
assert_action_json(result, data: { email: /@example\.com\z/ })
```

**`refute_action_json`** passes when the fragment does **not** match. Both fail with a clear message when given a non-result value or a hash missing the **`:json`** key.

---

## RSpec
Expand Down Expand Up @@ -79,6 +93,8 @@ require "action_figure/testing/rspec"
| `be_Conflict` | `:conflict` |
| `be_PaymentRequired` | `:payment_required` |
| `have_action_json` | `result[:json]` matches `a_hash_including(fragment)` |
| `accept_params(params)` | action class's contract accepts `params` |
| `reject_params(params)` | action class's contract rejects `params` (chain `.with_error_on(:field)`) |

Like the Minitest helpers, each **`be_*`** matcher compares **only `result[:status]`** — **`[:json]`** is ignored unless you assert on it separately. Use **`have_action_json`** when you want a focused assertion against the **`json`** body (compose with **`a_hash_including`** for nested subsets):

Expand Down Expand Up @@ -279,6 +295,49 @@ class Users::CreateActionTest < Minitest::Test
end
```

### Contract assertion helpers

The testing adapters wrap the `.contract.call` boilerplate above in intention-revealing helpers. They are **formatter-agnostic** — they exercise the validation pipeline directly, so the same assertions work regardless of which formatter the action includes.

**Minitest** — the subject is the action class:

```ruby
class Users::CreateActionTest < Minitest::Test
include ActionFigure::Testing::Minitest

def test_accepts_valid_params
assert_valid_params(Users::CreateAction, { email: "jane@example.com", name: "Jane" })
end

def test_requires_email
# passes when the contract rejects the params at all
assert_invalid_params(Users::CreateAction, { name: "Jane" })

# scope to a field: passes only when :email is among the errors
assert_invalid_params(Users::CreateAction, { name: "Jane" }, on: :email)
end
end
```

**RSpec** — the subject is the action class, not a result hash:

```ruby
RSpec.describe Users::CreateAction do
it "accepts valid params" do
expect(Users::CreateAction).to accept_params(email: "jane@example.com", name: "Jane")
end

it "requires email" do
expect(Users::CreateAction).to reject_params(name: "Jane")
expect(Users::CreateAction).to reject_params(name: "Jane").with_error_on(:email)
end
end
```

Both adapters raise a clear **`ArgumentError`** when the action class declares no **`params_schema`** (and therefore has no contract to validate against).

> **Validation errors vs. error bodies.** These helpers are the right tool for asserting *validation* behavior. There is no formatter-agnostic helper for **non-validation** error bodies (e.g. a `NotFound`/`Conflict` you return with a custom `errors:` payload) — each formatter stores those differently and the result hash carries no formatter identity. Assert those with a format-specific `assert_action_json` / `have_action_json`.

---

## Conventions
Expand Down
95 changes: 73 additions & 22 deletions lib/action_figure/testing/minitest.rb
Original file line number Diff line number Diff line change
@@ -1,58 +1,109 @@
# frozen_string_literal: true

require_relative "statuses"

module ActionFigure
module Testing
# Minitest assertions for ActionFigure response results.
#
# Include in your test class to get assert_Ok, assert_Created, etc.:
# Include in your test class to get assert_Ok, assert_Created, etc.
# (and the negated refute_Ok, refute_Created, ...):
#
# class Users::CreateTest < Minitest::Test
# include ActionFigure::Testing::Minitest
# end
#
# The status helpers are generated from ActionFigure::Testing::STATUSES so the
# Minitest and RSpec adapters never drift.
module Minitest
def assert_Ok(result, msg = nil)
assert_status(:ok, result, msg)
end
STATUSES.each do |name, status|
define_method(:"assert_#{name}") do |result, msg = nil|
assert_status(status, result, msg)
end

def assert_Created(result, msg = nil)
assert_status(:created, result, msg)
define_method(:"refute_#{name}") do |result, msg = nil|
refute_status(status, result, msg)
end
end

def assert_Accepted(result, msg = nil)
assert_status(:accepted, result, msg)
def assert_valid_params(action_class, params, msg = nil)
result = action_contract(action_class).call(params)
message = msg || "Expected params to be valid, but got errors: #{result.errors.to_h.inspect}"
assert result.success?, message
end

def assert_NoContent(result, msg = nil)
assert_status(:no_content, result, msg)
end
def assert_invalid_params(action_class, params, on: nil, msg: nil)
result = action_contract(action_class).call(params)

def assert_UnprocessableContent(result, msg = nil)
assert_status(:unprocessable_content, result, msg)
if on.nil?
message = msg || "Expected params to be invalid, but the contract accepted them"
assert result.failure?, message
else
errored = result.errors.to_h.key?(on)
message = msg || "Expected params to be invalid on #{on.inspect}, " \
"but errors were: #{result.errors.to_h.inspect}"
assert errored, message
end
end

def assert_NotFound(result, msg = nil)
assert_status(:not_found, result, msg)
# Asserts +result[:json]+ contains the given fragment as a (possibly nested) subset.
# Mirrors the RSpec +have_action_json+ matcher: nested Hashes match as subsets and
# Regexp values match against strings.
#
# assert_action_json(result, status: "success", data: { name: "Jane" })
def assert_action_json(result, fragment, msg = nil)
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)
unless result.key?(:json)
flunk(msg || "Expected #{result.inspect} to include key :json (ActionFigure render hash)")
end

message = msg || "Expected result[:json] to include #{fragment.inspect}, got #{result[:json].inspect}"
assert json_subset?(result[:json], fragment), message
end

def assert_Forbidden(result, msg = nil)
assert_status(:forbidden, result, msg)
def refute_action_json(result, fragment, msg = nil)
json = result.is_a?(Hash) ? result[:json] : nil
message = msg || "Expected result[:json] not to include #{fragment.inspect}"
refute(json.is_a?(Hash) && json_subset?(json, fragment), message)
end

def assert_Conflict(result, msg = nil)
assert_status(:conflict, result, msg)
private

def json_subset?(actual, expected)
return false unless actual.is_a?(Hash)

expected.all? do |key, value|
actual.key?(key) && json_value_match?(actual[key], value)
end
end

def assert_PaymentRequired(result, msg = nil)
assert_status(:payment_required, result, msg)
def json_value_match?(actual, expected)
case expected
when Hash then json_subset?(actual, expected)
when Regexp then expected.match?(actual.to_s)
else actual == expected
end
end

private
def action_contract(action_class)
ActionFigure::Testing.contract_for(action_class)
end

def assert_status(expected, result, msg)
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)

actual = result[:status]
message = msg || "Expected result status to be #{expected.inspect}, but got #{actual.inspect}"
assert actual == expected, message
end

def refute_status(expected, result, msg)
flunk(msg || "Expected an ActionFigure result hash, got #{result.inspect}") unless result.is_a?(Hash)

actual = result[:status]
message = msg || "Expected result status not to be #{expected.inspect}"
refute actual == expected, message
end
end
end
end
67 changes: 53 additions & 14 deletions lib/action_figure/testing/rspec.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# frozen_string_literal: true

require "rspec/matchers"
require_relative "statuses"

module ActionFigure
module Testing
Expand All @@ -17,22 +18,14 @@ module Testing
# end
# end
module RSpec
MATCHERS = {
Ok: :ok,
Created: :created,
Accepted: :accepted,
NoContent: :no_content,
UnprocessableContent: :unprocessable_content,
NotFound: :not_found,
Forbidden: :forbidden,
Conflict: :conflict,
PaymentRequired: :payment_required
}.freeze

MATCHERS.each do |name, status|
# Generated from ActionFigure::Testing::STATUSES so the RSpec and Minitest
# adapters never drift.
STATUSES.each do |name, status|
::RSpec::Matchers.define :"be_#{name}" do
match { |result| result[:status] == status }
match { |result| result.is_a?(Hash) && result[:status] == status }
failure_message do |result|
next "expected an ActionFigure result hash, got #{result.inspect}" unless result.is_a?(Hash)

"expected result status to be #{status.inspect}, but got #{result[:status].inspect}"
end
failure_message_when_negated do
Expand Down Expand Up @@ -67,6 +60,52 @@ module RSpec
"#{result.inspect} was expected not to match #{@inner_matcher.description}"
end
end

# Asserts an action class's params_schema/rules accept the given params.
# Subject is the action class, not a result hash:
#
# expect(Users::Create).to accept_params(email: "jane@example.com")
::RSpec::Matchers.define :accept_params do |params|
match { |action_class| ActionFigure::Testing.contract_for(action_class).call(params).success? }

failure_message do |action_class|
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
"expected #{action_class} to accept params, but got errors: #{errors.inspect}"
end

failure_message_when_negated do |action_class|
"expected #{action_class} not to accept params #{params.inspect}, but it did"
end
end

# Asserts an action class's params_schema/rules reject the given params.
# Chain +with_error_on+ to require the failure to land on a specific field:
#
# expect(Users::Create).to reject_params(name: "Jane").with_error_on(:email)
::RSpec::Matchers.define :reject_params do |params|
chain(:with_error_on) { |field| @field = field }

match do |action_class|
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
next false if errors.empty?

@field.nil? || errors.key?(@field)
end

failure_message do |action_class|
errors = ActionFigure::Testing.contract_for(action_class).call(params).errors.to_h
if @field
"expected #{action_class} to reject params with an error on #{@field.inspect}, " \
"but errors were: #{errors.inspect}"
else
"expected #{action_class} to reject params #{params.inspect}, but it accepted them"
end
end

failure_message_when_negated do |action_class|
"expected #{action_class} not to reject params #{params.inspect}"
end
end
end
end
end
37 changes: 37 additions & 0 deletions lib/action_figure/testing/statuses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# frozen_string_literal: true

require "action_figure"

module ActionFigure
# Helpers shared by the Minitest and RSpec testing adapters.
module Testing
# Converts a helper name (+:UnprocessableContent+) to its Rack-style status
# symbol (+:unprocessable_content+).
def self.status_symbol(name)
name.to_s.gsub(/([a-z])([A-Z])/, '\1_\2').downcase.to_sym
end

# Resolves an action class's validation contract, raising a clear error when
# the class declares no +params_schema+ (and therefore has no contract).
def self.contract_for(action_class)
contract = action_class.contract
return contract if contract

raise ArgumentError,
"#{action_class} defines no params_schema, so it has no contract to validate against"
end

# Single source of truth for the status-helper names exposed by the testing
# adapters, mapping each helper name to the status symbol Rails uses in
# +render+. Both the Minitest assertions (+assert_Ok+, +refute_Ok+, ...) and
# the RSpec matchers (+be_Ok+, ...) are generated from this map so the two
# adapters never drift.
#
# +NoContent+ lives on +Formatter+ rather than +Formatter::REQUIRED_METHODS+,
# so it is added here explicitly.
STATUSES = ActionFigure::Formatter::REQUIRED_METHODS
.to_h { |name| [name, status_symbol(name)] }
.merge(NoContent: :no_content)
.freeze
end
end
Loading