Skip to content

Add fluent scoped HTML assertions via assert_section(heading, callback) #10

@othercodes

Description

@othercodes

Problem

Every assertion in HTMLContentAssertionsMixin (assert_see, assert_dont_see, assert_see_text, assert_dont_see_text, assert_see_in_order) is page-global — it searches the entire decoded response body. There is no way to scope an assertion to a specific section of the HTML.

This breaks down on any response that contains:

  • Multiple <table> elements where you want to assert on rows or content of one specific table.
  • Sections where the same value (an email, an ID, a label) legitimately appears in more than one place, so assert_see / assert_dont_see cannot distinguish them.
  • Paginated or truncated tables where the test needs to assert the rendered row count rather than just "this string is somewhere on the page".
  • Nested sections where assertions on a child should not match content from a sibling.

The current workaround is raw substring slicing on response.content, which is not fluent, not chainable, not discoverable in IDEs, and reinvented in every test that needs it.

Requirements

A scoped HTML assertion API for pyssertive should:

  1. Be fluent and chainable.
  2. Reuse all existing HTML assertions inside the scope — no parallel API.
  3. Bound the scope by closure, like Laravel's fluent JSON testing. No explicit cleanup, no leaked state.
  4. Return to the outer page scope after the closure exits, so the outer chain can continue with page-global assertions.
  5. Support nesting — a section's assertions should themselves be able to open a sub-section.
  6. Add at least one section-specific assertion that page-global assertions cannot express, such as counting <tr> rows inside the scoped table.
  7. Expose the scoped object as a real class with IDE auto-completion and proper typing.

Proposed API

client.get(url).assert_ok().assert_section(
    "Section Heading Text",
    lambda section: (
        section
        .assert_see_text("Expected text inside the section")
        .assert_row_count(100)
        .assert_dont_see_text("Text that must not appear inside the section")
    ),
).assert_see_text("Text outside the section, on the outer page")

The closure receives a scoped object that exposes the same HTML assertions as the outer client, but bound to the section slice. After the closure returns, assert_section returns the outer fluent client so the chain continues at page scope.

Code draft

class HTMLContentAssertionsMixin:
    _response: HttpResponse

    def _get_content(self) -> str:
        return self._response.content.decode("utf-8", errors="replace")

    def assert_see(self, text: str) -> Self:
        body = html.unescape(self._get_content())
        body = re.sub(r"\s+", " ", body).strip()
        assert text in body, f"Expected to see '{text}', got: {body}"
        return self

    # ... existing assertions refactored to use _get_content() instead of
    # decoding self._response.content directly.

    def assert_section(
        self,
        heading: str,
        callback: Callable[["HtmlSection"], Any],
        *,
        end_marker: str = "</table>",
    ) -> Self:
        content = self._get_content()
        start = content.find(heading)
        assert start != -1, f"Section heading '{heading}' not found in response"
        end = content.find(end_marker, start)
        assert end != -1, f"End marker '{end_marker}' not found after section '{heading}'"
        section_html = content[start : end + len(end_marker)]
        callback(HtmlSection(section_html, heading))
        return self


class HtmlSection(HTMLContentAssertionsMixin):
    """Scoped HTML assertions over a slice of a response. Created by assert_section."""

    def __init__(self, html_content: str, heading: str) -> None:
        self._html = html_content
        self._heading = heading

    def _get_content(self) -> str:
        return self._html

    def assert_row_count(self, expected: int) -> Self:
        actual = self._html.count("<tr>")
        assert actual == expected, (
            f"Expected {expected} <tr> rows in section '{self._heading}', got {actual}"
        )
        return self

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions