Skip to content
Open
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
104 changes: 92 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/)*
Expand Down
179 changes: 179 additions & 0 deletions lib/inertia_rails/minitest.rb
Original file line number Diff line number Diff line change
@@ -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
Loading