view_component testing
I’m getting acquainted with view_component, which is a Ruby library for building view components (surprise) in Ruby on Rails. It seems to be what the cool kids are into these days, as an alternative to standard, out-of-the-box RoR views and view partials. Server based MVC is still the name of the game, here, but with better encapsulation. A view component is a plain ruby object that is initialized with whatever arguments, and defines what is exposed to a view template (erb, haml, slim). It’s nice because it brings OOP to the view layer in RoR.
There’s something to learn about testing these things, for which the documentation is a great starting place. But pretty quickly I found myself having to figure some things out, which, I acknowledge might be because I’m not component-ing the “right” way.
# Nested Components
For example, I have a component that depends on another component. I imagine this a common scenario, as it is with building frontends in VueJS.
# /app/views/components/message_component.rb class MessageComponent < ApplicationViewComponent attr_reader :message def initialize(message:) @message = message end def id dom_id(message) end end
With this template:
-# /app/views/message_component/message_component.html.haml %div{ id: id } = render InlineEditComponent.new(model: [current_user, message], attribute: :content) do |component| = component.with_field_slot do = message.content = link_to t('message.edit'), edit_user_message_path(current_user, message)
I want to test the MessageComponent
in isolation. I want to mock the
InlineEditComponent
. What makes it even more interesting if the fact that the
InlineEditComponent
has a slot which is populated by the MessageComponent
. It
took some trial and error, but I’ve so far settled on this:
RSpec.describe MessageComponent, type: :component do subject { page } let(:message) { build_stubbed(:message, :with_user, content: message_content) } let(:user) { message.user } let(:message_content) { Faker::Lorem.sentence } let(:component) { described_class.new(message:) } let(:inline_edit_component) do Class.new(ApplicationViewComponent) do renders_one :field_slot haml_template <<~HAML InlineEditComponent = field_slot HAML end end end before do stub_const('InlineEditComponent', inline_edit_component) allow(InlineEditComponent).to receive(:new).and_call_original with_current_user(user) { render_inline(component) } end it { is_expected.to have_link 'Edit Message' } it 'instantiates the InlineEditComponent with the proper args' do expect(InlineEditComponent).to have_received(:new).with(model: [user, message], attribute: :content) end it 'renders the InlineEditComponent' do expect(page).to have_text('InlineEditComponent') end it { is_expected.to have_text message_content } end
It defines a mock class of the InlineEditComponent
, which provides the bare
minimum to function as a container for a slot and a template. With this, I can
test that the rendered output does include the InlineEditComponent
along with
the field_slot
through which the message.content
is rendered. I’m
uneasy about mocking the slot this way, though. For one thing, this test will
still pass even if I change the name of the slot on the InlineEditComponent
. It
would be nice to get a kind of verifying double behavior on a slot. Anyway,
that’s what feature/system tests are for, right? This is good enough for now.
A couple other things worth pointing out:
- I could not use an anonymous class for the mock (eg,
Class.new(ApplicationViewComponent)
). It would break due to the view_component expecting the component class should have a name. - The
with_current_user
thing in thebefore
block comes from dry-effects. In short, the implementation here provide Devise’scurrent_user
to the component keeping one from have the passcurrent_user
to every componentsinitialize
method. There’s a detailed description of this technique is the useful Evil Martian’s 2-parter on view_components.