Skip to content

Commit

Permalink
Introduce ActionView::TestCase.register_parser
Browse files Browse the repository at this point in the history
Register a callable to decode rendered content for a given MIME type

Each registered decoder will also define a `#rendered.$MIME` helper
method, where `$MIME` corresponds to the value of the `mime` argument.

=== Arguments

`mime` - Symbol the MIME Type name for the rendered content
`callable` - Callable to decode the String. Accepts the String
                    value as its only argument
`block` - Block serves as the decoder when the
                 `callable` is omitted

By default, ActionView::TestCase defines a decoder for:

* :html - returns an instance of Nokogiri::XML::Node
* :json - returns an instance of ActiveSupport::HashWithIndifferentAccess

Each pre-registered decoder also defines a corresponding helper:

* :html - defines `rendered.html`
* :json - defines `rendered.json`

=== Examples

To parse the rendered content into RSS, register a call to `RSS::Parser.parse`:

```ruby
register_decoder :rss, -> rendered { RSS::Parser.parse(rendered) }

test "renders RSS" do
  article = Article.create!(title: "Hello, world")

  render formats: :rss, partial: article

  assert_equal "Hello, world", rendered.rss.items.last.title
end
```

To parse the rendered content into a Capybara::Simple::Node,
re-register an `:html` decoder with a call to
`Capybara.string`:

```ruby
register_decoder :html, -> rendered { Capybara.string(rendered) }

test "renders HTML" do
  article = Article.create!(title: "Hello, world")

  render partial: article

  rendered.html.assert_css "h1", text: "Hello, world"
end
```
  • Loading branch information
seanpdoyle authored and rafaelfranca committed Sep 27, 2023
1 parent 162be9f commit 7badc42
Show file tree
Hide file tree
Showing 8 changed files with 364 additions and 17 deletions.
18 changes: 18 additions & 0 deletions actionview/CHANGELOG.md
@@ -1,3 +1,21 @@
* Introduce `ActionView::TestCase.register_parser`

```ruby
register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }

test "renders RSS" do
article = Article.create!(title: "Hello, world")

render formats: :rss, partial: article

assert_equal "Hello, world", rendered.rss.items.last.title
end
```

By default, register parsers for `:html` and `:json`.

*Sean Doyle*

## Rails 7.1.0.beta1 (September 13, 2023) ##

* Fix `simple_format` with blank `wrapper_tag` option returns plain html tag
Expand Down
179 changes: 163 additions & 16 deletions actionview/lib/action_view/test_case.rb
Expand Up @@ -60,9 +60,96 @@ module Behavior
include ActiveSupport::Testing::ConstantLookup

delegate :lookup_context, to: :controller
attr_accessor :controller, :request, :output_buffer, :rendered
attr_accessor :controller, :request, :output_buffer

module ClassMethods
def inherited(descendant) # :nodoc:
super

descendant_content_class = content_class.dup

if descendant_content_class.respond_to?(:set_temporary_name)
descendant_content_class.set_temporary_name("rendered_content")
end

descendant.content_class = descendant_content_class
end

# Register a callable to parse rendered content for a given template
# format.
#
# Each registered parser will also define a +#rendered.[FORMAT]+ helper
# method, where +[FORMAT]+ corresponds to the value of the
# +format+ argument.
#
# === Arguments
#
# <tt>format</tt> - Symbol the name of the format used to render view's content
# <tt>callable</tt> - Callable to parse the String. Accepts the String.
# value as its only argument.
# <tt>block</tt> - Block serves as the parser when the
# <tt>callable</tt> is omitted.
#
# By default, ActionView::TestCase defines a parser for:
#
# * :html - returns an instance of Nokogiri::XML::Node
# * :json - returns an instance of ActiveSupport::HashWithIndifferentAccess
#
# Each pre-registered parser also defines a corresponding helper:
#
# * :html - defines `rendered.html`
# * :json - defines `rendered.json`
#
# === Examples
#
# test "renders HTML" do
# article = Article.create!(title: "Hello, world")
#
# render partial: "articles/article", locals: { article: article }
#
# assert_pattern { rendered.html.at("main h1") => { content: "Hello, world" } }
# end
#
# test "renders JSON" do
# article = Article.create!(title: "Hello, world")
#
# render formats: :json, partial: "articles/article", locals: { article: article }
#
# assert_pattern { rendered.json => { title: "Hello, world" } }
# end
#
# To parse the rendered content into RSS, register a call to <tt>RSS::Parser.parse</tt>:
#
# register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }
#
# test "renders RSS" do
# article = Article.create!(title: "Hello, world")
#
# render formats: :rss, partial: article
#
# assert_equal "Hello, world", rendered.rss.items.last.title
# end
#
# To parse the rendered content into a Capybara::Simple::Node,
# re-register an <tt>:html</tt> parser with a call to
# <tt>Capybara.string</tt>:
#
# register_parser :html, -> rendered { Capybara.string(rendered) }
#
# test "renders HTML" do
# article = Article.create!(title: "Hello, world")
#
# render partial: article
#
# rendered.html.assert_css "h1", text: "Hello, world"
# end
def register_parser(format, callable = nil, &block)
parser = callable || block || :itself.to_proc
content_class.redefine_method(format) do
parser.call(to_s)
end
end

def tests(helper_class)
case helper_class
when String, Symbol
Expand Down Expand Up @@ -108,6 +195,27 @@ def include_helper_modules!
end
end

included do
class_attribute :content_class, instance_accessor: false, default: Content

setup :setup_with_controller

register_parser :html, -> rendered { Rails::Dom::Testing.html_document.parse(rendered).root }
register_parser :json, -> rendered { JSON.parse(rendered, object_class: ActiveSupport::HashWithIndifferentAccess) }

ActiveSupport.run_load_hooks(:action_view_test_case, self)

helper do
def protect_against_forgery?
false
end

def _test_case
controller._test_case
end
end
end

def setup_with_controller
controller_class = Class.new(ActionView::TestCase::TestController)
@controller = controller_class.new
Expand All @@ -134,10 +242,64 @@ def rendered_views
@_rendered_views ||= RenderedViewsCollection.new
end

# Returns the content rendered by the last +render+ call.
#
# The returned object behaves like a string but also exposes a number of methods
# that allows you to parse the content string in formats registered using
# <tt>.register_parser</tt>.
#
# By default includes the following parsers:
#
# +.html+
#
# Parse the <tt>rendered</tt> content String into HTML. By default, this means
# a <tt>Nokogiri::XML::Node</tt>.
#
# test "renders HTML" do
# article = Article.create!(title: "Hello, world")
#
# render partial: "articles/article", locals: { article: article }
#
# assert_pattern { rendered.html.at("main h1") => { content: "Hello, world" } }
# end
#
# To parse the rendered content into a <tt>Capybara::Simple::Node</tt>,
# re-register an <tt>:html</tt> parser with a call to
# <tt>Capybara.string</tt>:
#
# register_parser :html, -> rendered { Capybara.string(rendered) }
#
# test "renders HTML" do
# article = Article.create!(title: "Hello, world")
#
# render partial: article
#
# rendered.html.assert_css "h1", text: "Hello, world"
# end
#
# +.json+
#
# Parse the <tt>rendered</tt> content String into JSON. By default, this means
# a <tt>ActiveSupport::HashWithIndifferentAccess</tt>.
#
# test "renders JSON" do
# article = Article.create!(title: "Hello, world")
#
# render formats: :json, partial: "articles/article", locals: { article: article }
#
# assert_pattern { rendered.json => { title: "Hello, world" } }
# end
def rendered
@_rendered ||= self.class.content_class.new(@rendered)
end

def _routes
@controller._routes if @controller.respond_to?(:_routes)
end

class Content < SimpleDelegator
end

# Need to experiment if this priority is the best one: rendered => output_buffer
class RenderedViewsCollection
def initialize
Expand All @@ -164,21 +326,6 @@ def view_rendered?(view, expected_locals)
end
end

included do
setup :setup_with_controller
ActiveSupport.run_load_hooks(:action_view_test_case, self)

helper do
def protect_against_forgery?
false
end

def _test_case
controller._test_case
end
end
end

private
# Need to experiment if this priority is the best one: rendered => output_buffer
def document_root_element
Expand Down
1 change: 1 addition & 0 deletions actionview/test/fixtures/developers/_developer.json.ruby
@@ -0,0 +1 @@
{ name: developer.name }.to_json
59 changes: 59 additions & 0 deletions actionview/test/template/test_case_test.rb
Expand Up @@ -382,6 +382,65 @@ def page
end
end

class RenderedMethodMissingTest < ActionView::TestCase
test "rendered delegates methods to the String" do
developer = DeveloperStruct.new("Eloy")

render "developers/developer", developer: developer

assert_kind_of String, rendered.to_s
assert_equal developer.name, rendered
assert_match rendered, /#{developer.name}/
assert_includes rendered, developer.name
end
end

class HTMLParserTest < ActionView::TestCase
test "rendered.html is a Nokogiri::XML::Element" do
developer = DeveloperStruct.new("Eloy")

render "developers/developer", developer: developer

assert_kind_of Nokogiri::XML::Element, rendered.html
assert_equal developer.name, document_root_element.text
end

test "do not memoize the rendered.html in view tests" do
concat form_tag("/foo")

assert_equal "/foo", document_root_element.at("form")["action"]

concat content_tag(:b, "Strong", class: "foo")

assert_equal "/foo", document_root_element.at("form")["action"]
assert_equal "foo", document_root_element.at("b")["class"]
end
end

class JSONParserTest < ActionView::TestCase
test "rendered.json is an ActiveSupport::HashWithIndifferentAccess" do
developer = DeveloperStruct.new("Eloy")

render formats: :json, partial: "developers/developer", locals: { developer: developer }

assert_kind_of ActiveSupport::HashWithIndifferentAccess, rendered.json
assert_equal developer.name, rendered.json[:name]
end
end

class MissingHTMLParserTest < ActionView::TestCase
register_parser :html, nil

test "rendered.html falls back to returning the value when the parser is missing" do
developer = DeveloperStruct.new("Eloy")

render "developers/developer", developer: developer

assert_kind_of String, rendered.html
assert_equal developer.name, rendered.html
end
end

module AHelperWithInitialize
def initialize(*)
super
Expand Down
Expand Up @@ -9,6 +9,27 @@ class ActionView::PatternMatchingTestCases < ActionView::TestCase
# rubocop:disable Lint/Syntax
assert_pattern { document_root_element.at("h1") => { content: "Eloy", attributes: [{ name: "id", value: "name" }] } }
refute_pattern { document_root_element.at("h1") => { content: "Not Eloy" } }
end

test "rendered.html integrates with pattern matching" do
developer = DeveloperStruct.new("Eloy")

render "developers/developer", developer: developer

# rubocop:disable Lint/Syntax
assert_pattern { rendered.html => { content: "Eloy" } }
refute_pattern { rendered.html => { content: "Not Eloy" } }
# rubocop:enable Lint/Syntax
end

test "rendered.json integrates with pattern matching" do
developer = DeveloperStruct.new("Eloy")

render formats: :json, partial: "developers/developer", locals: { developer: developer }

# rubocop:disable Lint/Syntax
assert_pattern { rendered.json => { name: "Eloy" } }
refute_pattern { rendered.json => { name: "Not Eloy" } }
# rubocop:enable Lint/Syntax
end
end
56 changes: 56 additions & 0 deletions guides/source/7_1_release_notes.md
Expand Up @@ -422,6 +422,62 @@ assert_pattern { html.at("main") => { children: [{ name: "h1", content: /content
[nokogiri-pattern-matching]: https://nokogiri.org/rdoc/Nokogiri/XML/Attr.html#method-i-deconstruct_keys
[minitest-pattern-matching]: https://docs.seattlerb.org/minitest/Minitest/Assertions.html#method-i-assert_pattern

### Introduce `ActionView::TestCase.register_parser`

[Extend `ActionView::TestCase`][#49194] to support parsing content rendered by
view partials into known structures. By default, define `rendered_html` to parse
HTML into a `Nokogiri::XML::Node` and `rendered_json` to parse JSON into an
`ActiveSupport::HashWithIndifferentAccess`:

```ruby
test "renders HTML" do
article = Article.create!(title: "Hello, world")

render partial: "articles/article", locals: { article: article }

assert_pattern { rendered_html.at("main h1") => { content: "Hello, world" } }
end

test "renders JSON" do
article = Article.create!(title: "Hello, world")

render formats: :json, partial: "articles/article", locals: { article: article }

assert_pattern { rendered_json => { title: "Hello, world" } }
end
```

To parse the rendered content into RSS, register a call to `RSS::Parser.parse`:

```ruby
register_parser :rss, -> rendered { RSS::Parser.parse(rendered) }

test "renders RSS" do
article = Article.create!(title: "Hello, world")

render formats: :rss, partial: article, locals: { article: article }

assert_equal "Hello, world", rendered_rss.items.last.title
end
```

To parse the rendered content into a Capybara::Simple::Node, re-register an
`:html` parser with a call to `Capybara.string`:

```ruby
register_parser :html, -> rendered { Capybara.string(rendered) }

test "renders HTML" do
article = Article.create!(title: "Hello, world")

render partial: article

rendered_html.assert_css "main h1", text: "Hello, world"
end
```

[#49194]: https://github.com/rails/rails/pull/49194

Railties
--------

Expand Down

0 comments on commit 7badc42

Please sign in to comment.