blob: 69b9c77fc225c675a778fcce443874ab1f67c385 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""
Framework for DataFrame API golden file tests, analogous to SQLQueryTestSuite for SQL.
A test is described by a ``.test`` file which doubles as the golden file: the
expected outputs are stored inline and rewritten in place when golden files are
regenerated (``SPARK_GENERATE_GOLDEN_FILES=1``).
``.test`` file format::
--! name
__file_metadata__
--! source
df_golden/group_by
!-- end
--! name
range + select + filter + order
--! script
scripts/group_by/range_select.py
--! expected_analysis_output
Sort [k#x ASC NULLS FIRST], true
+- ...
--! expected_optimized_output
...
--! expected_output_schema
struct<k:bigint>
--! expected_result
+---+
| k |
+---+
| 1 |
+---+
printed all 1 rows.
--! expected_result_hash
<sha256 over the result rows>
!-- end
The first block may be named ``__file_metadata__``; its remaining sections
(e.g. ``source``) are file-level metadata, matching the convention used by the
Scala ``SqlHiFiTestRunner`` framework. Each test case references a standalone
Python script (path relative to the ``.test`` file) that is executed with
``spark`` in scope and must assign the DataFrame under test to a variable
named ``df``. Cases run in file order against the same session, so earlier
cases can set up temp views for later ones.
Sections:
- ``name``: human-readable test case name (required).
- ``tags``: optional, whitespace/comma separated. Row order is asserted by
default; add the ``unordered`` tag to sort result rows before comparison for
cases whose result has no deterministic order (aggregate/join/distinct/...
without a global sort).
- ``script``: path to the Python script (required).
- ``expected_analysis_output``: the analyzed logical plan.
- ``expected_optimized_output``: the optimized logical plan.
- ``expected_output_schema``: ``df.schema.simpleString()``.
- ``expected_result``: pretty-printed result table plus a ``printed all N
rows.`` trailer.
- ``expected_result_hash``: sha256 over the (normalized, post-sort) result
rows -- a compact checksum of the same rows rendered in ``expected_result``
(the table is not truncated), co-required with it.
- ``expected_error``: expected error message when analysis or execution fails.
Mutually exclusive with the plan/schema/result sections: as in the SQL golden
suite, any error records only the message and discards the plan and schema.
At comparison time only the ``expected_*`` sections present in the file are
checked, so optional sections (e.g. ``expected_optimized_output``) may be
omitted. Regeneration writes all sections the case produces.
"""
import hashlib
import math
import os
import re
from decimal import Decimal
_CASE_END = "!-- end"
_SECTION_PREFIX = "--! "
_FILE_METADATA_NAME = "__file_metadata__"
# Canonical section order used when (re)generating a ``.test`` file.
_CASE_SECTION_ORDER = [
"name",
"tags",
"script",
"expected_analysis_output",
"expected_optimized_output",
"expected_output_schema",
"expected_result",
"expected_result_hash",
"expected_error",
]
_RESULT_SECTIONS = [s for s in _CASE_SECTION_ORDER if s.startswith("expected_")]
_KNOWN_HEADER_SECTIONS = {"source"}
_KNOWN_TAGS = {"unordered"}
# ---------------------------------------------------------------------------
# .test file parsing / serialization
# ---------------------------------------------------------------------------
def parse_test_file(filepath, require_terminated=False):
"""
Parse a ``.test`` file.
When *require_terminated* is set (verify mode), a file whose last case is
missing its ``!-- end`` terminator is rejected: in verify mode an unclosed
final case is corruption (e.g. a truncating bad merge) that could otherwise
pass by matching a partial case or silently merging two. Regeneration leaves
it unset and stays lenient, since it rewrites the file with terminators.
Returns
-------
header : dict
File-level metadata sections from the ``__file_metadata__`` block
(excluding ``name``), e.g. ``{"source": ...}``.
cases : list[dict]
One dict per test case, mapping section name to content.
"""
with open(filepath, "r") as f:
lines = f.read().split("\n")
cases = []
current = None
section_key = None
section_lines = []
def flush():
nonlocal section_key, section_lines
if section_key is not None and current is not None:
# A repeated section name is a copy/paste or merge mistake; last-wins
# would silently discard one of the two, so fail loudly instead.
assert section_key not in current, "{}: duplicate section `{}`".format(
filepath, section_key
)
current[section_key] = "\n".join(section_lines).strip("\n")
section_key = None
section_lines = []
for line in lines:
stripped = line.rstrip()
if stripped == _CASE_END:
flush()
if current:
cases.append(current)
current = None
elif stripped.startswith(_SECTION_PREFIX):
flush()
if current is None:
current = {}
section_key = stripped[len(_SECTION_PREFIX) :].strip()
elif stripped.startswith("--!"):
# Reaches here only because the space after "--!" is missing, i.e. a
# typo'd section marker. Left as body it would silently turn an
# assertion into inert prose, so reject it.
raise AssertionError(
"{}: malformed section marker (expected `{}`): {!r}".format(
filepath, _SECTION_PREFIX, line
)
)
elif section_key is not None:
section_lines.append(line)
elif stripped:
# Non-blank content outside any section (before the first marker or
# between cases) is dropped by the original loop; that hides stray
# text, so fail loudly. Blank separator lines are fine.
raise AssertionError("{}: content outside any section: {!r}".format(filepath, line))
# A case still open here never hit "!-- end". In verify mode that is
# corruption; under regeneration stay lenient and keep it so the rewrite can
# fix the formatting.
flush()
if current:
assert not require_terminated, (
"{}: file does not end with `{}` (last case is unterminated)".format(
filepath, _CASE_END
)
)
cases.append(current)
header = {}
if cases and cases[0].get("name") == _FILE_METADATA_NAME:
header = cases.pop(0)
del header["name"]
return header, cases
def write_test_file(filepath, header, cases):
"""Serialize *header* and *cases* back into ``.test`` file format."""
blocks = []
if header:
header_lines = [_SECTION_PREFIX + "name", _FILE_METADATA_NAME]
for key, value in header.items():
header_lines.append(_SECTION_PREFIX + key)
header_lines.append(value)
header_lines.append(_CASE_END)
blocks.append("\n".join(header_lines))
for case in cases:
case_lines = []
for key in _CASE_SECTION_ORDER:
value = case.get(key)
if value is not None:
case_lines.append(_SECTION_PREFIX + key)
case_lines.append(value)
case_lines.append(_CASE_END)
blocks.append("\n".join(case_lines))
with open(filepath, "w") as f:
f.write("\n\n\n".join(blocks) + "\n")
def parse_tags(case):
"""Return the set of tags declared on *case*."""
return {tag for tag in re.split(r"[,\s]+", case.get("tags", "")) if tag}
# ---------------------------------------------------------------------------
# Output normalisation (mirrors SQLQueryTestHelper.replaceNotIncludedMsg)
# ---------------------------------------------------------------------------
# Compiled once for performance.
_NORMALIZATION_RULES = [
(re.compile(r"#\d+"), "#x"),
(re.compile(r"plan_id=\d+"), "plan_id=x"),
(re.compile(r"joinId=\d+"), "joinId=x"),
(re.compile(r"repartitionId=\d+"), "repartitionId=x"),
(re.compile(r"uuid\(Some\(-?\d+\)\)"), "uuid(Some(x))"),
(re.compile(r"CTERelationDef \d+,"), "CTERelationDef xxxx,"),
(re.compile(r"CTERelationRef \d+,"), "CTERelationRef xxxx,"),
(re.compile(r"cterelationdef \d+,"), "cterelationdef xxxx,"),
(re.compile(r"cterelationref \d+,"), "cterelationref xxxx,"),
(re.compile(r"UnionLoop \d+"), "UnionLoop xxxx"),
(re.compile(r"UnionLoopRef \d+,"), "UnionLoopRef xxxx,"),
(re.compile(r"Loop id: \d+"), "Loop id: xxxx"),
(re.compile(r"@\w*,"), "@xxxxxxxx,"),
(re.compile(r"\*\(\d+\) "), "*"),
]
def replace_not_included(text):
"""Normalise environment-dependent fragments in *text*."""
for pattern, repl in _NORMALIZATION_RULES:
text = pattern.sub(repl, text)
return text
def format_error(e):
"""
Format an exception message for golden file comparison.
Uses ``str(e)``, which for connect exceptions is the server-side message
(``[ERROR_CLASS] message SQLSTATE: xxxxx``). Stripped to keep the output
deterministic:
- the appended JVM stacktrace;
- the ``== DataFrame ==`` query context block, which embeds the absolute
script path and line number of the DataFrame call (editing a script
comment must not break golden files);
- the trailing logical plan dump (a ``;\\n`` followed by the plan tree);
and expression ids are normalized.
"""
msg = str(e)
msg = msg.split("\n\nJVM stacktrace:")[0]
msg = msg.split("\n== DataFrame ==")[0]
# Drop a trailing logical-plan dump: Spark appends it as ";\n" followed by
# the plan tree, whose root line starts with an (optionally "'"-prefixed)
# uppercase operator name. Anchoring on that lookahead avoids truncating a
# message body that merely contains ";\n" (splitting on the first ";\n"
# unconditionally would lose the remainder of such a message).
msg = re.split(r";\n(?=['A-Z])", msg, maxsplit=1)[0]
return replace_not_included(msg).strip()
# ---------------------------------------------------------------------------
# Plan extraction
# ---------------------------------------------------------------------------
_EXPLAIN_HEADER = re.compile(r"^== .+ ==$", re.MULTILINE)
def _extract_explain_section(explain, marker):
"""
Return the body of the *marker* section of an extended explain output,
ending at the next ``== ... ==`` header.
"""
start = explain.find(marker)
if start < 0:
return None
start = explain.find("\n", start + len(marker))
if start < 0:
return None
start += 1
match = _EXPLAIN_HEADER.search(explain, start)
end = match.start() if match else len(explain)
return explain[start:end].strip("\n")
def get_plan_strings(df):
"""
Return ``(analyzed, optimized)`` normalized logical plan strings.
Uses ``df._explain_string(mode="extended")``, which exists on Spark
Connect only - the framework runs over connect (see ``DFGoldenTestBase``).
Triggers analysis, so analysis errors surface here.
"""
explain = df._explain_string(mode="extended")
analyzed = _extract_explain_section(explain, "== Analyzed Logical Plan ==")
optimized = _extract_explain_section(explain, "== Optimized Logical Plan ==")
if analyzed is None:
raise AssertionError("explain output has no analyzed plan section:\n" + explain)
# When the output schema is non-empty, the analyzed section starts with a
# schema header line (possibly truncated by spark.sql.debug.maxToStringFields).
# The schema has its own golden section, so drop the header by position.
if df.schema.fields:
analyzed = "\n".join(analyzed.split("\n")[1:]).strip("\n")
optimized = replace_not_included(optimized) if optimized is not None else None
return replace_not_included(analyzed), optimized
# ---------------------------------------------------------------------------
# Result formatting
# ---------------------------------------------------------------------------
def format_double(value):
"""
Render *value* (a Python ``float`` from a ``double`` column) exactly as Java
``Double.toString`` does, which is what Hive output uses for numeric types
(``HiveResult.toHiveStringDefault``: ``case (n, _: NumericType) =>
n.toString``). Matching it keeps double results comparable with the SQL
``.sql.out`` goldens; Python's own ``str``/``repr`` differs for special
values (``nan``/``inf``) and for the scientific-notation regime.
Java's rules: ``NaN``/``Infinity``/``-Infinity`` spelled out; a signed
``0.0``; plain decimal (always with a fractional digit) when
``1e-3 <= |x| < 1e7``; otherwise ``d.ddddEexp`` scientific notation with a
single leading digit. The shortest round-tripping digits come from Python's
``repr`` (normalized to drop the artificial trailing zero of values like
``1e7`` -> ``10000000.0``); only their placement is reformatted.
"""
if math.isnan(value):
return "NaN"
if math.isinf(value):
return "Infinity" if value > 0 else "-Infinity"
if value == 0.0:
return "-0.0" if math.copysign(1.0, value) < 0 else "0.0"
sign = "-" if value < 0 else ""
digit_tuple, exp = Decimal(repr(abs(value))).normalize().as_tuple()[1:]
digits = "".join(map(str, digit_tuple))
nd = len(digits)
# Power of ten of the leading significant digit.
leading_exp = exp + nd - 1
if -3 <= leading_exp < 7:
if leading_exp >= 0:
if nd <= leading_exp + 1:
body = digits + "0" * (leading_exp + 1 - nd) + ".0"
else:
body = digits[: leading_exp + 1] + "." + digits[leading_exp + 1 :]
else:
body = "0." + "0" * (-leading_exp - 1) + digits
else:
body = digits[0] + "." + (digits[1:] or "0") + "E" + str(leading_exp)
return sign + body
def _format_value(value, data_type, nested=False):
"""
Format a single cell value for golden file output, mirroring
``HiveResult.toHiveStringDefault`` so values line up with the SQL
``.sql.out`` goldens: structs carry quoted field names, strings are quoted
when nested, and a top-level null (``NULL``) differs from a nested one
(``null``).
"""
from pyspark.sql.types import (
ArrayType,
BinaryType,
BooleanType,
DateType,
DecimalType,
DoubleType,
FloatType,
MapType,
StringType,
StructType,
TimestampNTZType,
TimestampType,
)
if value is None:
return "null" if nested else "NULL"
if isinstance(data_type, BooleanType):
return "true" if value else "false"
if isinstance(data_type, StringType):
# A tab or newline in a cell would desync the rendered table (cells are
# tab-joined and re-split, the file is newline-delimited) while the hash
# stayed self-consistent, so --verify could not catch the misrender.
# Refuse loudly rather than bake a corrupt golden; add escaping with the
# first case that legitimately needs such a value.
if "\t" in value or "\n" in value:
raise AssertionError(
"df_golden: result string contains a tab or newline, which is "
"not supported yet (would desync the rendered table): {!r}".format(value)
)
return '"' + value + '"' if nested else value
if isinstance(data_type, DecimalType):
# BigDecimal.toPlainString: never scientific notation, scale preserved.
return format(value, "f")
if isinstance(data_type, DoubleType):
return format_double(value)
if isinstance(data_type, StructType):
parts = [
'"{}":{}'.format(f.name, _format_value(value[i], f.dataType, nested=True))
for i, f in enumerate(data_type.fields)
]
return "{" + ",".join(parts) + "}"
if isinstance(data_type, ArrayType):
parts = [_format_value(v, data_type.elementType, nested=True) for v in value]
return "[" + ",".join(parts) + "]"
if isinstance(data_type, MapType):
parts = [
_format_value(k, data_type.keyType, nested=True)
+ ":"
+ _format_value(v, data_type.valueType, nested=True)
for k, v in value.items()
]
# Hive sorts map entries by their rendered string, not by key.
return "{" + ",".join(sorted(parts)) + "}"
# These types have no faithful ``str()`` rendering and must not fall through
# to the generic branch below:
# - float: Python's repr is the double-precision shortest form, not Java
# ``Float.toString``'s float32-shortest form, so str() would diverge.
# (``double`` is handled above via ``format_double``; ``float`` waits for
# the first float-column case, which needs float32-shortest rendering.)
# - temporal/binary: need a Hive-style formatter and (for LTZ timestamps) a
# pinned session time zone this framework does not set up yet.
# Refuse them loudly rather than silently emit a wrong/non-deterministic
# value; add real formatting together with the first such test case.
if isinstance(
data_type,
(FloatType, DateType, TimestampType, TimestampNTZType, BinaryType),
):
raise AssertionError(
"df_golden: result column of type {} is not supported yet (needs a "
"Hive-style formatter)".format(data_type.simpleString())
)
return str(value)
def get_result_rows(df):
"""
Collect *df* and format each row as a tab-separated string matching hive
output conventions (``NULL`` for None, lowercase booleans, etc.).
Cells are joined with ``\\t`` and later re-split on ``\\t`` by
``render_result_table``, and the ``.test`` format is newline-delimited, so a
literal tab or newline inside a string value would desync the rendered table
while the hash stayed self-consistent (``--verify`` could not flag it).
``_format_value`` therefore rejects such strings loudly rather than let a
corrupt golden through.
"""
schema = df.schema
return [
"\t".join(_format_value(row[i], field.dataType) for i, field in enumerate(schema.fields))
for row in df.collect()
]
def render_result_table(columns, rows):
"""
Render *rows* (tab-separated strings) as a pretty-printed table::
+----+----+
| c1 | c2 |
+----+----+
| 1 | 10 |
+----+----+
printed all 1 rows.
"""
trailer = "printed all {} rows.".format(len(rows))
if not columns:
return trailer
cells = [r.split("\t") for r in rows]
widths = [len(c) for c in columns]
for row_cells in cells:
for i, cell in enumerate(row_cells[: len(widths)]):
widths[i] = max(widths[i], len(cell))
border = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
def fmt(values):
padded = [v.ljust(w) for v, w in zip(values, widths)]
return "| " + " | ".join(padded) + " |"
lines = [border, fmt(columns), border]
lines.extend(fmt(row_cells) for row_cells in cells)
lines.append(border)
lines.append(trailer)
return "\n".join(lines)
def hash_result_rows(rows):
"""sha256 over the normalized result rows; verifies the full result."""
return hashlib.sha256("\n".join(rows).encode("utf-8")).hexdigest()
# ---------------------------------------------------------------------------
# Test execution engine
# ---------------------------------------------------------------------------
def run_script(spark, script_path):
"""
Execute the test case script and return the DataFrame it assigns to ``df``.
The script runs with ``spark`` in scope and is responsible for its own
imports.
"""
with open(script_path, "r") as f:
code = f.read()
namespace = {"spark": spark}
exec(compile(code, script_path, "exec"), namespace)
if "df" not in namespace:
raise AssertionError(
"Test script {} must assign a DataFrame to a variable named `df`".format(script_path)
)
return namespace["df"]
def compute_case_outputs(spark, case, base_dir):
"""
Run a single test case and return a dict of actual ``expected_*`` sections.
"""
from pyspark.errors import PySparkException
tags = parse_tags(case)
script_path = os.path.join(base_dir, case["script"])
# Only Spark errors are legitimate expected outputs. Anything else
# (NameError, ImportError, ... from a buggy script) must fail the test;
# capturing it would write the Python error into the golden file as the
# expected output on regeneration.
try:
df = run_script(spark, script_path)
analyzed, optimized = get_plan_strings(df)
schema = df.schema.simpleString()
except PySparkException as e:
return {"expected_error": format_error(e)}
actual = {
"expected_analysis_output": analyzed,
"expected_output_schema": schema,
}
if optimized is not None:
actual["expected_optimized_output"] = optimized
try:
rows = get_result_rows(df)
except PySparkException as e:
# Match the SQL golden suite: on any error keep only the message and
# discard the analyzed plan / schema captured before execution.
return {"expected_error": format_error(e)}
rows = [replace_not_included(r) for r in rows]
# Sort the rows only when the case is explicitly tagged ``unordered``. Row
# order is asserted by default; a case whose result has no deterministic
# order (aggregate/join/distinct/... without a global sort) must opt out via
# the tag. Deriving orderedness from the rendered plan text was rejected as
# too loose: it silently sorts genuinely order-sensitive results, hiding real
# ordering regressions from the golden.
if "unordered" in tags:
rows = sorted(rows)
actual["expected_result"] = render_result_table(df.columns, rows)
actual["expected_result_hash"] = hash_result_rows(rows)
return actual
def _validate_test_file(test_file, header, cases, regenerate):
"""
Fail loudly on malformed ``.test`` content. A misspelled section or tag
that is silently ignored makes a case assert less than it appears to (or
nothing at all), so unknown names are errors, not noise.
"""
unknown_header = set(header) - _KNOWN_HEADER_SECTIONS
assert not unknown_header, "{}: unknown header sections: {}".format(
test_file, ", ".join(sorted(unknown_header))
)
assert cases, "{}: no test cases found".format(test_file)
for case in cases:
assert case.get("name"), "{}: every test case needs a name".format(test_file)
name = case["name"]
assert case.get("script"), "{}: case `{}` needs a script".format(test_file, name)
# Unknown sections are dropped and rewritten by regeneration, so only
# reject them in verify mode. Enforcing this during regeneration would
# block the very migration regeneration exists to perform: a section
# renamed or removed in the framework (e.g. the old
# ``expected_analysis_error``/``expected_execution_error`` split folded
# into ``expected_error``) leaves the on-disk file carrying a name no
# longer in ``_CASE_SECTION_ORDER`` until it is regenerated.
if not regenerate:
unknown = set(case) - set(_CASE_SECTION_ORDER)
assert not unknown, "{}: case `{}` has unknown sections: {}".format(
test_file, name, ", ".join(sorted(unknown))
)
# Tags are preserved verbatim across regeneration, so an unknown tag
# would persist; reject it in both modes.
unknown_tags = parse_tags(case) - _KNOWN_TAGS
assert not unknown_tags, "{}: case `{}` has unknown tags: {}".format(
test_file, name, ", ".join(sorted(unknown_tags))
)
# In regenerate mode new cases legitimately have no expected_*
# sections yet; in verify mode such a case would pass vacuously.
if not regenerate:
assert any(case.get(key) is not None for key in _RESULT_SECTIONS), (
"{}: case `{}` has no expected_* sections and would assert "
"nothing; regenerate the golden files".format(test_file, name)
)
# ``_compare_case`` only checks sections present in the file, so a
# dropped section (merge/manual edit) silently shrinks coverage
# without failing. Pin down what a well-formed case must look like:
has_error = case.get("expected_error") is not None
has_result = case.get("expected_result") is not None
has_hash = case.get("expected_result_hash") is not None
if has_error:
# An error case records only the error (the run discards plan,
# schema and result on failure); anything else is a corrupt file.
conflicting = sorted(
key
for key in _RESULT_SECTIONS
if key != "expected_error" and case.get(key) is not None
)
assert not conflicting, (
"{}: error case `{}` must carry only `expected_error`, not also: {}".format(
test_file, name, ", ".join(conflicting)
)
)
else:
# The result table and its hash are a pair; dropping one leaves
# the other asserting half the result, so require both or neither.
assert has_result == has_hash, (
"{}: case `{}` must have both `expected_result` and "
"`expected_result_hash` or neither".format(test_file, name)
)
def run_golden_test(test_case, spark, test_file):
"""
Run all cases of a ``.test`` file.
Parameters
----------
test_case : unittest.TestCase
The test case instance (for assertions).
spark : SparkSession
The session to run against. The caller provides a fresh session per
``.test`` file (the connect counterpart of ``SQLQueryTestSuite``'s
per-file ``newSession()``), so state created by case scripts - temp
views, UDFs, confs - is discarded with the session and cannot leak
into other files.
test_file : str
Absolute path to the ``.test`` file.
"""
regenerate = os.environ.get("SPARK_GENERATE_GOLDEN_FILES") is not None
base_dir = os.path.dirname(test_file)
header, cases = parse_test_file(test_file, require_terminated=not regenerate)
_validate_test_file(test_file, header, cases, regenerate)
# Golden files are generated with ANSI mode on, matching the SQL golden
# tests. The session is discarded after the file, so nothing to restore.
spark.conf.set("spark.sql.ansi.enabled", "true")
regenerated_cases = []
for case in cases:
actual = compute_case_outputs(spark, case, base_dir)
if regenerate:
regenerated_cases.append(_regenerate_case(case, actual))
else:
_compare_case(test_case, case, actual)
if regenerate:
write_test_file(test_file, header, regenerated_cases)
def _regenerate_case(old_case, actual):
"""
Build the regenerated form of *old_case* from this run's *actual* outputs.
Every populated ``expected_*`` section from this run replaces the on-disk
value; ``name`` / ``tags`` / ``script`` are carried over unchanged so the
case's identity, ordering guard, and script pointer survive regeneration.
"""
carried = {key: old_case.get(key) for key in ("name", "tags", "script")}
carried.update(actual)
return carried
def _compare_case(test_case, case, actual):
"""Compare the ``expected_*`` sections of *case* against *actual*."""
name = case["name"]
for key in _RESULT_SECTIONS:
expected = case.get(key)
if expected is None:
continue
got = actual.get(key)
if got is None:
produced = ", ".join(sorted(actual)) or "<nothing>"
test_case.fail(
"[{}] expected section `{}` but the case produced: {}".format(name, key, produced)
)
test_case.assertEqual(
expected.strip("\n"),
got.strip("\n"),
"[{}] mismatch in `{}`".format(name, key),
)