The nature of the data we work with at Verumex often requires us to display tables.
System specs that verify the content of these tables are essential to ensure that the data is displayed correctly.
Assuming your application renders this sales table:
<table id="sales">
<thead>
<tr>
<th>Date</th>
<th>Sales</th>
</tr>
</thead>
<tbody>
<tr>
<td>2024 Q1</td>
<td>100</td>
</tr>
<tr>
<td>2024 Q2</td>
<td>200</td>
</tr>
</tbody>
</table>
As far as I know, the standard test approach for this in Rspec is something like:
it 'renders the sales table' do
# ...
table = find_by_id('sales')
expect(table).to have_content('Sales')
# For more specific assertions:
rows = table.all('tr')
expect(rows[0]).to have_content('Date')
expect(rows[1]).to have_content('2024 Q1')
# ...
end
I’m a huge fan of Making wrong code look wrong
In my opinion, the above implementation makes it difficult to see what’s potentially wrong:
What should a better solution look like?
With that in mind, I wrote my ideal API for this:
it 'renders the sales table' do
# ...
actual_table = find_by_id('sales')
expected_table = <<~MARKDOWN
| Date | Sales |
| 2024 Q1 | 100 |
| 2024 Q2 | 200 |
MARKDOWN
expect_table_content(actual_table, expected_table)
# ...
end
Here is the implementation, after a couple of iterations.
The gist of it is:
# frozen_string_literal: true
module SpecHelpers
module TableHelpers
# Verifies if the content of an HTML table matches the expected markdown table format.
#
# @param actual [Capybara::Node::Element] The actual HTML table to verify.
# @param expected [String] The expected table content in markdown format.
def expect_table_content(actual, expected)
normalized_actual = normalize_html_table(actual)
normalized_expected = normalize_markdown_table(expected)
expect(normalized_actual)
.to eq(normalized_expected),
"HTML table parsed to:\n#{table_data_to_markdown(normalized_actual)}" \
"\nExpected:\n#{expected}"
end
private
# Converts an array of table data into a markdown formatted string.
#
# @param table_data [Array<Array<String>>] A nested array where each sub-array
# represents a row in the table and each element within a sub-array
# represents the content of a cell.
# @return [String] A string representing the table in markdown format,
# where each row is separated by a newline character, and cells within
# a row are separated by a pipe character.
def table_data_to_markdown(table_data)
# Calculate maximum width for each column
max_widths = table_data.transpose.map { |column| column.map(&:length).max }
table_data.map do |row|
concatenated_cells = row.map.with_index do |cell, i|
cell.ljust(max_widths[i])
end.join(' | ')
"| #{concatenated_cells} |"
end.join("\n")
end
# Extracts the text content from each cell of an HTML table.
#
# @param table [Capybara::Node::Element] The Capybara element representing the HTML table to be processed.
# @return [Array<Array<String>>] A nested array containing the text content of each cell, organized by rows.
def normalize_html_table(table)
table.all('tr').map do |row|
row.all('th,td').map do |cell|
cell.text.strip
end
end
end
# Normalizes markdown table content into a nested array format.
#
# @param markdown_table [String] The markdown table content as a string.
# @return [Array<Array<String>>] A nested array containing the text content
# of each cell, organized by rows.
def normalize_markdown_table(markdown_table)
markdown_table.lines.filter_map do |row|
row.strip.split('|').map(&:strip).drop(1)
end
end
end
end
The last requirement was:
Here is an example of the error message you would get:
actual_table = find_by_id('sales')
expected_table = <<~MARKDOWN
| Date | Sales |
| 2024 Q1 | 999 | # <== Changed from 100 to 999
| 2024 Q2 | 200 |
MARKDOWN
expect_table_content(actual_table, expected_table)
The resulting message:
HTML table parsed to:
| Date | Sales |
| 2024 Q1 | 100 |
| 2024 Q2 | 200 |
Expected:
| Date | Sales |
| 2024 Q1 | 999 |
| 2024 Q2 | 200 |
We’ve seen how to write a custom Rspec matcher to verify the content of a table.
Let me know if you would have done differently or if you have any questions!