defmodule HTMLAssertion do @moduledoc ~s""" HTMLAssertion adds assertions for testing rendered HTML using CSS selectors. ## Usage in Phoenix Controller and Integration Test Import the HTML assertion functions in `YourAppWeb.ConnCase`. Remember to replace all instances of `YourApp` with your app module name. ```elixir defmodule YourAppWeb.ConnCase do . . . using do quote do # Import conveniences for testing with connections use Phoenix.ConnTest alias YourAppWeb.Router.Helpers, as: Routes use HTMLAssertion # Imports assert_select, refute_select # The default endpoint for testing @endpoint YourAppWeb.Endpoint end end . . . end ``` 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_select("title", "PAGE TITLE") # Page title is "PAGE TITLE" and there is only one title element |> assert_select("title", count: 1, text: "PAGE TITLE") # Page title matches "PAGE" and there is only one title element |> assert_select("title", count: 1, match: "PAGE") # Page has one link with href value "/signup" |> assert_select("a[href='/signup']", count: 1) # Page has at least one link |> assert_select("a", min: 1) # Page has at most two links |> assert_select("a", max: 2) # Page contains no forms |> refute_select("form") end end ``` """ alias HTMLAssertion.{Debug, Matcher, Selector, Parser} @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() @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 HTMLAssertion.DSL import HTMLAssertion, except: [ assert_select: 2, assert_select: 3, assert_select: 4, refute_select: 2, refute_select: 3, refute_select: 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_select(html, ".zoo", class: "bar zoo") ~S{
} # check if `id` not exsists iex> assert_select(~S{
text
}, id: nil) "
text
" ``` #### Examples check :text Asserts a text element in HTML iex> html = ~S{

Header

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

Header

} iex> html = ~S{

Header

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

Header

} iex> html = ~S{

Header

} ...> try do ...> assert_select(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_select(html, text: "Some & text") ~S{
Some & text
} ## Selector `assert_select(html, "css selector")` ``` iex> html = ~S{

Header

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

Header

} iex> html = ~S{

Header

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

Header

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

Hello

}) assert_select(html, match: ~r{

Hello

}) assert_select(html, match: "

Hello

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

Hello World

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

Hello World

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

Hello World

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

Hello

}) """ @spec assert_select(html, Regex.t()) :: html | no_return() def assert_select(html, %Regex{} = value) do html(:assert, html, nil, match: value) end @spec assert_select(html, block_fn) :: html | no_return() def assert_select(html, block_fn) when is_binary(html) and is_function(block_fn) do html(:assert, html, nil, nil, block_fn) end @spec assert_select(html, css_selector) :: html | no_return() def assert_select(html, css_selector) when is_binary(html) and is_binary(css_selector) do html(:assert, html, css_selector) end @spec assert_select(html, attributes) :: html | no_return() def assert_select(html, attributes) when is_binary(html) and is_list(attributes) do html(:assert, html, nil, attributes) end @spec assert_select(html, Regex.t(), block_fn) :: html | no_return() def assert_select(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_select(html, attributes, block_fn) :: html | no_return() def assert_select(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_select(html, css_selector, block_fn) :: html | no_return() def assert_select(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_select(html, css_selector, attributes, block_fn \\ nil) @spec assert_select(html, css_selector, value, block_fn | nil) :: html | no_return() def assert_select(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_select(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, [text: value], block_fn) end @spec assert_select(html, css_selector, attributes, block_fn | nil) :: html | no_return() def assert_select(html, css_selector, attributes, block_fn) do html(:assert, html, css_selector, attributes, block_fn) end ################################### ### refute @doc ~S""" """ @spec refute_select(html, Regex.t()) :: html | no_return() def refute_select(html, %Regex{} = value) do html(:refute, html, nil, match: value) end @spec refute_select(html, css_selector) :: html | no_return() def refute_select(html, css_selector) when is_binary(html) and is_binary(css_selector) do html(:refute, html, css_selector) end @spec refute_select(html, attributes) :: html | no_return() def refute_select(html, attributes) when is_binary(html) and is_list(attributes) do html(:refute, html, nil, attributes) end @spec refute_select(html, Regex.t(), block_fn) :: html | no_return() def refute_select(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_select(html, attributes, block_fn) :: html | no_return() def refute_select(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_select(html, css_selector, block_fn) :: html | no_return() def refute_select(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_select(html, css_selector, attributes, block_fn \\ nil) @spec refute_select(html, css_selector, value, block_fn | nil) :: html | no_return() def refute_select(html, css_selector, %Regex{} = value, block_fn) do html(:refute, html, css_selector, [match: value], block_fn) end def refute_select(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_select(html, css_selector, attributes, block_fn | nil) :: html | no_return() def refute_select(html, css_selector, attributes, block_fn) do html(:refute, html, css_selector, attributes, block_fn) end defp html(matcher, context, css_selector, attributes \\ nil, block_fn \\ nil) defp html(matcher, context, css_selector, nil = _attributes, block_fn) do html(matcher, context, css_selector, [], block_fn) end defp html(matcher, context, css_selector, attributes, block_fn) when is_map(attributes) do attributes = Enum.into(attributes, []) html(matcher, context, css_selector, attributes, block_fn) end defp html(matcher, context, css_selector, attributes, block_fn) when matcher in [:assert, :refute] and is_binary(context) 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())}") sub_context = get_context(%{matcher: matcher, context: context, css_selector: css_selector, attributes: attributes}) # check :count meta-attribute {count_value, attributes} = Keyword.pop(attributes, :count) count_value && check_count(%{count_value: count_value, context: context, css_selector: css_selector}) # check :min meta-attribute {min_value, attributes} = Keyword.pop(attributes, :min) min_value && check_min(%{min_value: min_value, context: context, css_selector: css_selector}) # check :max meta-attribute {max_value, attributes} = Keyword.pop(attributes, :max) max_value && check_max(%{max_value: max_value, context: context, css_selector: css_selector}) check_attributes(matcher, sub_context, attributes) # call inside block block_fn && block_fn.(sub_context) context end defp check_count(%{context: context, count_value: count_value, css_selector: css_selector}) when is_integer(count_value) do count_elements = Parser.find(context, css_selector) |> Enum.count() unless count_value == count_elements do raise ExUnit.AssertionError, "Expected #{count_value} element(s). Got #{count_elements} element(s)." end end defp check_min(%{context: context, min_value: min_value, css_selector: css_selector}) when is_integer(min_value) do count_elements = Parser.find(context, css_selector) |> Enum.count() unless count_elements >= min_value do raise ExUnit.AssertionError, "Expected at least #{min_value} element(s). Got #{count_elements} element(s)." end end defp check_max(%{context: context, max_value: max_value, css_selector: css_selector}) when is_integer(max_value) do count_elements = Parser.find(context, css_selector) |> Enum.count() unless count_elements <= max_value do raise ExUnit.AssertionError, "Expected at most #{max_value} element(s). Got #{count_elements} element(s)." end end defp check_attributes(matcher, sub_context, attributes) do {contain_value, attributes} = Keyword.pop(attributes, :match) # check :match meta-attribute contain_value && Matcher.contain(matcher, sub_context, contain_value) if attributes != [] do Matcher.attributes(matcher, sub_context, attributes) end end defp get_context(%{context: context, css_selector: nil}) do context end defp get_context(%{matcher: :refute, attributes: attributes, context: context, css_selector: css_selector}) when attributes != [] do Selector.find(context, css_selector) end defp get_context(%{matcher: matcher, context: context, css_selector: css_selector}) do Matcher.selector(matcher, context, css_selector) end end