From e3cf67b316f52583331d7b4d500f0823de3712ef Mon Sep 17 00:00:00 2001 From: Pedro Fernandes Steimbruch Date: Fri, 5 Dec 2025 10:01:13 -0300 Subject: [PATCH 1/5] Add Minitest support with assertion-style test helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive Minitest testing support alongside existing RSpec matchers, providing the same testing capabilities for Minitest users. Features: - 5 assertion methods for testing Inertia responses: - assert_inertia_component - assert_inertia_exact_props - assert_inertia_includes_props - assert_inertia_exact_view_data - assert_inertia_includes_view_data - Automatic render interception via setup/teardown hooks - Configuration system (InertiaRails::Minitest.config) - Warning system for missing Inertia renderers - Full test coverage (23 passing specs) Usage: require 'inertia_rails/minitest' class ActionDispatch::IntegrationTest include InertiaRails::Minitest::Helpers end The implementation mirrors the RSpec helpers but uses classic Minitest assertion style instead of expectation matchers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 98 +++++++- lib/inertia_rails/minitest.rb | 160 ++++++++++++++ spec/inertia/minitest_helper_spec.rb | 320 +++++++++++++++++++++++++++ 3 files changed, 566 insertions(+), 12 deletions(-) create mode 100644 lib/inertia_rails/minitest.rb create mode 100644 spec/inertia/minitest_helper_spec.rb diff --git a/README.md b/README.md index 698d9702..da509562 100644 --- a/README.md +++ b/README.md @@ -293,15 +293,17 @@ end ## Testing -If you're using Rspec, Inertia Rails comes with some nice test helpers to make things simple. +Inertia Rails provides test helpers for both RSpec and Minitest to make testing Inertia responses simple. -To use these helpers, just add the following require statement to your `spec/rails_helper.rb` +### RSpec + +If you're using RSpec, add the following require statement to your `spec/rails_helper.rb`: ```ruby require 'inertia_rails/rspec' ``` -And in any test you want to use the inertia helpers, add the inertia flag to the describe block +Then add the `inertia: true` flag to any describe block where you want to use the inertia helpers: ```ruby RSpec.describe EventController, type: :request do @@ -311,35 +313,107 @@ RSpec.describe EventController, type: :request do end ``` -### Assertions +#### RSpec Assertions ```ruby RSpec.describe EventController, type: :request do describe '#index', inertia: true do - + # check the component expect_inertia.to render_component 'Event/Index' - + # access the component name expect(inertia.component).to eq 'TestComponent' - + # props (including shared props) expect_inertia.to have_exact_props({name: 'Brandon', sport: 'hockey'}) expect_inertia.to include_props({sport: 'hockey'}) - + # access props expect(inertia.props[:name]).to eq 'Brandon' - + # view data expect_inertia.to have_exact_view_data({name: 'Brian', sport: 'basketball'}) expect_inertia.to include_view_data({sport: 'basketball'}) - - # access view data + + # access view data expect(inertia.view_data[:name]).to eq 'Brian' - + end end +``` + +### Minitest + +If you're using Minitest, add the following to your `test/test_helper.rb`: + +```ruby +require 'inertia_rails/minitest' + +class ActionDispatch::IntegrationTest + include InertiaRails::Minitest::Helpers +end +``` + +#### Minitest Assertions + +```ruby +class EventsControllerTest < ActionDispatch::IntegrationTest + test "renders the index component" do + get events_path + + # check the component + assert_inertia_component 'Event/Index' + # access the component name + assert_equal 'Event/Index', inertia.component + + # props (including shared props) + assert_inertia_exact_props(name: 'Brandon', sport: 'hockey') + assert_inertia_includes_props(sport: 'hockey') + + # access props + assert_equal 'Brandon', inertia.props[:name] + + # view data + assert_inertia_exact_view_data(name: 'Brian', sport: 'basketball') + assert_inertia_includes_view_data(sport: 'basketball') + + # access view data + assert_equal 'Brian', inertia.view_data[:name] + end +end +``` + +#### Note on First Load vs Sequential Requests + +When testing, be aware that first-load (HTML) responses use **symbol keys** for props, while sequential (JSON) requests use **string keys**: + +```ruby +# First load - symbol keys +get events_path +assert_inertia_exact_props(name: 'Brandon') + +# Sequential request - string keys +get events_path, headers: { 'X-Inertia': true } +assert_inertia_exact_props('name' => 'Brandon') +``` + +#### Configuration + +You can configure Minitest test helpers globally: + +```ruby +# test/test_helper.rb +InertiaRails::Minitest.config.skip_missing_renderer_warnings = true +``` + +Or using a configure block: + +```ruby +InertiaRails::Minitest.configure do |config| + config.skip_missing_renderer_warnings = true +end ``` *Maintained and sponsored by the team at [bellaWatt](https://bellawatt.com/)* diff --git a/lib/inertia_rails/minitest.rb b/lib/inertia_rails/minitest.rb new file mode 100644 index 00000000..65857b67 --- /dev/null +++ b/lib/inertia_rails/minitest.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'minitest' + +module InertiaRails + module Minitest + class InertiaRenderWrapper + attr_reader :view_data, :props, :component + + def initialize + @view_data = nil + @props = nil + @component = nil + end + + def call(params) + assign_locals(params) + @render_method&.call(params) + end + + def wrap_render(render_method) + @render_method = render_method + self + end + + protected + + def assign_locals(params) + if params[:locals].present? + @view_data = params[:locals].except(:page) + @props = params[:locals][:page][:props] + @component = params[:locals][:page][:component] + else + # Sequential Inertia request + @view_data = {} + json = JSON.parse(params[:json]) + @props = json['props'] + @component = json['component'] + end + end + end + + class Configuration + attr_accessor :skip_missing_renderer_warnings + + def initialize + @skip_missing_renderer_warnings = false + end + end + + class << self + def config + @config ||= Configuration.new + end + + def configure + yield(config) if block_given? + end + end + + module Helpers + def inertia + if @_inertia_render_wrapper.nil? && !InertiaRails::Minitest.config.skip_missing_renderer_warnings + warn 'WARNING: the test never created an Inertia renderer. ' \ + "Maybe the code wasn't able to reach a `render inertia:` call? If this was intended, " \ + "or you don't want to see this message, " \ + 'set InertiaRails::Minitest.config.skip_missing_renderer_warnings = true' + end + @_inertia_render_wrapper + end + + def inertia_wrap_render(render) + @_inertia_render_wrapper = InertiaRenderWrapper.new.wrap_render(render) + end + + # Assertion: Asserts that the rendered component matches the expected component name + def assert_inertia_component(expected_component, message = nil) + message ||= "Expected rendered inertia component to be #{expected_component.inspect}, " \ + "instead received #{inertia&.component.inspect || 'nothing'}" + assert_equal expected_component, inertia&.component, message + end + + # Assertion: Asserts that props match exactly (no extra or missing keys) + def assert_inertia_exact_props(expected_props, message = nil) + message ||= "Expected inertia props to receive #{expected_props.inspect}, " \ + "instead received #{inertia&.props.inspect || 'nothing'}" + assert_equal expected_props, inertia&.props, message + end + + # Assertion: Asserts that props include the specified keys/values (allows extra keys) + def assert_inertia_includes_props(expected_props, message = nil) + actual_props = inertia&.props || {} + message ||= "Expected inertia props to include #{expected_props.inspect}, " \ + "instead received #{actual_props.inspect}" + + expected_props.each do |key, value| + assert_includes actual_props.keys, key, + "Expected props to include key #{key.inspect}, but it was not present. " \ + "Available keys: #{actual_props.keys.inspect}" + assert_equal value, actual_props[key], + "Expected props[#{key.inspect}] to be #{value.inspect}, " \ + "but got #{actual_props[key].inspect}" + end + end + + # Assertion: Asserts that view data matches exactly + def assert_inertia_exact_view_data(expected_view_data, message = nil) + message ||= "Expected inertia view data to receive #{expected_view_data.inspect}, " \ + "instead received #{inertia&.view_data.inspect || 'nothing'}" + assert_equal expected_view_data, inertia&.view_data, message + end + + # Assertion: Asserts that view data includes the specified keys/values + def assert_inertia_includes_view_data(expected_view_data, message = nil) + actual_view_data = inertia&.view_data || {} + message ||= "Expected inertia view data to include #{expected_view_data.inspect}, " \ + "instead received #{actual_view_data.inspect}" + + expected_view_data.each do |key, value| + assert_includes actual_view_data.keys, key, + "Expected view data to include key #{key.inspect}, but it was not present. " \ + "Available keys: #{actual_view_data.keys.inspect}" + assert_equal value, actual_view_data[key], + "Expected view_data[#{key.inspect}] to be #{value.inspect}, " \ + "but got #{actual_view_data[key].inspect}" + end + end + + def self.included(base) + # Only set up hooks if the base class supports them (Minitest::Test subclasses) + if base.respond_to?(:setup) && base.respond_to?(:teardown) + base.class_eval do + setup do + # Intercept InertiaRails::Renderer.new to wrap the render method + @_original_renderer_new = InertiaRails::Renderer.method(:new) + + InertiaRails::Renderer.define_singleton_method(:new) do |component, controller, request, response, render, named_args| + @_original_renderer_new.call( + component, + controller, + request, + response, + controller.inertia_wrap_render(render), + **(named_args || {}) + ) + end + end + + teardown do + # Restore the original Renderer.new method + if @_original_renderer_new + InertiaRails::Renderer.define_singleton_method(:new, @_original_renderer_new) + end + end + end + end + end + end + end +end diff --git a/spec/inertia/minitest_helper_spec.rb b/spec/inertia/minitest_helper_spec.rb new file mode 100644 index 00000000..a8a65280 --- /dev/null +++ b/spec/inertia/minitest_helper_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require_relative '../../lib/inertia_rails/minitest' + +class FakeStdErr + attr_accessor :messages + + def initialize + @messages = [] + end + + def write(msg) + @messages << msg + end + + # Rails 5.0 + Ruby 2.6 require puts to be a public method + def puts(thing); end +end + +RSpec.describe InertiaRails::Minitest, type: :request do + # Basic unit tests for the Minitest module components + describe 'InertiaRenderWrapper' do + let(:wrapper) { InertiaRails::Minitest::InertiaRenderWrapper.new } + + it 'initializes with nil values' do + expect(wrapper.view_data).to be_nil + expect(wrapper.props).to be_nil + expect(wrapper.component).to be_nil + end + + it 'extracts data from HTML response params' do + params = { + locals: { + page: { + props: { name: 'Brandon', sport: 'hockey' }, + component: 'TestComponent' + }, + meta: 'test data' + } + } + + wrapper.call(params) + + expect(wrapper.props).to eq({ name: 'Brandon', sport: 'hockey' }) + expect(wrapper.component).to eq 'TestComponent' + expect(wrapper.view_data).to eq({ meta: 'test data' }) + end + + it 'extracts data from JSON response params' do + json_data = { + 'props' => { 'name' => 'Brandon', 'sport' => 'hockey' }, + 'component' => 'TestComponent' + } + params = { json: json_data.to_json } + + wrapper.call(params) + + expect(wrapper.props).to eq({ 'name' => 'Brandon', 'sport' => 'hockey' }) + expect(wrapper.component).to eq 'TestComponent' + expect(wrapper.view_data).to eq({}) + end + + it 'wraps a render method' do + render_method = double('render_method') + wrapped = wrapper.wrap_render(render_method) + + expect(wrapped).to eq wrapper + expect(wrapper.instance_variable_get(:@render_method)).to eq render_method + end + + it 'calls the wrapped render method' do + render_method = double('render_method') + wrapper.wrap_render(render_method) + + params = { + locals: { + page: { + props: { name: 'Brandon' }, + component: 'TestComponent' + } + } + } + + expect(render_method).to receive(:call).with(params) + wrapper.call(params) + end + end + + describe 'Configuration' do + it 'has default configuration' do + expect(InertiaRails::Minitest.config.skip_missing_renderer_warnings).to eq false + end + + it 'allows configuration changes' do + original_value = InertiaRails::Minitest.config.skip_missing_renderer_warnings + + InertiaRails::Minitest.config.skip_missing_renderer_warnings = true + expect(InertiaRails::Minitest.config.skip_missing_renderer_warnings).to eq true + + # Restore + InertiaRails::Minitest.config.skip_missing_renderer_warnings = original_value + end + + it 'supports configure block' do + original_value = InertiaRails::Minitest.config.skip_missing_renderer_warnings + + InertiaRails::Minitest.configure do |config| + config.skip_missing_renderer_warnings = true + end + + expect(InertiaRails::Minitest.config.skip_missing_renderer_warnings).to eq true + + # Restore + InertiaRails::Minitest.config.skip_missing_renderer_warnings = original_value + end + end + + describe 'Helpers module' do + # Create a test class to test the helpers + let(:test_class) do + Class.new do + include Minitest::Assertions + include InertiaRails::Minitest::Helpers + + # Need this for Minitest assertions to work + attr_accessor :assertions + + def initialize + @assertions = 0 + end + + # Mock setup/teardown from Minitest + def self.setup_blocks + @setup_blocks ||= [] + end + + def self.teardown_blocks + @teardown_blocks ||= [] + end + + def self.setup(&block) + setup_blocks << block + end + + def self.teardown(&block) + teardown_blocks << block + end + + def run_setup + self.class.setup_blocks.each { |block| instance_eval(&block) } + end + + def run_teardown + self.class.teardown_blocks.each { |block| instance_eval(&block) } + end + end + end + + let(:test_instance) { test_class.new } + + describe '#inertia' do + it 'returns the wrapper when set' do + wrapper = InertiaRails::Minitest::InertiaRenderWrapper.new + test_instance.instance_variable_set(:@_inertia_render_wrapper, wrapper) + + expect(test_instance.inertia).to eq wrapper + end + + it 'returns nil when wrapper is not set' do + # Suppress warnings for this test + original_value = InertiaRails::Minitest.config.skip_missing_renderer_warnings + InertiaRails::Minitest.config.skip_missing_renderer_warnings = true + + expect(test_instance.inertia).to be_nil + + InertiaRails::Minitest.config.skip_missing_renderer_warnings = original_value + end + + it 'warns when wrapper is not set and warnings are enabled' do + original_stderr = $stderr + fake_std_err = FakeStdErr.new + $stderr = fake_std_err + + test_instance.inertia + + warn_message = 'WARNING: the test never created an Inertia renderer. ' \ + "Maybe the code wasn't able to reach a `render inertia:` call? If this was intended, " \ + "or you don't want to see this message, " \ + 'set InertiaRails::Minitest.config.skip_missing_renderer_warnings = true' + expect(fake_std_err.messages[0].chomp).to eq(warn_message) + ensure + $stderr = original_stderr + end + + it 'does not warn when skip_missing_renderer_warnings is true' do + original_value = InertiaRails::Minitest.config.skip_missing_renderer_warnings + InertiaRails::Minitest.config.skip_missing_renderer_warnings = true + + original_stderr = $stderr + fake_std_err = FakeStdErr.new + $stderr = fake_std_err + + test_instance.inertia + + expect(fake_std_err.messages).to be_empty + ensure + $stderr = original_stderr + InertiaRails::Minitest.config.skip_missing_renderer_warnings = original_value + end + end + + describe '#inertia_wrap_render' do + it 'creates and stores a wrapper' do + render_method = double('render_method') + result = test_instance.inertia_wrap_render(render_method) + + expect(result).to be_a(InertiaRails::Minitest::InertiaRenderWrapper) + expect(test_instance.instance_variable_get(:@_inertia_render_wrapper)).to eq result + end + end + + describe 'assertion methods' do + let(:wrapper) { InertiaRails::Minitest::InertiaRenderWrapper.new } + + before do + test_instance.instance_variable_set(:@_inertia_render_wrapper, wrapper) + end + + describe '#assert_inertia_component' do + it 'passes when component matches' do + wrapper.call({ locals: { page: { props: {}, component: 'TestComponent' } } }) + + expect { + test_instance.assert_inertia_component('TestComponent') + }.not_to raise_error + end + + it 'fails when component does not match' do + wrapper.call({ locals: { page: { props: {}, component: 'ActualComponent' } } }) + + expect { + test_instance.assert_inertia_component('ExpectedComponent') + }.to raise_error(Minitest::Assertion, /Expected rendered inertia component/) + end + end + + describe '#assert_inertia_exact_props' do + it 'passes when props match exactly' do + wrapper.call({ locals: { page: { props: { name: 'Brandon', sport: 'hockey' }, component: 'Test' } } }) + + expect { + test_instance.assert_inertia_exact_props({ name: 'Brandon', sport: 'hockey' }) + }.not_to raise_error + end + + it 'fails when props do not match' do + wrapper.call({ locals: { page: { props: { name: 'Brandon' }, component: 'Test' } } }) + + expect { + test_instance.assert_inertia_exact_props({ name: 'Other' }) + }.to raise_error(Minitest::Assertion) + end + end + + describe '#assert_inertia_includes_props' do + it 'passes when props include expected keys' do + wrapper.call({ locals: { page: { props: { name: 'Brandon', sport: 'hockey', age: 30 }, component: 'Test' } } }) + + expect { + test_instance.assert_inertia_includes_props({ sport: 'hockey' }) + }.not_to raise_error + end + + it 'fails when props do not include expected keys' do + wrapper.call({ locals: { page: { props: { name: 'Brandon' }, component: 'Test' } } }) + + expect { + test_instance.assert_inertia_includes_props({ sport: 'hockey' }) + }.to raise_error(Minitest::Assertion, /Expected props to include key/) + end + end + + describe '#assert_inertia_exact_view_data' do + it 'passes when view data matches exactly' do + wrapper.call({ locals: { page: { props: {}, component: 'Test' }, meta: 'test', title: 'Title' } }) + + expect { + test_instance.assert_inertia_exact_view_data({ meta: 'test', title: 'Title' }) + }.not_to raise_error + end + + it 'fails when view data does not match' do + wrapper.call({ locals: { page: { props: {}, component: 'Test' }, meta: 'test' } }) + + expect { + test_instance.assert_inertia_exact_view_data({ meta: 'other' }) + }.to raise_error(Minitest::Assertion) + end + end + + describe '#assert_inertia_includes_view_data' do + it 'passes when view data includes expected keys' do + wrapper.call({ locals: { page: { props: {}, component: 'Test' }, meta: 'test', title: 'Title', extra: 'data' } }) + + expect { + test_instance.assert_inertia_includes_view_data({ meta: 'test', title: 'Title' }) + }.not_to raise_error + end + + it 'fails when view data does not include expected keys' do + wrapper.call({ locals: { page: { props: {}, component: 'Test' }, meta: 'test' } }) + + expect { + test_instance.assert_inertia_includes_view_data({ missing: 'key' }) + }.to raise_error(Minitest::Assertion, /Expected view data to include key/) + end + end + end + end +end From bc6f0c27fa3952c129587f9f6e0bcc2335b8ad9c Mon Sep 17 00:00:00 2001 From: Pedro Fernandes Steimbruch Date: Fri, 5 Dec 2025 11:13:01 -0300 Subject: [PATCH 2/5] Fix ArgumentError: use keyword arguments splat for Renderer.new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The intercepted Renderer.new method was treating named_args as a positional parameter instead of keyword arguments, causing an 'ArgumentError: wrong number of arguments (given 5, expected 6)' when rendering Inertia responses in tests. Changed from: |component, controller, request, response, render, named_args| To: |component, controller, request, response, render, **named_args| This properly handles the keyword arguments passed by Rails when calling InertiaRails::Renderer.new. Fixes issue reported when testing real-world applications with custom InertiaConfiguration concerns. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/inertia_rails/minitest.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/inertia_rails/minitest.rb b/lib/inertia_rails/minitest.rb index 65857b67..9aa9af20 100644 --- a/lib/inertia_rails/minitest.rb +++ b/lib/inertia_rails/minitest.rb @@ -134,14 +134,14 @@ def self.included(base) # Intercept InertiaRails::Renderer.new to wrap the render method @_original_renderer_new = InertiaRails::Renderer.method(:new) - InertiaRails::Renderer.define_singleton_method(:new) do |component, controller, request, response, render, named_args| + InertiaRails::Renderer.define_singleton_method(:new) do |component, controller, request, response, render, **named_args| @_original_renderer_new.call( component, controller, request, response, controller.inertia_wrap_render(render), - **(named_args || {}) + **named_args ) end end From 1c68dc917049237d77d2cad66d1f0389d24c1e29 Mon Sep 17 00:00:00 2001 From: Pedro Fernandes Steimbruch Date: Fri, 5 Dec 2025 11:16:01 -0300 Subject: [PATCH 3/5] Fix NoMethodError: call inertia_wrap_render on test instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup hook was trying to call inertia_wrap_render on the controller instance, but that method only exists in the test helpers. This caused: NoMethodError: undefined method 'inertia_wrap_render' for an instance of Dashboard::WorkspacesController Solution: Capture the test instance (self) in the setup block and call inertia_wrap_render on it instead of on the controller. This matches how RSpec's implementation works - the wrapping happens in the test context, not the controller context. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/inertia_rails/minitest.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lib/inertia_rails/minitest.rb b/lib/inertia_rails/minitest.rb index 9aa9af20..70426859 100644 --- a/lib/inertia_rails/minitest.rb +++ b/lib/inertia_rails/minitest.rb @@ -134,13 +134,16 @@ def self.included(base) # Intercept InertiaRails::Renderer.new to wrap the render method @_original_renderer_new = InertiaRails::Renderer.method(:new) + # Capture self (the test instance) so we can call inertia_wrap_render on it + test_instance = self + InertiaRails::Renderer.define_singleton_method(:new) do |component, controller, request, response, render, **named_args| @_original_renderer_new.call( component, controller, request, response, - controller.inertia_wrap_render(render), + test_instance.inertia_wrap_render(render), **named_args ) end From 996f25666ff8f4e7381d04131cb1f8433a043de5 Mon Sep 17 00:00:00 2001 From: Pedro Fernandes Steimbruch Date: Fri, 5 Dec 2025 11:18:24 -0300 Subject: [PATCH 4/5] Fix closure scope issue: capture variables as locals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The define_singleton_method block was trying to access @_original_renderer_new as an instance variable, but inside the closure it was looking for it on the wrong object (InertiaRails::Renderer instead of the test instance), causing: NoMethodError: undefined method 'call' for nil Solution: Capture both @_original_renderer_new and self as local variables (original_renderer_new and test_instance) before creating the closure. This ensures they're part of the closure's binding and accessible when the method is called. The instance variable @_original_renderer_new is still needed for teardown to restore the original method. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/inertia_rails/minitest.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/inertia_rails/minitest.rb b/lib/inertia_rails/minitest.rb index 70426859..e8f91602 100644 --- a/lib/inertia_rails/minitest.rb +++ b/lib/inertia_rails/minitest.rb @@ -132,13 +132,15 @@ def self.included(base) base.class_eval do setup do # Intercept InertiaRails::Renderer.new to wrap the render method + # Store in instance variable for teardown @_original_renderer_new = InertiaRails::Renderer.method(:new) - # Capture self (the test instance) so we can call inertia_wrap_render on it + # Capture as local variables for the closure + original_renderer_new = @_original_renderer_new test_instance = self InertiaRails::Renderer.define_singleton_method(:new) do |component, controller, request, response, render, **named_args| - @_original_renderer_new.call( + original_renderer_new.call( component, controller, request, From c53a7eb50ec7b679c3ca35c87ea67874d10ffd5c Mon Sep 17 00:00:00 2001 From: Pedro Fernandes Steimbruch Date: Fri, 5 Dec 2025 11:23:11 -0300 Subject: [PATCH 5/5] Add HashWithIndifferentAccess for consistent key access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Props and view_data now use HashWithIndifferentAccess, eliminating the inconsistency where first-load (HTML) requests used symbol keys while sequential (JSON) requests used string keys. Benefits: - Users can use either symbol or string keys in tests - No need to remember which request type uses which key format - Assertions accept either format: assert_inertia_exact_props(name: 'X') or assert_inertia_exact_props('name' => 'X') - More intuitive and user-friendly testing experience Implementation: - Added require for 'active_support/core_ext/hash/indifferent_access' - Wrapped props and view_data with .with_indifferent_access in InertiaRenderWrapper#assign_locals - Updated assertion methods to convert expected values to indifferent access for consistent comparison - Added tests to verify both symbol and string key access works Updated README to document this improvement and removed the confusing 'Note on First Load vs Sequential Requests' section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- README.md | 22 ++++++++----- lib/inertia_rails/minitest.rb | 44 +++++++++++++++++--------- spec/inertia/minitest_helper_spec.rb | 46 ++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index da509562..f168f214 100644 --- a/README.md +++ b/README.md @@ -385,20 +385,26 @@ class EventsControllerTest < ActionDispatch::IntegrationTest end ``` -#### Note on First Load vs Sequential Requests +#### Indifferent Key Access -When testing, be aware that first-load (HTML) responses use **symbol keys** for props, while sequential (JSON) requests use **string keys**: +Props and view data use `HashWithIndifferentAccess`, so you can use **either symbol or string keys** in your tests regardless of request type: ```ruby -# First load - symbol keys -get events_path -assert_inertia_exact_props(name: 'Brandon') +test "works with both symbol and string keys" do + get events_path -# Sequential request - string keys -get events_path, headers: { 'X-Inertia': true } -assert_inertia_exact_props('name' => 'Brandon') + # Both work! Use whichever you prefer + assert_equal 'Brandon', inertia.props[:name] + assert_equal 'Brandon', inertia.props['name'] + + # Assertions also accept either format + assert_inertia_exact_props(name: 'Brandon') # symbols + assert_inertia_exact_props('name' => 'Brandon') # strings +end ``` +This eliminates the need to remember whether first-load (HTML) or sequential (JSON) requests use different key types. + #### Configuration You can configure Minitest test helpers globally: diff --git a/lib/inertia_rails/minitest.rb b/lib/inertia_rails/minitest.rb index e8f91602..e37f4f7e 100644 --- a/lib/inertia_rails/minitest.rb +++ b/lib/inertia_rails/minitest.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'minitest' +require 'active_support/core_ext/hash/indifferent_access' module InertiaRails module Minitest @@ -27,14 +28,15 @@ def wrap_render(render_method) def assign_locals(params) if params[:locals].present? - @view_data = params[:locals].except(:page) - @props = params[:locals][:page][:props] + # First load (HTML response) - convert to indifferent access + @view_data = params[:locals].except(:page).with_indifferent_access + @props = params[:locals][:page][:props].with_indifferent_access @component = params[:locals][:page][:component] else - # Sequential Inertia request - @view_data = {} + # Sequential Inertia request (JSON response) - convert to indifferent access + @view_data = {}.with_indifferent_access json = JSON.parse(params[:json]) - @props = json['props'] + @props = json['props'].with_indifferent_access @component = json['component'] end end @@ -82,9 +84,13 @@ def assert_inertia_component(expected_component, message = nil) # Assertion: Asserts that props match exactly (no extra or missing keys) def assert_inertia_exact_props(expected_props, message = nil) + # Convert expected to HashWithIndifferentAccess for consistent comparison + expected = expected_props.with_indifferent_access + actual = inertia&.props + message ||= "Expected inertia props to receive #{expected_props.inspect}, " \ - "instead received #{inertia&.props.inspect || 'nothing'}" - assert_equal expected_props, inertia&.props, message + "instead received #{actual.inspect || 'nothing'}" + assert_equal expected, actual, message end # Assertion: Asserts that props include the specified keys/values (allows extra keys) @@ -94,9 +100,11 @@ def assert_inertia_includes_props(expected_props, message = nil) "instead received #{actual_props.inspect}" expected_props.each do |key, value| - assert_includes actual_props.keys, key, - "Expected props to include key #{key.inspect}, but it was not present. " \ - "Available keys: #{actual_props.keys.inspect}" + # Use string version of key for comparison (HashWithIndifferentAccess uses strings internally) + key_str = key.to_s + assert actual_props.key?(key_str), + "Expected props to include key #{key.inspect}, but it was not present. " \ + "Available keys: #{actual_props.keys.inspect}" assert_equal value, actual_props[key], "Expected props[#{key.inspect}] to be #{value.inspect}, " \ "but got #{actual_props[key].inspect}" @@ -105,9 +113,13 @@ def assert_inertia_includes_props(expected_props, message = nil) # Assertion: Asserts that view data matches exactly def assert_inertia_exact_view_data(expected_view_data, message = nil) + # Convert expected to HashWithIndifferentAccess for consistent comparison + expected = expected_view_data.with_indifferent_access + actual = inertia&.view_data + message ||= "Expected inertia view data to receive #{expected_view_data.inspect}, " \ - "instead received #{inertia&.view_data.inspect || 'nothing'}" - assert_equal expected_view_data, inertia&.view_data, message + "instead received #{actual.inspect || 'nothing'}" + assert_equal expected, actual, message end # Assertion: Asserts that view data includes the specified keys/values @@ -117,9 +129,11 @@ def assert_inertia_includes_view_data(expected_view_data, message = nil) "instead received #{actual_view_data.inspect}" expected_view_data.each do |key, value| - assert_includes actual_view_data.keys, key, - "Expected view data to include key #{key.inspect}, but it was not present. " \ - "Available keys: #{actual_view_data.keys.inspect}" + # Use string version of key for comparison (HashWithIndifferentAccess uses strings internally) + key_str = key.to_s + assert actual_view_data.key?(key_str), + "Expected view data to include key #{key.inspect}, but it was not present. " \ + "Available keys: #{actual_view_data.keys.inspect}" assert_equal value, actual_view_data[key], "Expected view_data[#{key.inspect}] to be #{value.inspect}, " \ "but got #{actual_view_data[key].inspect}" diff --git a/spec/inertia/minitest_helper_spec.rb b/spec/inertia/minitest_helper_spec.rb index a8a65280..5c4efaec 100644 --- a/spec/inertia/minitest_helper_spec.rb +++ b/spec/inertia/minitest_helper_spec.rb @@ -41,9 +41,31 @@ def puts(thing); end wrapper.call(params) - expect(wrapper.props).to eq({ name: 'Brandon', sport: 'hockey' }) + # HashWithIndifferentAccess allows both access styles + expect(wrapper.props[:name]).to eq 'Brandon' + expect(wrapper.props['name']).to eq 'Brandon' expect(wrapper.component).to eq 'TestComponent' - expect(wrapper.view_data).to eq({ meta: 'test data' }) + expect(wrapper.view_data[:meta]).to eq 'test data' + expect(wrapper.view_data['meta']).to eq 'test data' + end + + it 'allows both symbol and string key access for HTML response' do + params = { + locals: { + page: { + props: { name: 'Brandon', sport: 'hockey' }, + component: 'TestComponent' + } + } + } + + wrapper.call(params) + + # Both should work thanks to HashWithIndifferentAccess + expect(wrapper.props[:name]).to eq 'Brandon' + expect(wrapper.props['name']).to eq 'Brandon' + expect(wrapper.props[:sport]).to eq 'hockey' + expect(wrapper.props['sport']).to eq 'hockey' end it 'extracts data from JSON response params' do @@ -55,11 +77,29 @@ def puts(thing); end wrapper.call(params) - expect(wrapper.props).to eq({ 'name' => 'Brandon', 'sport' => 'hockey' }) + # HashWithIndifferentAccess allows both access styles + expect(wrapper.props[:name]).to eq 'Brandon' + expect(wrapper.props['name']).to eq 'Brandon' expect(wrapper.component).to eq 'TestComponent' expect(wrapper.view_data).to eq({}) end + it 'allows both symbol and string key access for JSON response' do + json_data = { + 'props' => { 'name' => 'Brandon', 'sport' => 'hockey' }, + 'component' => 'TestComponent' + } + params = { json: json_data.to_json } + + wrapper.call(params) + + # Both should work thanks to HashWithIndifferentAccess + expect(wrapper.props[:name]).to eq 'Brandon' + expect(wrapper.props['name']).to eq 'Brandon' + expect(wrapper.props[:sport]).to eq 'hockey' + expect(wrapper.props['sport']).to eq 'hockey' + end + it 'wraps a render method' do render_method = double('render_method') wrapped = wrapper.wrap_render(render_method)