Introduce ActionView::TestCase.register_parser

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
```
This commit is contained in:
Sean Doyle 2023-09-07 15:30:11 -04:00 committed by Rafael Mendonça França
parent 162be9f219
commit 7badc42723
No known key found for this signature in database
GPG Key ID: FC23B6D0F1EEE948
8 changed files with 364 additions and 17 deletions

@ -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

@ -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
@ -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
@ -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
@ -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

@ -0,0 +1 @@
{ name: developer.name }.to_json

@ -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

@ -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

@ -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
--------

@ -1699,7 +1699,7 @@ Partial templates - usually called "partials" - are another device for breaking
View tests provide an opportunity to test that partials render content the way you expect. View partial tests reside in `test/views/` and inherit from `ActionView::TestCase`.
To render a partial, call `render` like you would in a template. The content is
available through the test-local `rendered` method:
available through the test-local `#rendered` method:
```ruby
class ArticlePartialTest < ActionView::TestCase
@ -1793,7 +1793,52 @@ class ArticlePartialTest < ViewPartialTestCase
end
```
Starting in Action View version 7.1, the `#rendered` helper method returns an
object capable of parsing the view partial's rendered content.
To transform the `String` content returned by the `#rendered` method into an
object, define a parser by calling `.register_parser`. Calling
`.register_parser :rss` defines a `#rendered.rss` helper method. For example,
to parse rendered [RSS content][] into an object with `#rendered.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
assert_equal "Hello, world", rendered.rss.items.last.title
end
```
By default, `ActionView::TestCase` defines a parser for:
* `:html` - returns an instance of [Nokogiri::XML::Node](https://nokogiri.org/rdoc/Nokogiri/XML/Node.html)
* `:json` - returns an instance of [ActiveSupport::HashWithIndifferentAccess](https://edgeapi.rubyonrails.org/classes/ActiveSupport/HashWithIndifferentAccess.html)
```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
```
[rails-dom-testing]: https://github.com/rails/rails-dom-testing
[RSS content]: https://www.rssboard.org/rss-specification
Testing Helpers
---------------