diff --git a/README.md b/README.md index 698d9702..f168f214 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,113 @@ 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 +``` + +#### Indifferent Key Access + +Props and view data use `HashWithIndifferentAccess`, so you can use **either symbol or string keys** in your tests regardless of request type: + +```ruby +test "works with both symbol and string keys" do + get events_path + + # 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: + +```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..e37f4f7e --- /dev/null +++ b/lib/inertia_rails/minitest.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'minitest' +require 'active_support/core_ext/hash/indifferent_access' + +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? + # 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 (JSON response) - convert to indifferent access + @view_data = {}.with_indifferent_access + json = JSON.parse(params[:json]) + @props = json['props'].with_indifferent_access + @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) + # 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 #{actual.inspect || 'nothing'}" + assert_equal expected, actual, 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| + # 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}" + end + end + + # 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 #{actual.inspect || 'nothing'}" + assert_equal expected, actual, 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| + # 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}" + 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 + # Store in instance variable for teardown + @_original_renderer_new = InertiaRails::Renderer.method(:new) + + # 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( + component, + controller, + request, + response, + test_instance.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..5c4efaec --- /dev/null +++ b/spec/inertia/minitest_helper_spec.rb @@ -0,0 +1,360 @@ +# 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) + + # 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[: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 + json_data = { + 'props' => { 'name' => 'Brandon', 'sport' => 'hockey' }, + 'component' => 'TestComponent' + } + params = { json: json_data.to_json } + + wrapper.call(params) + + # 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) + + 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