Skip to content

Instantly share code, notes, and snippets.

@Phlip
Created May 16, 2020 03:23
Show Gist options
  • Save Phlip/5dbc7f5ac25f74591f3754b9fd6b8d66 to your computer and use it in GitHub Desktop.
Save Phlip/5dbc7f5ac25f74591f3754b9fd6b8d66 to your computer and use it in GitHub Desktop.
assert_xpath, assert_latest, assert_yin_yang
require 'test_helper'
require 'nokogiri'
require 'pp'
class WelcomeControllerTest < ActionController::TestCase
# Someone currently working in Ruby on Rails or similar should
# put these into Ruby gems ktx
##
# +assert_xml+ validates XML in its string argument, +xml+, and prepares it for
# further testing with +assert_xpath+. It optionally raises a Minitest assertion
# failure with any XML syntax errors it finds.
#
# ==== Parameters
#
# * +xml+ - A string containing XML.
# * +strict+ - Optional boolean deciding whether to raise syntax errors. Defaults to +true+.
#
# ==== Returns
#
# If the XML passes inspection, it places the XML's Document Object Model into
# the variable <code>@selected</code>, for +assert_xpath+ and +refute_xpath+ to
# interrogate.
#
# Finally, it returns <code>@selected</code>, for custom testing.
#
def assert_xml(xml, strict = true)
@selected = Nokogiri::XML(xml)
assert @selected.xml?, 'Nokogiri should identify this as XML.'
strict and _assert_no_xml_or_html_syntax_errors(xml)
return @selected
end
##
# +assert_html+ validates an HTML string, and prepares it for further
# testing with +assert_xpath+ or +refute_xpath+.
#
# ==== Parameters
#
# * +html+ - Optional string of HTML. Defaults to <code>response.body</code>.
# * +strict+ - Optional boolean deciding whether to raise syntax errors. Defaults to +true+.
#
# ==== Examples
#
# Call +assert_html+ in one of several ways.
#
# In a test environment such as a test suite derived from
# +ActionController::TestCase+ or +ActionDispatch::IntegrationTest+, if a call
# such as <code>get :action</code> has prepared the +response+ instance variable,
# you may call +assert_html+ invisibly, by letting +assert_xpath+ call it for you:
#
# get :new
# assert_xpath '//form[ "/create" = @action ]'
#
# In that mode, +assert_html+ will raise a Minitest failure if the HTML contains
# a syntax error. If you cannot fix this error, you can reduce +assert_html+'s
# aggressiveness by calling it directly with +false+ in its second parameter:
#
# get :new
# assert_html response.body, false
# assert_xpath '//form[ "/create" = @action ]'
#
# ==== Returns
#
# +assert_html+ returns the <code>@selected</code> Document Object Model root element,
# for custom testing.
#
def assert_html(html = nil, strict = true)
html ||= response.body
@selected = Nokogiri::HTML(html)
assert @selected.html?, 'Nokogiri should identify this as HTML.'
if strict
_assert_no_xml_or_html_syntax_errors(html)
if strict == :html5 || html =~ /\A\s*<!doctype html>/i
deprecated = %w(acronym applet basefont big center dir font frame
frameset noframes isindex nobr menu s strike tt u)
deprecated.each do |dep|
refute_xpath "//#{dep}", "The <#{dep}> element is deprecated."
end
deprecated_attributes = [
[ 'height', [ 'table', 'tr', 'th', 'td' ] ],
[ 'align', %w(caption iframe img input object legend table
hr div h1 h2 h3 h4 h5 h6
p col colgroup tbody td tfoot th thead tr) ],
[ 'valign', [ 'td' 'th' ] ],
[ 'width', [ 'hr', 'table', 'td', 'th', 'col', 'colgroup', 'pre' ] ],
[ 'name', [ 'img' ] ]
]
deprecated_attributes.each do |attr, tags|
tags.each do |tag|
refute_xpath "//#{tag}[ @#{attr} ]", "The <#{tag} #{attr}> attribute is deprecated."
end
end
refute_xpath '//body//style', '<style> tags must be in the <head>.'
refute_xpath '//table/tr', '<table> element missing <thead> or <tbody>.'
refute_xpath '//td[ not( parent::tr ) ]', '<td> element without <tr> parent.'
refute_xpath '//th[ not( parent::tr ) ]', '<th> element without <tr> parent.'
# TODO fix Warning: <input> anchor "pc_contract_create_activity_" already defined
# in pc_contracts_controller new
# refute_xpath '//img[ @full_size ]', '<img> contains proprietary attribute "full_size".'
# CONSIDER tell el-Goog not to do this: refute_xpath '//textarea[ @value ]', '<textarea> contains proprietary attribute "value".'
# CONSIDER tell el-Goog not to do this: refute_xpath '//iframe[ "" = @src ]', '<iframe> contains empty attribute "src".'
# A document must not include both a meta element with an http-equiv attribute whose value is content-type, and a meta element with a charset attribute.
# Consider avoiding viewport values that prevent users from resizing documents.
#
# From line 9, column 1; to line 9, column 103
#
# s</title>↩<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">↩<meta
#
# The type attribute is unnecessary for JavaScript resources.
#
# From line 394, column 9; to line 394, column 46
#
# >↩ <script type="application/javascript">↩
# TODO tidy sez: Warning: <a> escaping malformed URI reference
# TODO tidy sez: Warning: <a> illegal characters found in URI
end
end
return @selected
end
def _assert_no_xml_or_html_syntax_errors(xml) #:nodoc:
errs =
@selected.errors.map do |error|
if error.level > 0
err = "#{error.level > 1 ? 'Error' : 'Warning'} on line #{error.line}, column #{error.column}: code #{error.code}, level #{error.level}\n"
err << "#{error}\n"
error.str1.present? and err << "#{error.str1}\n"
error.str2.present? and err << "#{error.str2}\n"
error.str3.present? and err << "#{error.str3}\n"
err << xml.lines[error.line - 1].rstrip + "\n"
error.column > 1 and err << ('-' * (error.column - 1))
err << "^\n"
err
else
''
end
end
errs = errs.join("\n")
errs.present? and raise Minitest::Assertion, errs
end
private :_assert_no_xml_or_html_syntax_errors
##
# After calling +assert_xml+ or +assert_html+, or calling a test method that loads
# +response.body+, call +assert_xpath+ to query XML's or HTML's Document Object
# Model and interrogate its tags, attributes, and contents.
#
# ==== Parameters
#
# * +path+ - Required XPath string.
# * +replacements+ - Optional Hash of replacement keys and values.
# * +message+ - Optional string or ->{block} containing additional diagnostics.
# * <code>&block</code> - Optional block to call with a selected context.
#
# ==== Returns
#
# +assert_xpath+ returns the first <code>Nokogiri::XML::Element</code> it finds matching its
# XPath, and it yields this element to any block it is called with. Note that Nokogiri
# supplies many useful methods on this element, including +.text+ to access its text
# contents, and <code>[]</code> to access its attributes, such as the below
# <code>form[:method]</code>. And because the method +.to_s+ returns an element's outer
# HTML, a debugging trace line like <code>puts assert_xpath('td[ 2 ]')</code> will
# output the selected element's HTML.
#
# ==== Yields
#
# You may call +assert_select+ and pass it a block containing +assert_xpath+,
# and +assert_xpath+ optionally yields to a block where you can call +assert_xpath+
# and +assert_select+. Each block restricts the context which XPath or CSS selectors
# can access:
#
# get :index
# assert_select 'td#contact_form' do
# assert_xpath 'form[ "/contact" = @action ]' do |form|
# assert_equal 'post', form[:method]
# assert_xpath './/input[ "name" = @name and "text" = @type and "" = @value ]'
# assert_select 'input[type=submit][name=commit]'
# end
# end
#
# A failure inside one of those +assert_xpath+ blocks will report only its XML or HTML
# context in its failure messages.
#
# ==== Operations
#
# Note that an XPath of <code>'form'</code> finds an immediate child of the current
# context, while a CSS selector of <code>'form'</code> will find any descendant. And
# note that, although an XPath of <code>'//input'</code> will find any 'input' in
# the current document, only a relative XPath of <code>'.//input'</code> will find
# only the descendants of the current context.
#
# Warning: At failure time, +assert_xpath+ prints out the XML or HTML context where your
# XPath failed. However, as a convenience for reading this source, +assert_xpath+ uses
# Nokogiri to reformat the source, and to expand entities such as <code>&</code>. This
# means the diagnostic message won't exactly match the input. It's better than nothing.
#
# Consult an XPath reference to learn the full power of the queries possible. Here are
# some examples:
#
# assert_xpath '//select[ "names" = @name and 26 = count( option ) ]' do
# assert_xpath 'option[ 1 ][ "Able" = text() ]', 'Must be first.'
# assert_xpath 'option[ 2 ][ "Baker" = text() and not( @selected ) ]'
# assert_xpath 'option[ 3 ][ "Charlie" = text() and @selected ]'
# assert_xpath 'option[ last() ][ "Zed" = text() ]'
# end
# assert_xpath './/textarea[ "message" = @name and not( text() ) ]'
# assert_xpath '/html/head/title[ contains( text(), "Contact us" ) ]'
# em = assert_xpath('td/em')
# assert_match /No members/, em.text
# assert_xpath 'div[ 1 ][ not( * ) ]', 'The first div must have no children.'
# assert_xpath '//p[ label[ "type_id" = @for ] ]' # Returns the p not the label
#
# +assert_xpath+ accepts a Hash of replacement values for its second or third
# argument. Use this to inject strings that are long, or contain quotes ' ",
# or are generated. A Hash key of +:id+ will inject its value into an XPath of
# <code>$id</code>:
#
# assert_xpath 'label/input[ "radio" = @type and $value = @value and $id = @id ]',
# value: type.id, id: "type_id_#{type.id}"
#
# Finally, +assert_xpath+ accepts a message string or callable, to provide extra
# diagnostics in its failure message. This message could be the last argument, but
# passing the replacements hash last is sometimes more convenient:
#
# assert_xpath 'script[ $amount = @data-amount ]',
# 'The script must contain a data-amount in pennies',
# amount: 100
#
# Pass a ->{block} for the message argument if it is expensive and you don't want
# it to slow down successful tests:
#
# assert_xpath 'script[ $amount = @data-amount ]',
# ->{ generate_extra_diagnostics_for_amount(100) },
# amount: 100
#
def assert_xpath(path, replacements = {}, message = nil, &block)
replacements, message = _get_xpath_arguments(replacements, message)
element = @selected.at_xpath(path, nil, replacements)
element or _flunk_xpath(path, '', replacements, message)
if block
begin
waz_selected = @selected
@selected = element
block.call(element)
ensure
@selected = waz_selected
end
end
pass # Increment the test runner's assertion count.
return element
end
##
# See +assert_xpath+ to learn what contexts can call +refute_xpath+. This
# assertion fails if the given XPath query can find an element in the current
# <code>@selected</code> or +response.body+ context.
#
# ==== Parameters
#
# Like +assert_xpath+ it takes an XPath string, an optional Hash of
# replacements, and an optional message as a string or callable:
#
# refute_xpath '//form[ $action = @action ]',
# { action: "/users/#{user.id}/cancel" },
# ->{ 'The Cancel form must not appear yet' }
#
# ==== Returns
#
# Unlike +assert_xpath+, +refute_xpath+ naturally does not yield to a block
# or return an element.
#
def refute_xpath(path, replacements = {}, message = nil)
replacements, message = _get_xpath_arguments(replacements, message)
element = @selected.at_xpath(path, nil, replacements)
element and _flunk_xpath(path, 'not ', replacements, message)
pass # Increment the test runner's assertion count.
return nil # there it is; the non-element!
end
def _get_xpath_arguments(replacements, message) #:nodoc:
@selected ||= nil # Avoid a dumb warning.
@selected or assert_html # Because assert_html snags response.body for us.
message_is_replacements = message.is_a?(Hash)
replacements_is_message = replacements.is_a?(String) || replacements.respond_to?(:call)
replacements, message = message, replacements if message_is_replacements || replacements_is_message
# Nokogiri requires all replacement values to be strings...
replacements ||= {}
replacements = replacements.merge(replacements){ |_, _, v| v.to_s }
return replacements, message
end
private :_get_xpath_arguments
def _flunk_xpath(path, polarity, replacements, message) #:nodoc:
message = message.respond_to?(:call) ? message.call : message
diagnostic = message.to_s
diagnostic.length > 0 and diagnostic << "\n"
element = Array.wrap(@selected)[0]
pretty = element.xml? ? element.to_xml : element.to_xhtml
diagnostic << "Element #{polarity}expected in:\n`#{pretty}`\nat xpath:\n`#{path}`"
replacements.any? and diagnostic << "\nwith: " + replacements.pretty_inspect
raise Minitest::Assertion, diagnostic
end
private :_flunk_xpath
##
# When a test case calls methods that write new ActiveModel records to a database,
# sometimes the test needs to assert those records were created, by fetching them back
# for inspection. +assert_latest_record+ collects every record in the given model or
# models that appear while its block runs, and returns either a single record or a ragged
# array.
#
# ==== Parameters
#
# * +models+ - At least 1 ActiveRecord model or association.
# * +message+ - Optional string or ->{block} to provide more diagnostics at failure time.
# * <code>&block</code> - Required block to call and monitor for new records.
#
# ==== Example
#
# user, email_addresses =
# assert_latest_record User, EmailAddress, ->{ 'Need moar records!' } do
# post :create, ...
# end
# assert_equal 'franklyn', user.login # 1 user, so not an array
# assert_equal 2, email_addresses.length
# assert_equal 'franklyn@gmail.com', email_addresses.first.mail
# assert_equal 'franklyn@hotmail.com', email_addresses.second.mail
#
# ==== Returns
#
# The returned value is a set of one or more created records. The set is normalized,
# so all arrays of one item are replaced with the item itself.
#
# ==== Operations
#
# The last argument to +assert_latest_record+ can be a string or a callable block.
# At failure time the assertion adds this string or this block's return value to
# the diagnostic message.
#
# You may call +assert_latest_record+ with anything that responds to <code>.pluck(:id)</code>
# and <code>.where()</code>, including ActiveRecord associations:
#
# user = User.last
# email_address =
# assert_latest_record user.email_addresses do
# post :add_email_address, user_id: user.id, ...
# end
# assert_equal 'franklyn@philly.com', email_address.mail
# assert_equal email_address.user_id, user.id, 'This assertion is redundant.'
#
# +assert_latest_record+ also works on records with generated non-sequential and multiple
# primary keys, such as GUIDs or join tables.
#
def assert_latest_record(*models, &block)
models, message = _get_latest_record_args(models, 'assert')
latests = _get_latest_record(models, block)
latests.include?(nil) and _flunk_latest_record(models, latests, message, true)
pass # Increment the test runner's assertion count
return latests.length > 1 ? latests : latests.first
end
##
# When a test case calls methods that might write new ActiveModel records to a
# database, sometimes the test must check that no records were written.
# +refute_latest_record+ watches for new records in the given class or classes
# that appear while its block runs, and fails if any appear.
#
# ==== Parameters
#
# See +assert_latest_record+.
#
# ==== Operations
#
# refute_latest_record User, EmailAddress, ->{ 'GET should not create records' } do
# get :index
# end
#
# The last argument to +refute_latest_record+ can be a string or a callable block.
# At failure time the assertion adds this string or this block's return value to
# the diagnostic message.
#
# Like +assert_latest_record+, you may call +refute_latest_record+ with anything
# that responds to <code>pluck(:id)</code> and <code>where()</code>, including
# ActiveRecord associations. And, like +assert_latest_record+, it works on
# records with generated and multiple primary keys, such as GUIDs or join tables.
#
def refute_latest_record(*models, &block)
models, message = _get_latest_record_args(models, 'refute')
latests = _get_latest_record(models, block)
latests.all?(&:nil?) or _flunk_latest_record(models, latests, message, false)
pass
return
end
##
# Sometimes a test must detect new records without using an assertion that passes
# judgment on whether they should have been written. Call +get_latest_record+ to
# return a ragged array of records created during its block, or +nil+:
#
# user, email_addresses, posts =
# get_latest_record User, EmailAddress, Post do
# post :create, ...
# end
#
# assert_nil posts, "Don't create Post records while creating a User"
#
# Unlike +assert_latest_record+, +get_latest_record+ does not take a +message+ string
# or block, because it has no diagnostic message.
#
# Like +assert_latest_record+, you may call +get_latest_record+ with anything
# that responds to <code>.pluck(:id)</code> and <code>.where()</code>, including
# ActiveRecord associations. And, like +assert_latest_record+, it works on
# records with generated and multiple primary keys, such as GUIDs or join tables.
#
def get_latest_record(*models, &block)
assert models.any?, 'Call get_latest_record with one or more ActiveRecord models or associations.'
refute_nil block, 'Call get_latest_record with a block.'
records = _get_latest_record(models, block)
return records.length > 1 ? records : records.first
end # Methods should be easy to use correctly and hard to use incorrectly...
def _get_latest_record_args(models, what) #:nodoc:
message = nil
message = models.pop unless models.last.respond_to?(:pluck)
valid_message = message.nil? || message.kind_of?(String) || message.respond_to?(:call)
models.length > 0 && valid_message and return models, message
raise "call #{what}_latest_record(models..., message) with any number\n" +
'of Model classes or associations, followed by an optional diagnostic message'
end
private :_get_latest_record_args
def _get_latest_record(models, block) #:nodoc:
id_sets = models.map{ |model| model.pluck(*model.primary_key) } # Sorry about your memory!
block.call
record_sets = []
models.each_with_index do |model, index|
pk = model.primary_key
set = id_sets[index]
records =
if set.length == 0
model
elsif pk.is_a?(Array)
pks = pk.map{ |k| "`#{k}` = ?" }.join(' AND ')
pks = [ "(#{pks})" ] * set.length
pks = pks.join(' OR ')
model.where.not(pks, *set.flatten)
else
model.where.not(pk => set)
end
records = records.order(*pk).to_a
record_sets.push records.length > 1 ? records : records.first
end
return record_sets
end
private :_get_latest_record
def _flunk_latest_record(models, latests, message, polarity) #:nodoc:
itch_list = []
models.each_with_index do |model, index|
records_found = latests[index] != nil
records_found == polarity or itch_list << model.name
end
itch_list = itch_list.join(', ')
diagnostic = "should#{' not' unless polarity} create new #{itch_list} record(s) in block"
message = nil if message == ''
message = message.call.to_s if message.respond_to?(:call)
message = [ message, diagnostic ].compact.join("\n")
raise Minitest::Assertion, message
end
private :_flunk_latest_record
def assert_yin_yang(*args, proc)
args.empty? and args = [false, true]
explain = 'Call assert_yin_yang with none, one, or two value arguments and a procedure.'
assert args.length < 3 && proc.respond_to?(:call), explain
yin_result = proc.call
message = "Expression expected to change from #{args.first.inspect} to #{args.last.inspect} but failed "
if args.first.nil?
assert_nil yin_result, message + 'before yield'
else
assert_equal args.first, yin_result, message + 'before yield'
end
x = yield
yang_result = proc.call
if args.last.nil?
assert_nil yang_result, message + 'after yield'
else
assert_equal args.last, yang_result, message + 'after yield'
end
return x
end
def test_strict_Nokogiri
html = '<script>
let buttonDelete = $(`<a class="icon icon-del" href="#"></a>`);
</script>'
doc = Nokogiri::HTML(html, nil, nil, Nokogiri::XML::ParseOptions::STRICT)
# assert_empty doc.errors CONSIDER see https://stackoverflow.com/questions/49683104/well-formed-scriptjavascript-script-tags-confuse-nokogirihtml-in-strict-mo
# Then see: https://bugzilla.gnome.org/show_bug.cgi?id=795390
end
def test_assert_xml
bad_xml = '<a><b'
doc = Nokogiri::XML(bad_xml)
assert_equal 2, doc.errors.length # CONSIDER use .length not .size
error = doc.errors.first
assert_equal 3, error.level
assert error.fatal?
assert_equal 1, error.line
assert_equal 6, error.column
assert_match /1:6: FATAL: Couldn't find end of Start Tag b line 1/, error.to_s
assert_equal 73, error.code
assert_equal 'b', error.str1
assert_nil error.str2
assert_nil error.str3
error = doc.errors.second
assert_equal 3, error.level
assert error.fatal?
assert_equal 1, error.line
assert_equal 6, error.column
assert_match /1:6: FATAL: Premature end of data in tag a line 1/, error.to_s
assert_equal 77, error.code
assert_equal 'a', error.str1
assert_nil error.str2
assert_nil error.str3
e =
assert_raises Minitest::Assertion do
assert_xml bad_xml
end
assert_match /Couldn't find end of Start Tag b line 1/, e.message
assert_match /Premature end of data in tag a line 1/, e.message
end
# def test_assert_xpath
# assert_xml '<a><b></b><b id="42"></b></a>'
#
# e =
# assert_raises Minitest::Assertion do
#
# assert_xpath '/a/b[ $id = @id ]', { id: 43 }, ->{ 'Tom Lehrer' }
#
# end
#
# assert_match /Tom Lehrer/, e.message
# assert_includes @selected.to_s, e.message
# assert_includes ':id=>"43"', e.message
# end
# def test_assert_xpath_with_reversed_arguments
# assert_xml '<a><b></b><b id="42"></b></a>'
#
# e =
# assert_raises Minitest::Assertion do
#
# assert_xpath '/a/b[ $id = @id ]', ->{ 'Insect Surfers' }, id: 43
#
# end
#
# assert_match /Insect Surfers/, e.message
# assert_includes @selected.to_s, e.message
# assert_includes ':id=>"43"', e.message
# end
# def test_refute_xpath
# assert_xml '<a><b></b><b id="42"></b></a>'
#
# e =
# assert_raises Minitest::Assertion do
#
# refute_xpath '/a/b[ $id = @id ]', { id: '42' }, ->{ 'Bart and the Bedazzle' }
#
# end
#
# assert_match /Bart and the Bedazzle/, e.message
# assert_includes @selected.to_s, e.message
# assert_includes ':id=>"42"', e.message
# end
# def test_refute_xpath_with_reversed_arguments
# assert_xml '<a><b></b><b id="42"></b></a>'
#
# e =
# assert_raises Minitest::Assertion do
#
# refute_xpath '/a/b[ $id = @id ]', ->{ 'Noam Chomsky' }, id: '42'
#
# end
#
# assert_match /Noam Chomsky/, e.message
# assert_includes @selected.to_s, e.message
# assert_includes ':id=>"42"', e.message
# end
# def test_assert_latest_record
# e =
# assert_raises Minitest::Assertion do
#
# assert_latest_record Product, TimeEntry, ->{ 'permatrails' } do
# # post :create # oops!
# end
#
# end
#
# assert_match /^permatrails$/, e.message
# assert_match /should create new PcProduct, TimeEntry record\(s\) in block/, e.message
# end
test "should get index" do
get :index
assert_response :success
assert_select 'h1'
assert_select 'ul' do
assert_xpath 'li[ "frogs" = text() ]'
end
x = 7
assert_yin_yang ->{ x == 8 } do
x = 8
end
assert_yin_yang true, false, ->{ x == 8 } do
x = 7
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment