defmodule AssertHTML do @moduledoc ~s""" AssertHTML adds ExUnit assert helpers for testing rendered HTML using CSS selectors. ## Usage in Phoenix Controller and Integration Test Assuming the `html_response(conn, 200)` returns: ```html PAGE TITLE Sign up Help ``` An example controller test: ```elixir defmodule YourAppWeb.PageControllerTest do use YourAppWeb.ConnCase, async: true test "should get index", %{conn: conn} do conn = conn |> get(Routes.page_path(conn, :index)) html_response(conn, 200) # Page title is "PAGE TITLE" |> assert_html("title", "PAGE TITLE") # Page title is "PAGE TITLE" and there is only one title element |> assert_html("title", count: 1, text: "PAGE TITLE") # Page title matches "PAGE" and there is only one title element |> assert_html("title", count: 1, match: "PAGE") # Page has one link with href value "/signup" |> assert_html("a[href='/signup']", count: 1) # Page has at least one link |> assert_html("a", min: 1) # Page has at most two links |> assert_html("a", max: 2) # Page contains no forms |> refute_html("form") end end ``` ### Check selector `assert_html(html, ".css .selector .exsits")` - assert error if element doesn't exists in selector path. `refute_html(html, ".css .selector")` - assert error if element doesn't exists in selector path. ### Check Attributes Supports meta attributes: * `:text` – text in element * `:match` - contain value. """ alias AssertHTML.{Debug, Matcher} @collection_checks [:match, :count, :min, :max] @typedoc ~S""" CSS selector ## Supported selectors | Pattern | Description | |-----------------|------------------------------| | * | any element | | E | an element of type `E` | | E[foo] | an `E` element with a "foo" attribute | | E[foo="bar"] | an E element whose "foo" attribute value is exactly equal to "bar" | | E[foo~="bar"] | an E element whose "foo" attribute value is a list of whitespace-separated values, one of which is exactly equal to "bar" | | E[foo^="bar"] | an E element whose "foo" attribute value begins exactly with the string "bar" | | E[foo$="bar"] | an E element whose "foo" attribute value ends exactly with the string "bar" | | E[foo*="bar"] | an E element whose "foo" attribute value contains the substring "bar" | | E[foo\|="en"] | an E element whose "foo" attribute has a hyphen-separated list of values beginning (from the left) with "en" | | E:nth-child(n) | an E element, the n-th child of its parent | | E:first-child | an E element, first child of its parent | | E:last-child | an E element, last child of its parent | | E:nth-of-type(n) | an E element, the n-th child of its type among its siblings | | E:first-of-type | an E element, first child of its type among its siblings | | E:last-of-type | an E element, last child of its type among its siblings | | E.warning | an E element whose class is "warning" | | E#myid | an `E` element with ID equal to "myid" | | E:not(s) | an E element that does not match simple selector s | | E F | an F element descendant of an E element | | E > F | an F element child of an E element | | E + F | an F element immediately preceded by an E element | | E ~ F | an F element preceded by an E element | """ @type css_selector :: String.t() @typedoc """ HTML response """ @type html :: String.t() @typep matcher :: :assert | :refute @type context :: {matcher, html} @typedoc """ HTML element attributes """ @type attributes :: [{attribute_name, value}] @typedoc """ Checking value - if nil should not exist """ @type value :: nil | String.t() | Regex.t() @typedoc """ HTML element attribute name """ @type attribute_name :: atom() | binary() @typep block_fn :: (html -> any()) # @typep value :: String.t() # use macro definition defmacro __using__(_opts) do quote location: :keep do import AssertHTML.DSL import AssertHTML, except: [ assert_html: 2, assert_html: 3, assert_html: 4, refute_html: 2, refute_html: 3, refute_html: 4 ] end end @doc ~S""" Asserts an attributes in HTML element ## assert attributes - `:text` – asserts a text element in HTML - `:match` - asserts containing value in HTML ``` iex> html = ~S{
} ...> assert_html(html, ".zoo", class: "bar zoo") ~S{
} # check if `id` not exsists iex> assert_html(~S{
text
}, id: nil) "
text
" ``` #### Examples check :text Asserts a text element in HTML iex> html = ~S{

Header

} ...> assert_html(html, text: "Header") ~S{

Header

} iex> html = ~S{

Header

} ...> assert_html(html, ".title", text: "Header") ~S{

Header

} iex> html = ~S{

Header

} ...> try do ...> assert_html(html, text: "HEADER") ...> rescue ...> e in ExUnit.AssertionError -> e ...> end %ExUnit.AssertionError{ left: "HEADER", right: "Header", message: "Comparison `text` attribute failed.\n\n\t

Header

.\n" } iex> html = ~S{
Some & text
} ...> assert_html(html, text: "Some & text") ~S{
Some & text
} ## Selector `assert_html(html, "css selector")` ``` iex> html = ~S{

Header

} ...> assert_html(html, "p .foo h1") ~S{

Header

} iex> html = ~S{

Header

} ...> assert_html(html, "h1") ~S{

Header

} ``` ## Match elements in HTML assert_html(html, ~r{

Hello

}) assert_html(html, match: ~r{

Hello

}) assert_html(html, match: "

Hello

") \# Asserts a text element in HTML ### Examples iex> html = ~S{

Hello World

} ...> assert_html(html, "h1", "Hello World") == html true iex> html = ~S{

Hello World

} ...> assert_html(html, ".title", ~r{World}) ~S{

Hello World

} ## assert elements in selector assert_html(html, ".container table", ~r{

Hello

}) """ @spec assert_html(html, Regex.t()) :: html | no_return() def assert_html(html, %Regex{} = value) do html(:assert, html, nil, match: value) end @spec assert_html(html, block_fn) :: html | no_return() def assert_html(html, block_fn) when is_binary(html) and is_function(block_fn) do html(:assert, html, nil, nil, block_fn) end @spec assert_html(html, css_selector) :: html | no_return() def assert_html(html, css_selector) when is_binary(html) and is_binary(css_selector) do html(:assert, html, css_selector) end @spec assert_html(html, attributes) :: html | no_return() def assert_html(html, attributes) when is_binary(html) and is_list(attributes) do html(:assert, html, nil, attributes) end @spec assert_html(html, Regex.t(), block_fn) :: html | no_return() def assert_html(html, %Regex{} = value, block_fn) when is_binary(html) and is_function(block_fn) do html(:assert, html, nil, [match: value], block_fn) end @spec assert_html(html, attributes, block_fn) :: html | no_return() def assert_html(html, attributes, block_fn) when is_binary(html) and is_list(attributes) and is_function(block_fn) do html(:assert, html, nil, attributes, block_fn) end @spec assert_html(html, css_selector, block_fn) :: html | no_return() def assert_html(html, css_selector, block_fn) when is_binary(html) and is_binary(css_selector) and is_function(block_fn) do html(:assert, html, css_selector, nil, block_fn) end def assert_html(html, css_selector, attributes, block_fn \\ nil) @spec assert_html(html, css_selector, value, block_fn | nil) :: html | no_return() def assert_html(html, css_selector, %Regex{} = value, block_fn) when is_binary(html) and is_binary(css_selector) do html(:assert, html, css_selector, [match: value], block_fn) end def assert_html(html, css_selector, value, block_fn) when is_binary(html) and is_binary(css_selector) and is_binary(value) do html(:assert, html, css_selector, [match: value], block_fn) end @spec assert_html(html, css_selector, attributes, block_fn | nil) :: html | no_return() def assert_html(html, css_selector, attributes, block_fn) do html(:assert, html, css_selector, attributes, block_fn) end ################################### ### Refute @doc ~S""" Opposite method for assert_html See more (t:refute_html/2) """ @spec refute_html(html, Regex.t()) :: html | no_return() def refute_html(html, %Regex{} = value) do html(:refute, html, nil, match: value) end @spec refute_html(html, css_selector) :: html | no_return() def refute_html(html, css_selector) when is_binary(html) and is_binary(css_selector) do html(:refute, html, css_selector) end @spec refute_html(html, attributes) :: html | no_return() def refute_html(html, attributes) when is_binary(html) and is_list(attributes) do html(:refute, html, nil, attributes) end @spec refute_html(html, Regex.t(), block_fn) :: html | no_return() def refute_html(html, %Regex{} = value, block_fn) when is_binary(html) and is_function(block_fn) do html(:refute, html, nil, [match: value], block_fn) end @spec refute_html(html, attributes, block_fn) :: html | no_return() def refute_html(html, attributes, block_fn) when is_binary(html) and is_list(attributes) and is_function(block_fn) do html(:refute, html, nil, attributes, block_fn) end @spec refute_html(html, css_selector, block_fn) :: html | no_return() def refute_html(html, css_selector, block_fn) when is_binary(html) and is_binary(css_selector) and is_function(block_fn) do html(:refute, html, css_selector, nil, block_fn) end def refute_html(html, css_selector, attributes, block_fn \\ nil) @spec refute_html(html, css_selector, value, block_fn | nil) :: html | no_return() def refute_html(html, css_selector, %Regex{} = value, block_fn) do html(:refute, html, css_selector, [match: value], block_fn) end def refute_html(html, css_selector, value, block_fn) when is_binary(html) and is_binary(css_selector) and is_binary(value) do html(:refute, html, css_selector, [match: value], block_fn) end @spec refute_html(html, css_selector, attributes, block_fn | nil) :: html | no_return() def refute_html(html, css_selector, attributes, block_fn) do html(:refute, html, css_selector, attributes, block_fn) end defp html(matcher, html_content, css_selector, attributes \\ nil, block_fn \\ nil) defp html(matcher, html_content, css_selector, nil = _attributes, block_fn) do html(matcher, html_content, css_selector, [], block_fn) end defp html(matcher, html_content, css_selector, attributes, block_fn) when is_map(attributes) do attributes = Enum.into(attributes, []) html(matcher, html_content, css_selector, attributes, block_fn) end defp html(matcher, html_content, css_selector, attributes, block_fn) when matcher in [:assert, :refute] and is_binary(html_content) and (is_binary(css_selector) or is_nil(css_selector)) and is_list(attributes) and (is_function(block_fn) or is_nil(block_fn)) do Debug.log("call .html with arguments: #{inspect(binding())}") params = {collection_params, attributes_params} = Keyword.split(attributes, @collection_checks) context = {matcher, html_content} # check selector check_selector(params, context, css_selector) # collection checks (:count, :min, :max and :match collection) check_collection(collection_params, context, css_selector) # check element attributes check_element(attributes_params, context, css_selector) # call inside block if block_fn do sub_html_content = get_sub_html!(context, css_selector, once: true) block_fn.(sub_html_content) end html_content end defp check_element(attributes, context, css_selector) defp check_element([], _context, _css_selector) do :skip end defp check_element(attributes, {matcher, html}, css_selector) do sub_html_content = get_sub_html!({matcher, html}, css_selector, once: true, skip_refute: true) Matcher.attributes({matcher, sub_html_content}, attributes) end defp check_collection([], _context, _css_selector) do :skip end # assert check selection exists defp check_collection(attributes, {matcher, _html} = context, css_selector) do # check :match meta-attribute {contain_value, attributes} = Keyword.pop(attributes, :match) if contain_value do sub_html_content = get_sub_html!(context, css_selector, once: true, skip_refute: true) Matcher.contain({matcher, sub_html_content}, contain_value) end # check :count meta-attribute {count_value, attributes} = Keyword.pop(attributes, :count) count_value && Matcher.count(context, css_selector, count_value) # check :min meta-attribute {min_value, attributes} = Keyword.pop(attributes, :min) min_value && Matcher.min(context, css_selector, min_value) # check :max meta-attribute {max_value, _attributes} = Keyword.pop(attributes, :max) max_value && Matcher.max(context, css_selector, max_value) end defp get_sub_html!({_matcher, html_content}, nil, _options) do html_content end defp get_sub_html!(context, css_selector, options) do Matcher.selector(context, css_selector, options) end defp check_selector(params, context, css_selector) defp check_selector({[], []}, context, css_selector) do get_sub_html!(context, css_selector, once: true) end defp check_selector({[], _}, context, css_selector) do get_sub_html!(context, css_selector, once: true, skip_refute: true) end defp check_selector({_, []}, context, css_selector) do get_sub_html!(context, css_selector, skip_refute: true) end defp check_selector(_params, _mc, _css_selector) do :ok end end