Working on LoginPage tests

This commit is contained in:
2025-10-26 22:45:34 +01:00
parent b98e52378e
commit 09d012d065
7 changed files with 900 additions and 320 deletions

View File

@@ -14,5 +14,9 @@ clean-build: clean-package
find . -name "*.pyc" -exec rm -f {} + find . -name "*.pyc" -exec rm -f {} +
find . -name "*.pyo" -exec rm -f {} + find . -name "*.pyo" -exec rm -f {} +
clean-tests:
rm -rf tests/.sesskey
rm -rf tests/Users.db
# Alias to clean everything # Alias to clean everything
clean: clean-build clean: clean-build clean-tests

View File

@@ -18,7 +18,7 @@ from ..auth.utils import (
) )
def setup_auth_routes(app, rt, mount_auth_app=True): def setup_auth_routes(app, rt, mount_auth_app=True, sqlite_db_path="Users.db"):
""" """
Setup all authentication and protected routes. Setup all authentication and protected routes.
@@ -26,6 +26,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
app: FastHTML application instance app: FastHTML application instance
rt: Route decorator from FastHTML rt: Route decorator from FastHTML
mount_auth_app: Whether to mount the auth FastApi API routes mount_auth_app: Whether to mount the auth FastApi API routes
sqlite_db_path: by default, create a new SQLite database at this path
""" """
# ============================================================================ # ============================================================================
@@ -187,7 +188,7 @@ def setup_auth_routes(app, rt, mount_auth_app=True):
def mount_auth_fastapi_api(): def mount_auth_fastapi_api():
# Mount FastAPI auth backend # Mount FastAPI auth backend
auth_api = create_auth_app_for_sqlite("Users.db", "jwt-secret-to-change") auth_api = create_auth_app_for_sqlite(sqlite_db_path, "jwt-secret-to-change")
app.mount("/auth", auth_api) app.mount("/auth", auth_api)
if mount_auth_app: if mount_auth_app:

View File

@@ -27,8 +27,10 @@ DEFAULT_SKIP_PATTERNS = [
r'.*\.css', r'.*\.css',
r'.*\.js', r'.*\.js',
'/login', '/login',
'/login2', '/login-p',
'/register', '/register',
'/register-p',
'/logout',
] ]

View File

@@ -2,6 +2,7 @@ import dataclasses
import json import json
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Self
from bs4 import BeautifulSoup, Tag from bs4 import BeautifulSoup, Tag
from fastcore.xml import FT, to_xml from fastcore.xml import FT, to_xml
@@ -132,7 +133,10 @@ class TestableElement:
def _support_htmx(self): def _support_htmx(self):
"""Check if the element supports HTMX.""" """Check if the element supports HTMX."""
return self.ft.has_attr('hx_get') or self.ft.has_attr('hx_post') return ('hx_get' in self.ft.attrs or
'hx-get' in self.ft.attrs or
'hx_post' in self.ft.attrs or
'hx-post' in self.ft.attrs)
class TestableForm(TestableElement): class TestableForm(TestableElement):
@@ -149,7 +153,7 @@ class TestableForm(TestableElement):
source: The source HTML string containing a form. source: The source HTML string containing a form.
""" """
super().__init__(client, source) super().__init__(client, source)
self.form = BeautifulSoup(source, 'html.parser').find('form') self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
self.fields_mapping = {} # link between the input label and the input name self.fields_mapping = {} # link between the input label and the input name
self.fields = {} # field name; field value self.fields = {} # field name; field value
self.select_fields = {} # list of possible options for 'select' input fields self.select_fields = {} # list of possible options for 'select' input fields
@@ -236,7 +240,7 @@ class TestableForm(TestableElement):
elif options: elif options:
self.fields[name] = options[0]['value'] self.fields[name] = options[0]['value']
def fill_form(self, **kwargs): def fill(self, **kwargs):
""" """
Fill the form with the given data. Fill the form with the given data.
@@ -244,17 +248,56 @@ class TestableForm(TestableElement):
**kwargs: Field names and their values to fill in the form. **kwargs: Field names and their values to fill in the form.
""" """
for name, value in kwargs.items(): for name, value in kwargs.items():
self.fields[name] = value field_name = self.translate(name)
if field_name not in self.fields:
raise ValueError(f"Invalid field name '{name}'.")
self.fields[self.translate(name)] = value
def submit(self): def submit(self):
""" """
Submit the form. Submit the form.
This method handles both HTMX-enabled forms and classic HTML form submissions:
- If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
- Otherwise, simulates a classic browser form submission using the form's
action and method attributes
Returns: Returns:
The response from the form submission. The response from the form submission.
Raises:
ValueError: If the form has no action attribute for classic submission.
""" """
# Check if the form supports HTMX
if self._support_htmx():
return self._send_htmx_request(data=self.fields) return self._send_htmx_request(data=self.fields)
# Classic form submission
action = self.form.get('action')
if not action or action.strip() == '':
raise ValueError(
"The form has no 'action' attribute. "
"Cannot submit a classic form without a target URL."
)
method = self.form.get('method', 'post').upper()
# Prepare headers for classic form submission
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# Send the request via the client
return self.client.send_request(
method=method,
url=action,
headers=headers,
data=self.fields
)
def translate(self, field):
return self.fields_mapping.get(field, field)
def _update_fields_mapping(self): def _update_fields_mapping(self):
""" """
Build a mapping between label text and input field names. Build a mapping between label text and input field names.
@@ -459,7 +502,7 @@ class MyTestClient:
# make sure that the commands are mounted # make sure that the commands are mounted
mount_commands(self.app) mount_commands(self.app)
def open(self, path: str): def open(self, path: str) -> Self:
""" """
Open a page and store its content for subsequent assertions. Open a page and store its content for subsequent assertions.
@@ -483,7 +526,9 @@ class MyTestClient:
return self return self
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None): def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
if json_data is not None:
json_data['session'] = self._session json_data['session'] = self._session
res = self.client.request( res = self.client.request(
method, method,
url, url,
@@ -500,7 +545,7 @@ class MyTestClient:
self.set_content(res.text) self.set_content(res.text)
return self return self
def should_see(self, text: str): def should_see(self, text: str) -> Self:
""" """
Assert that the given text is present in the visible page content. Assert that the given text is present in the visible page content.
@@ -517,6 +562,11 @@ class MyTestClient:
AssertionError: If the text is not found in the page content. AssertionError: If the text is not found in the page content.
ValueError: If no page has been opened yet. ValueError: If no page has been opened yet.
""" """
def clean_text(txt):
return "\n".join(line for line in txt.splitlines() if line.strip())
if self._content is None: if self._content is None:
raise ValueError( raise ValueError(
"No page content available. Call open() before should_see()." "No page content available. Call open() before should_see()."
@@ -527,7 +577,7 @@ class MyTestClient:
if text not in visible_text: if text not in visible_text:
# Provide a snippet of the actual content for debugging # Provide a snippet of the actual content for debugging
snippet_length = 200 snippet_length = 200
content_snippet = ( content_snippet = clean_text(
visible_text[:snippet_length] + "..." visible_text[:snippet_length] + "..."
if len(visible_text) > snippet_length if len(visible_text) > snippet_length
else visible_text else visible_text
@@ -539,7 +589,7 @@ class MyTestClient:
return self return self
def should_not_see(self, text: str): def should_not_see(self, text: str) -> Self:
""" """
Assert that the given text is NOT present in the visible page content. Assert that the given text is NOT present in the visible page content.
@@ -582,7 +632,7 @@ class MyTestClient:
return self return self
def find_element(self, selector: str): def find_element(self, selector: str) -> TestableElement:
""" """
Find a single HTML element using a CSS selector. Find a single HTML element using a CSS selector.
@@ -621,7 +671,7 @@ class MyTestClient:
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1." f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
) )
def find_form(self, fields: list = None): def find_form(self, fields: list = None) -> TestableForm:
""" """
Find a form element in the page content. Find a form element in the page content.
Can provide title of the fields to ease the search Can provide title of the fields to ease the search
@@ -630,22 +680,29 @@ class MyTestClient:
""" """
if self._content is None: if self._content is None:
raise ValueError( raise ValueError(
"No page content available. Call open() before find_element()." "No page content available. Call open() before find_form()."
) )
results = self._soup.select("form") results = self._soup.select("form")
if len(results) == 0: if len(results) == 0:
raise AssertionError( raise AssertionError(
f"No element form found." f"No form found."
) )
# result = _filter(results, fields) if fields is None:
remaining = [TestableForm(self, form) for form in results]
else:
remaining = []
for form in results:
testable_form = TestableForm(self, form)
if all(testable_form.translate(field) in testable_form.fields for field in fields):
remaining.append(testable_form)
if len(results) == 1: if len(remaining) == 1:
return TestableForm(self, results[0]) return remaining[0]
else: else:
raise AssertionError( raise AssertionError(
f"Found {len(results)} forms (with the specified fields). Expected exactly 1." f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1."
) )
def get_content(self) -> str: def get_content(self) -> str:
@@ -657,7 +714,7 @@ class MyTestClient:
""" """
return self._content return self._content
def set_content(self, content: str): def set_content(self, content: str) -> Self:
""" """
Set the HTML content and parse it with BeautifulSoup. Set the HTML content and parse it with BeautifulSoup.
@@ -666,6 +723,7 @@ class MyTestClient:
""" """
self._content = content self._content = content
self._soup = BeautifulSoup(content, 'html.parser') self._soup = BeautifulSoup(content, 'html.parser')
return self
@staticmethod @staticmethod
def _find_visible_text_element(soup, text: str): def _find_visible_text_element(soup, text: str):

View File

@@ -1,8 +1,10 @@
import os
import pytest import pytest
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.auth.routes import setup_auth_routes from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware from myfasthtml.auth.utils import create_auth_beforeware, register_user
from myfasthtml.core.testclient import MyTestClient from myfasthtml.core.testclient import MyTestClient
@@ -10,7 +12,7 @@ from myfasthtml.core.testclient import MyTestClient
def app(): def app():
beforeware = create_auth_beforeware() beforeware = create_auth_beforeware()
test_app, test_rt = fast_app(before=beforeware) test_app, test_rt = fast_app(before=beforeware)
setup_auth_routes(test_app, test_rt) setup_auth_routes(test_app, test_rt, mount_auth_app=True, sqlite_db_path="TestUsers.db")
return test_app return test_app
@@ -24,16 +26,34 @@ def user(app):
user = MyTestClient(app) user = MyTestClient(app)
return user return user
@pytest.fixture(autouse=True)
def cleanup():
if os.path.exists("TestUsers.db"):
os.remove("TestUsers.db")
def test_i_can_see_login_page(user): def test_i_can_see_login_page(user):
user.open("/login") user.open("/login")
user.should_see("Sign In") user.should_see("Sign In")
user.should_see("Register here") user.should_see("Register here")
user.find_form(fields=["Email", "Password"])
def test_i_cannot_login_with_wrong_credentials(user): def test_i_cannot_login_with_wrong_credentials(user):
user.open("/login") user.open("/login")
user.fill_form({ form = user.find_form(fields=["Email", "Password"])
"username": "wrong", form.fill(Email="user@email.com", Password="#Passw0rd")
"password": "" form.submit()
}) user.should_see("Invalid email or password. Please try again.")
user.click("button")
user.should_see("Invalid credentials")
def test_i_can_login_with_correct_credentials(user):
# create user
register_user("user@email.com", "user", "#Passw0rd")
user.open("/login")
form = user.find_form(fields=["Email", "Password"])
form.fill(Email="user@email.com", Password="#Passw0rd")
form.submit()
user.should_see("You are now logged in")

View File

@@ -2,10 +2,12 @@ import pytest
from fasthtml.components import Div from fasthtml.components import Div
from fasthtml.fastapp import fast_app from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
def test_i_can_open_a_page(): class TestMyTestClientOpen:
def test_i_can_open_a_page(self):
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -16,8 +18,7 @@ def test_i_can_open_a_page():
assert client.get_content() == "hello world" assert client.get_content() == "hello world"
def test_i_can_open_a_page_when_html(self):
def test_i_can_open_a_page_when_html():
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -28,8 +29,7 @@ def test_i_can_open_a_page_when_html():
assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n' assert client.get_content() == ' <!doctype html>\n <html>\n <head>\n <title>FastHTML page</title>\n <link rel="canonical" href="http://testserver/">\n </head>\n <body>\n <div>hello world</div>\n </body>\n </html>\n'
def test_i_cannot_open_a_page_not_defined(self):
def test_i_cannot_open_a_page_not_defined():
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -38,7 +38,9 @@ def test_i_cannot_open_a_page_not_defined():
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'" assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
def test_i_can_see_text_in_plain_response():
class TestMyTestClientShouldSee:
def test_i_can_see_text_in_plain_response(self):
"""Test that should_see() works with plain text responses.""" """Test that should_see() works with plain text responses."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -49,8 +51,7 @@ def test_i_can_see_text_in_plain_response():
client.open("/").should_see("hello world") client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response(self):
def test_i_can_see_text_in_html_response():
"""Test that should_see() extracts visible text from HTML responses.""" """Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -61,8 +62,7 @@ def test_i_can_see_text_in_html_response():
client.open("/").should_see("Welcome").should_see("This is a test") client.open("/").should_see("Welcome").should_see("This is a test")
def test_i_can_see_text_ignoring_html_tags(self):
def test_i_can_see_text_ignoring_html_tags():
"""Test that should_see() searches in visible text only, not in HTML tags.""" """Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -80,8 +80,7 @@ def test_i_can_see_text_ignoring_html_tags():
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value) assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
def test_i_cannot_see_text_that_is_not_present(self):
def test_i_cannot_see_text_that_is_not_present():
"""Test that should_see() raises AssertionError when text is not found.""" """Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -96,8 +95,7 @@ def test_i_cannot_see_text_that_is_not_present():
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value) assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value)
assert "hello world" in str(exc_info.value) assert "hello world" in str(exc_info.value)
def test_i_cannot_call_should_see_without_opening_page(self):
def test_i_cannot_call_should_see_without_opening_page():
"""Test that should_see() raises ValueError if no page has been opened.""" """Test that should_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -107,8 +105,7 @@ def test_i_cannot_call_should_see_without_opening_page():
assert str(exc_info.value) == "No page content available. Call open() before should_see()." assert str(exc_info.value) == "No page content available. Call open() before should_see()."
def test_i_can_verify_text_is_not_present(self):
def test_i_can_verify_text_is_not_present():
"""Test that should_not_see() works when text is absent.""" """Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -119,8 +116,7 @@ def test_i_can_verify_text_is_not_present():
client.open("/").should_not_see("goodbye") client.open("/").should_not_see("goodbye")
def test_i_cannot_use_should_not_see_when_text_is_present(self):
def test_i_cannot_use_should_not_see_when_text_is_present():
"""Test that should_not_see() raises AssertionError when text is found.""" """Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -135,8 +131,7 @@ def test_i_cannot_use_should_not_see_when_text_is_present():
error_message = str(exc_info.value) error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message assert "Expected NOT to see 'hello' in page content but it was found" in error_message
def test_i_cannot_call_should_not_see_without_opening_page(self):
def test_i_cannot_call_should_not_see_without_opening_page():
"""Test that should_not_see() raises ValueError if no page has been opened.""" """Test that should_not_see() raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -146,8 +141,7 @@ def test_i_cannot_call_should_not_see_without_opening_page():
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()." assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions(self):
def test_i_can_chain_multiple_assertions():
"""Test that assertions can be chained together.""" """Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -159,8 +153,7 @@ def test_i_can_chain_multiple_assertions():
# Chain multiple assertions # Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error") client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
def test_i_can_see_element_context_when_text_should_not_be_seen(self):
def test_i_can_see_element_context_when_text_should_not_be_seen():
"""Test that the HTML element containing the text is displayed with parent context.""" """Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -177,8 +170,7 @@ def test_i_can_see_element_context_when_text_should_not_be_seen():
assert '<p class="content">forbidden text</p>' in error_message assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor(self):
def test_i_can_configure_parent_levels_in_constructor():
"""Test that parent_levels parameter controls the number of parent levels shown.""" """Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2) client = MyTestClient(test_app, parent_levels=2)
@@ -195,8 +187,7 @@ def test_i_can_configure_parent_levels_in_constructor():
assert '<div class="container">' in error_message assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message assert '<div class="wrapper">' in error_message
def test_i_can_find_text_in_nested_elements(self):
def test_i_can_find_text_in_nested_elements():
"""Test that the smallest element containing the text is found in nested structures.""" """Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -212,8 +203,7 @@ def test_i_can_find_text_in_nested_elements():
# Should find the <p> element, not the outer <div> # Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message assert '<p class="target">nested text</p>' in error_message
def test_i_can_find_fragmented_text_across_tags(self):
def test_i_can_find_fragmented_text_across_tags():
"""Test that text fragmented across multiple tags is correctly found.""" """Test that text fragmented across multiple tags is correctly found."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -229,8 +219,7 @@ def test_i_can_find_fragmented_text_across_tags():
# Should find the parent <p> element that contains the full text # Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes(self):
def test_i_do_not_find_text_in_html_attributes():
"""Test that text in HTML attributes is not considered as visible text.""" """Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -247,7 +236,9 @@ def test_i_do_not_find_text_in_html_attributes():
client.should_not_see("Success") client.should_not_see("Success")
@pytest.mark.parametrize("selector,expected_tag", [ class TestMyTestClientFindElement:
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'), ("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'), (".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'), ("div > div", '<div class="content"'),
@@ -258,8 +249,8 @@ def test_i_do_not_find_text_in_html_attributes():
('[href^="/home"]', '<a class="link home-link"'), ('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'), ('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'), ('[data-author*="john"]', '<a class="link home-link"'),
]) ])
def test_i_can_find_element(selector, expected_tag): def test_i_can_find_element(self, selector, expected_tag):
"""Test that find_element works with various CSS selectors.""" """Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -282,8 +273,7 @@ def test_i_can_find_element(selector, expected_tag):
assert isinstance(element, TestableElement) assert isinstance(element, TestableElement)
assert expected_tag in element.html_fragment assert expected_tag in element.html_fragment
def test_i_cannot_find_element_when_none_exists(self):
def test_i_cannot_find_element_when_none_exists():
"""Test that find_element raises AssertionError when no element matches.""" """Test that find_element raises AssertionError when no element matches."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -297,8 +287,7 @@ def test_i_cannot_find_element_when_none_exists():
assert "No element found matching selector '#non-existent'" in str(exc_info.value) assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist(self):
def test_i_cannot_find_element_when_multiple_exist():
"""Test that find_element raises AssertionError when multiple elements match.""" """Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -314,8 +303,7 @@ def test_i_cannot_find_element_when_multiple_exist():
assert "Found 3 elements matching selector '.text'" in error_message assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page(self):
def test_i_cannot_call_find_element_without_opening_page():
"""Test that find_element raises ValueError if no page has been opened.""" """Test that find_element raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False) test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app) client = MyTestClient(test_app)
@@ -324,3 +312,173 @@ def test_i_cannot_call_find_element_without_opening_page():
client.find_element("#any-selector") client.find_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()." assert str(exc_info.value) == "No page content available. Call open() before find_element()."
class TestMyTestClientFindForm:
def test_i_can_find_form(self):
"""Test that find_form works fo simple form."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''<form></form>'''
form = client.open("/").find_form()
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_in_nested_elements(self):
"""Test that find_form works when form is nested in other elements."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<div class="wrapper">
<form></form>
</div>
</div>
'''
form = client.open("/").find_form()
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_all_fields(self):
"""Test that find_form works when form has fields."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["username", "password"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_label(self):
"""Test that find_form works when form has fields."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<label for="username">Username</label>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["Username", "password"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_can_find_form_with_one_field(self):
"""Test that find_form works when form has at least one field."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
'''
form = client.open("/").find_form(fields=["username"])
assert form is not None
assert isinstance(form, TestableForm)
def test_i_cannot_find_element_when_none_exists(self):
"""Test that find_form raises AssertionError when no form found."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form()
assert "No form found" in str(exc_info.value)
def test_i_cannot_call_find_form_without_opening_page(self):
"""Test that find_form raises ValueError if no page has been opened."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.find_form()
assert str(exc_info.value) == "No page content available. Call open() before find_form()."
def test_cannot_find_form_when_multiple_exist(self):
"""Test that find_form raises AssertionError when multiple forms match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<form></form>
<form></form>
</div>
'''
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form()
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message
def test_cannot_find_form_with_fields_when_multiple_exist(self):
"""Test that find_form raises AssertionError when multiple forms match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div class="container">
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
<form>
<input type="text" name="username" placeholder="Username">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign In</button>
</form>
</div>
'''
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_form(fields=["username", "password"])
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -1,6 +1,7 @@
import pytest import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import TestableForm from myfasthtml.core.testclient import TestableForm, MyTestClient
@pytest.fixture @pytest.fixture
@@ -583,3 +584,339 @@ class TestableFormUpdateFieldValues:
f"Expected {{'flag': True}}, got {form.fields}" f"Expected {{'flag': True}}, got {form.fields}"
assert isinstance(form.fields["flag"], bool), \ assert isinstance(form.fields["flag"], bool), \
f"Expected bool type, got {type(form.fields['flag'])}" f"Expected bool type, got {type(form.fields['flag'])}"
class TestMyTestClientFill:
def test_i_can_fill_form_using_input_name(self, mock_client):
"""
I can fill using the input name
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
form.fill(username="john_doe")
assert form.fields == {"username": "john_doe"}
def test_i_can_fill_form_using_label(self, mock_client):
"""
I can fill using the label associated with the input
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
form.fill(Username="john_doe")
assert form.fields == {"username": "john_doe"}
def test_i_cannot_fill_form_with_invalid_field_name(self, mock_client):
"""
I cannot fill form with invalid field name
"""
html = '<form><label for="uid">Username</label><input id="uid" name="username" /></form>'
form = TestableForm(mock_client, html)
with pytest.raises(ValueError) as excinfo:
form.fill(invalid_field="john_doe")
assert str(excinfo.value) == "Invalid field name 'invalid_field'."
class TestableFormSubmit:
"""
Test suite for the submit() method of TestableForm class.
This module tests form submission for both HTMX and classic forms.
"""
def test_i_can_submit_classic_form_with_post_method(self):
"""
Test that a classic form with POST method is submitted correctly.
This ensures the form uses the action and method attributes properly.
"""
# HTML form with classic submission
html = '''
<form action="/submit" method="post">
<input type="text" name="username" value="john_doe" />
<input type="password" name="password" value="secret123" />
</form>
'''
# Create the form
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(username: str, password: str):
return f"Form received {username=}, {password=}"
form.submit()
assert client.get_content() == "Form received username='john_doe', password='secret123'"
def test_i_can_submit_classic_form_with_get_method(self):
"""
Test that a classic form with GET method is submitted correctly.
This ensures GET requests are properly handled.
"""
html = '''
<form action="/search" method="get">
<input type="text" name="q" value="python" />
<input type="text" name="page" value="1" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/search')
def get(q: str, page: int):
return f"Search results for {q=}, {page=}"
form.submit()
assert client.get_content() == "Search results for q='python', page=1"
def test_i_can_submit_classic_form_without_method_defaults_to_post(self):
"""
Test that POST is used by default when method attribute is absent.
This ensures proper default behavior matching HTML standards.
"""
html = '''
<form action="/submit">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(data: str):
return f"Received {data=}"
form.submit()
assert client.get_content() == "Received data='test'"
def test_i_can_submit_form_with_htmx_post(self):
"""
Test that a form with hx_post uses HTMX submission.
This ensures HTMX-enabled forms are handled correctly.
"""
html = '''
<form hx-post="/htmx-submit">
<input type="text" name="username" value="alice" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/htmx-submit')
def post(username: str):
return f"HTMX received {username=}"
form.submit()
assert client.get_content() == "HTMX received username='alice'"
def test_i_can_submit_form_with_htmx_get(self):
"""
Test that a form with hx_get uses HTMX submission.
This ensures HTMX GET requests work properly.
"""
html = '''
<form hx-get="/htmx-search">
<input type="text" name="query" value="fasthtml" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/htmx-search')
def get(query: str):
return f"HTMX search for {query=}"
form.submit()
assert client.get_content() == "HTMX search for query='fasthtml'"
def test_i_can_submit_form_with_filled_fields(self):
"""
Test that fields filled via fill_form() are submitted correctly.
This ensures dynamic form filling works as expected.
"""
html = '''
<form action="/login" method="post">
<input type="text" name="username" value="" />
<input type="password" name="password" value="" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Fill the form dynamically
form.fill(username="bob", password="secure456")
@rt('/login')
def post(username: str, password: str):
return f"Login {username=}, {password=}"
form.submit()
assert client.get_content() == "Login username='bob', password='secure456'"
def test_i_can_submit_form_with_mixed_field_types(self):
"""
Test that all field types are submitted with correct values.
This ensures type conversion and submission work together.
"""
html = '''
<form action="/register" method="post">
<input type="text" name="username" value="charlie" />
<input type="number" name="age" value="30" />
<input type="checkbox" name="newsletter" checked />
<select name="country">
<option value="US" selected>USA</option>
<option value="FR">France</option>
</select>
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/register')
def post(username: str, age: int, newsletter: bool, country: str):
return f"Registration {username=}, {age=}, {newsletter=}, {country=}"
result = form.submit()
# Note: the types are converted in self.fields
expected = "Registration username='charlie', age=30, newsletter=True, country='US'"
assert client.get_content() == expected
def test_i_cannot_submit_classic_form_without_action(self):
"""
Test that an exception is raised when action attribute is missing.
This ensures proper error handling for malformed forms.
"""
html = '''
<form method="post">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Should raise ValueError
try:
form.submit()
assert False, "Expected ValueError to be raised"
except ValueError as e:
assert "no 'action' attribute" in str(e).lower()
def test_i_cannot_submit_classic_form_with_empty_action(self):
"""
Test that an exception is raised when action attribute is empty.
This ensures validation of the action attribute.
"""
html = '''
<form action="" method="post">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
# Should raise ValueError
try:
form.submit()
assert False, "Expected ValueError to be raised"
except ValueError as e:
assert "no 'action' attribute" in str(e).lower()
def test_i_can_submit_form_with_case_insensitive_method(self):
"""
Test that HTTP method is properly normalized to uppercase.
This ensures method attribute is case-insensitive.
"""
html = '''
<form action="/submit" method="PoSt">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/submit')
def post(data: str):
return f"Received {data=}"
form.submit()
assert client.get_content() == "Received data='test'"
def test_i_can_prioritize_htmx_over_classic_submission(self):
"""
Test that HTMX is prioritized even when action/method are present.
This ensures correct priority between HTMX and classic submission.
"""
html = '''
<form action="/classic" method="post" hx-post="/htmx">
<input type="text" name="data" value="test" />
</form>
'''
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/classic')
def post_classic(data: str):
return "Classic submission"
@rt('/htmx')
def post_htmx(data: str):
return "HTMX submission"
form.submit()
assert client.get_content() == "HTMX submission"
def test_i_can_submit_empty_form(self):
"""
Test that a form without fields can be submitted.
This ensures robustness for minimal forms.
"""
html = '<form action="/empty" method="post"></form>'
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
form = TestableForm(client, html)
@rt('/empty')
def post():
return "Empty form received"
form.submit()
assert client.get_content() == "Empty form received"