Skip to content

Lesson 6 — Testing components and views in Rails

Why testing Phlex is genuinely different

Testing views in a traditional Rails app is painful. To test an ERB partial you typically need a full controller context, request stubs, and instance variables set up correctly. The setup is fragile, slow, and most teams simply don’t bother. View logic goes untested by default.

Phlex changes this completely. A component or view is a plain Ruby object. Instantiate it with known inputs, call it, assert the output. No fixtures, no factories, no request stubs. The test suite for the entire Phlex::UI library runs in under a second.

There’s some conceptual understanding needed here when we look at the setup for view_test_helper, but the bottom line is that its both easy to test views, and the tests execute quickly.

Two test helpers

We have two helpers in test/:

component_test_helper.rb — for Phlex::UI library components. These are Rails-agnostic by design: they accept plain values as props and never call Rails infrastructure internally. Route helpers, dom_id, and current_user are the caller’s responsibility — the component just receives strings and Ruby objects.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# test/component_test_helper.rb
require "test_helper"
require "nokogiri"

module ComponentTestHelper
  def render_component(component)
    component.call
  end

  def render_fragment(component)
    Nokogiri::HTML5.fragment(render_component(component))
  end
end

view_test_helper.rb — for Views:: - classes and any app-specific component that uses Rails infrastructure. Views always use route helpers at minimum, so they always need a Rails view context.

We’ll create a view_test_helper here that can be used in this and any future Rails project you might create. (I’ve tried to add sufficient comments and explanations)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# test/view_test_helper.rb
#
# Use this helper for testing Views:: classes and any app-specific
# component that uses Rails infrastructure (route helpers, form_with,
# dom_id etc.)
#
# Usage:
#   class Views::Boards::IndexTest < ActiveSupport::TestCase
#     include ViewTestHelper
#   end
#
require "test_helper"
require "nokogiri"

module ViewTestHelper
  # Route helpers — boards_path, new_board_path, board_path(@board) etc.
  # Also makes them available directly in test assertions.
  include Rails.application.routes.url_helpers

  # Required by url_helpers when called outside a request context.
  def default_url_options
    { host: "localhost" }
  end

  # Render a view or component through Rails' own rendering pipeline,
  # giving it a full view context — csrf_meta_tags, stylesheet_link_tag,
  # turbo helpers etc. all work correctly.
  def render_view(view)
    view_context.render(view)
  end

  # Same as render_view but returns a parsed Nokogiri fragment
  # for DOM assertions: doc.at_css("h1"), doc.css("li") etc.
  def render_view_fragment(view)
    Nokogiri::HTML5.fragment(render_view(view))
  end

  # Stub current_user for views that use it.
  # Override in your test with:
  #   def current_user = User.new(name: "Alice", email: "alice@example.com")
  def current_user
    nil
  end

  private

  # Rails' purpose-built test controller — provides a minimal but
  # complete Rails context without needing a real request or routing.
  def controller
    @controller ||= ActionView::TestCase::TestController.new
  end

  # Memoised view context derived from the test controller.
  # All Rails view helpers are available through this context.
  def view_context
    @view_context ||= controller.view_context
  end
end

Four things are happening here:

  • include Rails.application.routes.url_helpers — makes all route helpers (boards_path, new_board_path, board_path(@board) etc.) available both inside rendered views and directly in test assertions.
  • default_url_options — required by Rails’ URL helpers when called outside a request context. Provides a host so URL generation works. Path helpers don’t use the host but Rails still requires the method to be defined.
  • view_context.render(view) — delegates rendering through Rails’ own view context rather than calling .call directly. This gives the view a proper Rails environment — helpers like csrf_meta_tags, stylesheet_link_tag, turbo_stream_from all work correctly.
  • ActionView::TestCase::TestController — Rails’ built-in test controller, purpose-made for this use case. It provides a minimal but complete Rails context without needing a real request or routing setup.

The simple rule

Phlex::UI components (Button, Badge, Avatar, Card, Table, Breadcrumb, EmptyState etc.) → ComponentTestHelper

Everything else — views, form components, layout components, anything that might use Rails helpers — → ViewTestHelper

When in doubt, use ViewTestHelper. Passing a view context to a component that doesn’t need one is harmless — Phlex accepts and ignores it. The cost is negligible.

A note on architecture: Where app-specific components like BoardForm live — under Views::, a separate App::Components:: namespace, or somewhere else — is an architectural decision that depends on your app’s scale and team preferences. We keep it simple here: the clean separation that matters is between the portable Phlex::UI library (always ComponentTestHelper) and everything else (use ViewTestHelper when in doubt). The Bonus Module revisits this when we extract Phlex::UI as a gem.

Why Phlex::UI components are Rails-agnostic

Every Phlex::UI component is designed so that Rails infrastructure is the caller’s responsibility, not the component’s. Consider Breadcrumb:

1
2
3
4
5
# The component just receives strings — no route helpers called inside
Breadcrumb() do |b|
  b.item "Boards", url: boards_path   # caller generates the URL
  b.item @board.name                  # last item has no URL
end

The component renders <a href="..."> with whatever string it receives. It knows nothing about Rails routing. This means:

  • Component tests need no Rails context — fast, simple, portable
  • The component works in any Ruby context — Rails, Sinatra, plain Ruby
  • When extracted to a gem, component_test_helper.rb travels with it unchanged

This design is intentional. It’s what makes Phlex::UI a genuine library rather than a collection of app-specific templates.

Component tests

We already have a collection of component tests. Here’s another to show how we’re using the component_test_helper.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# test/components/empty_state_test.rb
require "test_helper"
require "component_test_helper"

class EmptyStateTest < ActiveSupport::TestCase
  include ComponentTestHelper

  test "renders title and message" do
    html = render_component Components::EmptyState.new(
      title:   "Nothing here",
      message: "Add something to get started."
    )
    assert_includes html, "Nothing here"
    assert_includes html, "Add something to get started."
  end

  test "renders action link when both label and url provided" do
    doc = render_fragment Components::EmptyState.new(
      title:        "No boards",
      message:      "Create one.",
      action_label: "Create a board",
      action_url:   "/boards/new"
    )
    assert doc.at_css("a[href='/boards/new']"), "Expected action link"
    assert_includes doc.at_css("a").text, "Create a board"
  end

  test "no action link when action_label missing" do
    doc = render_fragment Components::EmptyState.new(
      title:      "No boards",
      message:    "Create one.",
      action_url: "/boards/new"
    )
    assert_nil doc.at_css("a"), "Expected no action link"
  end

  test "no action link when action_url missing" do
    doc = render_fragment Components::EmptyState.new(
      title:        "No boards",
      message:      "Create one.",
      action_label: "Create a board"
    )
    assert_nil doc.at_css("a"), "Expected no action link"
  end

  test "wrong title type raises" do
    assert_raises(Literal::TypeError) do
      Components::EmptyState.new(title: 42, message: "msg")
    end
  end
end

Trying to test the components now will probably give an error. Lookbook is only defined as a development gem and so the initializer will fail. You need to modify the `lookbook.rb’ initializer to fix this:

1
2
3
4
5
6
7
# config/initializers/lookbook.rb
if defined?(Lookbook)
  Lookbook.configure do |config|
    config.component_paths  << Rails.root.join("app/components")
    config.preview_layout   = "lookbook/preview"
  end
end

defined?(Lookbook) returns nil if the constant isn’t loaded — which is the case in test and production where the gem isn’t in the bundle. The block is skipped entirely and no error is raised. This is the correct pattern for any initializer that configures a development-only gem. The same guard should be applied to any other development-only gem configuration you add in future.

Now running our tests gives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
% bin/rails test test/components
Running 58 tests in parallel using 12 processes
Run options: --seed 9105

# Running:

..........................................................

Finished in 0.211427s, 274.3264 runs/s, 643.2480 assertions/s.
58 runs, 136 assertions, 0 failures, 0 errors, 0 skips

View tests

Views always use ViewTestHelper. Pass an explicit id to unsaved ActiveRecord objects so that route helpers (board_path(@board)) and dom_id(@board) generate correctly without hitting the database:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# test/views/boards/index_test.rb
require "test_helper"
require "view_test_helper"

class Views::Boards::IndexTest < ActiveSupport::TestCase
  include ViewTestHelper

  test "shows empty state when no boards" do
    html = render_view Views::Boards::Index.new(boards: [])
    assert_includes html, "No boards yet"
    assert_includes html, "Create your first board to get started"
  end

  test "renders board names when boards present" do
    boards = [
      Board.new(name: "Alpha", id: 1),
      Board.new(name: "Beta",  id: 2),
    ]
    html = render_view Views::Boards::Index.new(boards: boards)
    assert_includes html, "Alpha"
    assert_includes html, "Beta"
  end

  test "shows new board link" do
    html = render_view Views::Boards::Index.new(boards: [])
    assert_includes html, new_board_path
  end

  test "renders grid when boards present" do
    boards = [Board.new(name: "Alpha", id: 1)]
    doc = render_view_fragment Views::Boards::Index.new(boards: boards)
    assert doc.at_css(".grid"), "Expected a grid container"
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# test/views/boards/show_test.rb
require "test_helper"
require "view_test_helper"

class Views::Boards::ShowTest < ActiveSupport::TestCase
  include ViewTestHelper

  def board
    @board ||= Board.new(name: "My Board", id: 1).tap do |b|
      b.columns.build(name: "To Do",       id: 1, position: 0)
      b.columns.build(name: "In Progress", id: 2, position: 1)
      b.columns.build(name: "Done",        id: 3, position: 2)
    end
  end

  test "renders board name as heading" do
    html = render_view Views::Boards::Show.new(board: board)
    assert_includes html, "My Board"
  end

  test "renders breadcrumb with boards link" do
    html = render_view Views::Boards::Show.new(board: board)
    assert_includes html, boards_path
  end

  test "renders all columns" do
    html = render_view Views::Boards::Show.new(board: board)
    assert_includes html, "To Do"
    assert_includes html, "In Progress"
    assert_includes html, "Done"
  end
end

And running the tests gives:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
% bin/rails test test/views/
Running 7 tests in a single process (parallelization threshold is 50)
Run options: --seed 24997

# Running:

.......

Finished in 0.074490s, 93.9723 runs/s, 281.9170 assertions/s.
7 runs, 21 assertions, 0 failures, 0 errors, 0 skips

What to test at each level

Level Helper What to test
Components:: (library) ComponentTestHelper Props, variants, slots, type validation, conditional rendering, accessibility attributes
Views:: and app components ViewTestHelper Data rendering, empty states, conditional sections, route links

What not to test

  • Exact Tailwind class strings — couples tests to styling and breaks when you restyle
  • Exact HTML structure — breaks on trivial template refactors
  • Things Rails already tests — don’t test that form_with generates a form tag

Focus on behaviour: does the empty state appear when it should? Do the right links appear? Does the wrong type raise? Does the breadcrumb correctly render the last item as text rather than a link?

The future-proof split

When we extract Phlex::UI to a gem in the Bonus Module, component_test_helper.rb travels with it untouched — it has no app-specific dependencies. view_test_helper.rb stays in the app. This clean separation was worth establishing from the start.

Exercise

Write tests for Components::Breadcrumb. Use the correct helper.

  1. Renders a nav element with aria-label="Breadcrumb"
  2. The last item renders as plain text, not a link
  3. All items except the last render as links
  4. Three items render three li elements

Solution
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# test/components/breadcrumb_test.rb
require "test_helper"
require "component_test_helper"

class BreadcrumbTest < ActiveSupport::TestCase
  include ComponentTestHelper

  test "renders nav with aria label" do
    doc = render_fragment Components::Breadcrumb.new { |b|
      b.item "Home",   url: "/"
      b.item "Boards", url: "/boards"
      b.item "My Board"
    }
    assert doc.at_css("nav[aria-label='Breadcrumb']"),
           "Expected nav with aria-label='Breadcrumb'"
  end

  test "last item renders as text not link" do
    doc = render_fragment Components::Breadcrumb.new { |b|
      b.item "Boards", url: "/boards"
      b.item "My Board"
    }
    assert_nil doc.css("li").last.at_css("a"),
               "Expected last item to have no link"
    assert_includes doc.css("li").last.text, "My Board"
  end

  test "all items except last render as links" do
    doc = render_fragment Components::Breadcrumb.new { |b|
      b.item "Home",   url: "/"
      b.item "Boards", url: "/boards"
      b.item "My Board"
    }
    items = doc.css("li")
    assert items.first.at_css("a"), "Expected first item to be a link"
    assert items[1].at_css("a"),    "Expected second item to be a link"
    assert_nil items.last.at_css("a"),
               "Expected last item not to be a link"
  end

  test "three items render three li elements" do
    doc = render_fragment Components::Breadcrumb.new { |b|
      b.item "Home",   url: "/"
      b.item "Boards", url: "/boards"
      b.item "My Board"
    }
    assert_equal 3, doc.css("li").count
  end
end

Breadcrumb accepts url: as a plain string — it never calls route helpers internally. So ComponentTestHelper is the correct choice, even though the caller would normally pass a route helper result.