Skip to content

Lesson 7 — Testing with Minitest

Why testing Phlex components is different

Testing views in a traditional Rails app is painful. To test an ERB partial you need a full Rails environment, a controller context, instance variables set correctly, and a view context that wires it all together. The setup is fragile, the tests are slow, and most teams simply don’t bother. View logic goes untested by default. Phlex changes this completely — and it’s one of the most underappreciated benefits of the component approach. A Phlex component is a plain Ruby object. It has an initialize method that declares exactly what it needs, and a call method that returns an HTML string. That’s it. There’s no implicit state, no controller context, no view environment required. To test it you do exactly what you’d do with any Ruby object:

  • Instantiate it with known inputs
  • Call it to get the output
  • Assert the output is what you expect
1
2
3
# The entire test setup for any Phlex component:
html = Components::Button.new(label: "Save", variant: :danger)
assert_includes html, "bg-red-600"

No fixtures, no factories, no request stubs, no database. The component takes data in and produces HTML out — a pure function in all but name.

What this means in practice:

  • Tests run in milliseconds — no Rails boot time
  • Tests can run without a database connection
  • Components can be tested as you build them, before they ever touch a view
  • Refactoring a component’s internals doesn’t break call sites — the test catches regressions immediately
  • Edge cases (disabled state, empty content, missing optional props) are trivial to cover

This is the payoff for the explicit interface that prop declarations enforce. Because every component declares exactly what it accepts, you always know what inputs to test. There are no hidden instance variables, no implicit controller state, no partials loaded from unexpected paths.

The test suite for an entire component library can run in under a second. By the time you add components to a Rails page, you already know they work.

We’ll start with a test_helper file to simplify our test suites.

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

Dir[File.join(__dir__, "../app/components/**/*.rb")].each { |f| require f }

module ComponentTestHelper
  def render(component)
    component.call
  end

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

Testing Components::Button

 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
# test/components/button_test.rb
require_relative "../test_helper"

class ButtonTest < Minitest::Test
  include ComponentTestHelper

  def test_renders_label
    html = render Components::Button.new(label: "Save")
    assert_includes html, ">Save<"
  end

  def test_default_variant_has_no_class
    doc = render_fragment Components::Button.new(label: "Save")
    assert_nil doc.at_css("button")["class"],
               "Primary button should have no class attribute"
  end

  def test_secondary_variant_has_class
    doc = render_fragment Components::Button.new(label: "Save",
                                                 variant: :secondary)
    assert_equal "secondary", doc.at_css("button")["class"]
  end

  def test_disabled_has_attribute
    doc = render_fragment Components::Button.new(label: "Save", disabled: true)
    assert doc.at_css("button[disabled]"), "Expected disabled attribute"
  end

  def test_disabled_has_aria_disabled
    doc = render_fragment Components::Button.new(label: "Save", disabled: true)
    assert_equal "true", doc.at_css("button")["aria-disabled"]
  end

  def test_wrong_type_raises
    assert_raises(Literal::TypeError) { Components::Button.new(label: 42) }
  end
end

Testing Components::Card

 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
# test/components/card_test.rb
require_relative "../test_helper"

class CardTest < Minitest::Test
  include ComponentTestHelper

  def test_renders_title
    html = render Components::Card.new(title: "Hello")
    assert_includes html, "Hello"
  end

  def test_no_footer_when_not_provided
    doc = render_fragment Components::Card.new(title: "Hello")
    assert_nil doc.at_css("footer"), "Expected no footer element"
  end

  def test_footer_slot_renders
    html = Components::Card.new.call do |card|
      card.footer { "Footer content" }
    end
    assert_includes html, "Footer content"
    assert_includes html, "<footer>"
  end

  def test_body_slot_renders
    html = Components::Card.new.call do |card|
      card.body { p { "Body content" } }
    end
    assert_includes html, "Body content"
  end
end

Run tests:

1
2
ruby test/components/button_test.rb
ruby test/components/card_test.rb

What to test — and what not to

Good to test:

  • Business logic — initials in Avatar, change_variant in Stat
  • Conditional rendering — is the dismiss button present only when dismissible: true?
  • Accessibility attributes — role, aria-disabled, alt
  • Type validation — does passing a wrong type raise Literal::TypeError?

Less valuable to test:

  • Exact HTML structure — breaks on trivial refactors
  • Specific class strings — couples tests to styling decisions

Exercise

Here’s a new component we’d like to test:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# app/components/alert.rb
require_relative "base"

module Components
  class Alert < Base
    ICONS = {
      info:    "ℹ",
      success: "✓",
      warning: "⚠",
      danger:  "✕",
    }.freeze

    prop :message,     String
    prop :variant,     Symbol,   default: :info
    prop :dismissible, _Boolean, default: -> { false }

    def view_template
      div(role: "alert", class: @variant.to_s) do
        span { "#{ICONS[@variant]} #{@message}" }
        button(type: "button") { "×" } if @dismissible
      end
    end
  end
end

Write tests for this new Components::Alert. Make sure you cover:

  1. The role="alert" attribute is present
  2. The dismiss button renders when dismissible: true
  3. The dismiss button is absent when dismissible: false (the default)
  4. Two variants apply different CSS classes
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
# test/components/alert_test.rb
require_relative "../test_helper"

class AlertTest < Minitest::Test
  include ComponentTestHelper

  def test_has_role_alert
    doc = render_fragment Components::Alert.new(message: "Hello")
    assert doc.at_css("[role=alert]"), "Expected role=alert"
  end

  def test_dismissible_shows_button
    doc = render_fragment Components::Alert.new(
      message: "Hello", dismissible: true
    )
    assert doc.at_css("button"), "Expected dismiss button"
  end

  def test_not_dismissible_hides_button
    doc = render_fragment Components::Alert.new(message: "Hello")
    assert_nil doc.at_css("button"), "Expected no dismiss button"
  end

  def test_success_variant_class
    doc = render_fragment Components::Alert.new(
      message: "OK", variant: :success
    )
    assert_includes doc.at_css("[role=alert]")["class"], "success"
  end

  def test_danger_variant_class
    doc = render_fragment Components::Alert.new(
      message: "Error", variant: :danger
    )
    assert_includes doc.at_css("[role=alert]")["class"], "danger"
  end
end

Module 3 summary

  • Components::Base gives every component a shared inheritance root, Literal property declarations, and the class_names helper
  • extend Phlex::Kit in Components enables ComponentName(...) syntax — no render or .new required; every component file begins with require_relative "base" making each self-contained
  • Leaf components (Button, Badge, Avatar, Alert, Link) render entirely from props
  • Single yield point components (Panel) use plain yield
  • Named slot components must call vanish(&) as the first line of view_template — this yields the block to populate slot instance variables before HTML is rendered
  • capture(&) stores block output as a string; raw safe(...) replays it
  • vanish also collects configuration — Table uses it to gather column definitions before rendering
  • The block argument (|t|, |card|, |s|) is always the component instance — Phlex upgrades yield to yield(self) automatically

Phlex::UI components built

  • Components::Button — variant (primary/secondary/danger/ghost), disabled
  • Components::Badge — colour variants via Pico <mark>
  • Components::Avatar — image with initials fallback, size variants
  • Components::Alert — info/success/warning/danger, dismissible
  • Components::Card — title shorthand + header/body/footer named slots
  • Components::Table — column-based API via vanish configuration pattern
  • Components::Link — default, secondary, button variants
  • Components::Heading — dynamic tag level via send
  • Components::Stat — composes Badge internally
  • Components::Panel — single yield point container
  • Components::Section — body and aside named slots