An object oriented view layer for Hanami. Write views, layouts, and components as Phlex classes instead of templates.
phlex-hanami docs usage testing.md
5.1 kB · 149 lines
Markdown
at main

Testing #

A view is a Ruby object, so a view test builds one and calls it. The only thing it needs that an ordinary object does not is a view context, because path, assets, flash and the rest are read off the context Hanami builds for each request. phlex-hanami ships helpers that build one for you.

The helpers are three methods in Phlex::Hanami::Testing::ViewHelpers, and none of them know what test framework you use. RSpec gets a file that wires them up for you. Anything else includes the module.

Setup with RSpec #

Require it from spec/spec_helper.rb, or from a file under spec/support, which the spec helper hanami-rspec generates already loads:

# spec/support/phlex.rb
require "phlex/hanami/rspec"

That is the whole integration. hanami-rspec is a generator gem and adds nothing at runtime, so there is nothing to order this against.

The helpers go into every example group tagged type: :view. Tag component specs the same way, since a component is a view as far as this is concerned. To pick them up somewhere else, include them by hand:

RSpec.configure do |config|
  config.include Phlex::Hanami::Testing::ViewHelpers
end

Setup with Minitest #

hanami new --test=minitest generates a test/support/minitest.rb that reopens the base test class for helpers. Include the module there, and every test gets them:

# test/support/minitest.rb
require "phlex/hanami/testing"

class Hanami::Minitest::Test
  include Phlex::Hanami::Testing::ViewHelpers
end

render is a common enough name that you may not want it on every test in the suite. Include it into a base class for view tests instead:

# test/support/view_test.rb
require "phlex/hanami/testing"

class ViewTest < Hanami::Minitest::Test
  include Phlex::Hanami::Testing::ViewHelpers
end

The examples below are written for RSpec. Every helper works the same under Minitest; only the assertions change.

A view spec #

# spec/my_app/views/posts/index_spec.rb
RSpec.describe MyApp::Views::Posts::Index, type: :view do
  subject(:html) { render(described_class.new(posts: posts)) }

  let(:posts) { [Post.new(title: "First")] }

  it "lists the posts" do
    expect(html).to include("<li>First</li>")
  end

  it "links to each post" do
    expect(html).to include(%(<a href="/posts/1">))
  end
end

No request runs. render builds a context from the view's own slice, so path and assets give the same answers they give in production, and a view in the admin slice gets the admin slice's routes.

render renders the instance you hand it, so no layout is applied and you are asserting on the view's own markup. To render a view inside its layout the way Hanami does, call the class:

described_class.call(context: view_context, posts: posts)

There are no matchers. The output is a String, and include, match and whatever HTML matcher you already like all work on it.

A component spec #

The same, with a block if the component takes one:

RSpec.describe MyApp::Components::Card, type: :view do
  it "wraps its content" do
    html = render(described_class.new(title: "Hello")) { "body" }

    expect(html).to include("<h2>Hello</h2>")
    expect(html).to include("body")
  end
end

A plain Phlex::HTML class that never heard of Hanami renders through render too. It has no slice, so it gets a bare context, which answers content_for and raises a clear error for anything else.

Views that need a request #

session, flash and csrf_token come from the request. view_request builds one without running an action:

RSpec.describe MyApp::Views::Layout, type: :view do
  it "shows the flash" do
    request = view_request("/posts", flash: { alert: "Not saved" })

    expect(render(described_class.new, request: request)).to include("Not saved")
  end
end

view_request takes a path, and session:, flash: and csrf_token: for the three things a view reads off it. Anything else goes to Rack::MockRequest.env_for, so headers and a request method are available when a view cares about them. Sessions are always on, so a view reading an empty session gets nil rather than an error.

Building the context yourself #

view_context returns the context render would have built, for the times you want to set something on it first:

it "reads content set elsewhere" do
  context = view_context
  context.content_for(:page_title, "Posts")

  expect(render(described_class.new, context: context)).to include("<title>Posts</title>")
end

It takes slice: to build from a slice other than the app, and request: to attach a request. The class it builds is the one an action would build: a Hanami::View::Context subclass when hanami-view is bundled, otherwise Phlex::Hanami::Context. A view reads the same either way.

Hanami turns off container memoization in the test env so that components can be stubbed. Each context resolves its routes, assets and i18n backend from the container as it is built, rather than holding a set from an earlier example.