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,16 +248,55 @@ 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.
""" """
return self._send_htmx_request(data=self.fields) # Check if the form supports HTMX
if self._support_htmx():
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):
""" """
@@ -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):
json_data['session'] = self._session if json_data is not None:
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,43 +2,45 @@ 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:
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/') def test_i_can_open_a_page(self):
def get(): return "hello world" test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return "hello world"
client.open("/")
assert client.get_content() == "hello world"
client.open("/") def test_i_can_open_a_page_when_html(self):
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
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() == "hello world" def test_i_cannot_open_a_page_not_defined(self):
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/not_found")
assert str(exc_info.value) == "Failed to open '/not_found'. status code=404 : reason='404 Not Found'"
def test_i_can_open_a_page_when_html(): class TestMyTestClientShouldSee:
test_app, rt = fast_app(default_hdrs=False) def test_i_can_see_text_in_plain_response(self):
client = MyTestClient(test_app)
@rt('/')
def get(): return Div("hello world")
client.open("/")
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():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(AssertionError) as exc_info:
client.open("/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():
"""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)
@@ -48,279 +50,435 @@ def test_i_can_see_text_in_plain_response():
return "hello world" return "hello world"
client.open("/").should_see("hello world") client.open("/").should_see("hello world")
def test_i_can_see_text_in_html_response():
"""Test that should_see() extracts visible text from HTML responses."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/') def test_i_can_see_text_in_html_response(self):
def get(): """Test that should_see() extracts visible text from HTML responses."""
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>" test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>This is a test</p></body></html>"
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):
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False)
def test_i_can_see_text_ignoring_html_tags(): client = MyTestClient(test_app)
"""Test that should_see() searches in visible text only, not in HTML tags."""
test_app, rt = fast_app(default_hdrs=False) @rt('/')
client = MyTestClient(test_app) def get():
return '<div class="container">Content</div>'
# Should find the visible text
client.open("/").should_see("Content")
# Should NOT find text that's only in attributes/tags
with pytest.raises(AssertionError) as exc_info:
client.should_see("container")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value)
@rt('/') def test_i_cannot_see_text_that_is_not_present(self):
def get(): """Test that should_see() raises AssertionError when text is not found."""
return '<div class="container">Content</div>' test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_see("goodbye")
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)
# Should find the visible text def test_i_cannot_call_should_see_without_opening_page(self):
client.open("/").should_see("Content") """Test that should_see() 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.should_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_see()."
# Should NOT find text that's only in attributes/tags def test_i_can_verify_text_is_not_present(self):
with pytest.raises(AssertionError) as exc_info: """Test that should_not_see() works when text is absent."""
client.should_see("container") test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "hello world"
client.open("/").should_not_see("goodbye")
assert "Expected to see 'container' in page content but it was not found" in str(exc_info.value) def test_i_cannot_use_should_not_see_when_text_is_present(self):
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False)
def test_i_cannot_see_text_that_is_not_present(): client = MyTestClient(test_app)
"""Test that should_see() raises AssertionError when text is not found."""
test_app, rt = fast_app(default_hdrs=False) @rt('/')
client = MyTestClient(test_app) def get():
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
assert "Expected NOT to see 'hello' in page content but it was found" in error_message
@rt('/') def test_i_cannot_call_should_not_see_without_opening_page(self):
def get(): """Test that should_not_see() raises ValueError if no page has been opened."""
return "hello world" test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
with pytest.raises(ValueError) as exc_info:
client.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
with pytest.raises(AssertionError) as exc_info: def test_i_can_chain_multiple_assertions(self):
client.open("/").should_see("goodbye") """Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
client.open("/").should_see("Welcome").should_see("Content").should_not_see("Error")
assert "Expected to see 'goodbye' in page content but it was not found" in str(exc_info.value) def test_i_can_see_element_context_when_text_should_not_be_seen(self):
assert "hello world" in str(exc_info.value) """Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
def test_i_cannot_call_should_see_without_opening_page():
"""Test that should_see() raises ValueError if no page has been opened.""" @rt('/')
test_app, rt = fast_app(default_hdrs=False) def get():
client = MyTestClient(test_app) return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
with pytest.raises(ValueError) as exc_info: def test_i_can_configure_parent_levels_in_constructor(self):
client.should_see("anything") """Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error")
error_message = str(exc_info.value)
assert '<p>error</p>' in error_message
assert '<div class="container">' in error_message
assert '<div class="wrapper">' in error_message
assert str(exc_info.value) == "No page content available. Call open() before should_see()." def test_i_can_find_text_in_nested_elements(self):
"""Test that the smallest element containing the text is found in nested structures."""
test_app, rt = fast_app(default_hdrs=False)
def test_i_can_verify_text_is_not_present(): client = MyTestClient(test_app)
"""Test that should_not_see() works when text is absent."""
test_app, rt = fast_app(default_hdrs=False) @rt('/')
client = MyTestClient(test_app) def get():
return '<div><section><article><p class="target">nested text</p></article></section></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("nested text")
error_message = str(exc_info.value)
# Should find the <p> element, not the outer <div>
assert '<p class="target">nested text</p>' in error_message
@rt('/') def test_i_can_find_fragmented_text_across_tags(self):
def get(): """Test that text fragmented across multiple tags is correctly found."""
return "hello world" test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
client.open("/").should_not_see("goodbye") def test_i_do_not_find_text_in_html_attributes(self):
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
def test_i_cannot_use_should_not_see_when_text_is_present(): client = MyTestClient(test_app)
"""Test that should_not_see() raises AssertionError when text is found."""
test_app, rt = fast_app(default_hdrs=False) @rt('/')
client = MyTestClient(test_app) def get():
return '<div class="error message" title="error info">Success</div>'
@rt('/')
def get(): # "error" is in attributes but not in visible text
return "hello world"
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello")
error_message = str(exc_info.value)
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():
"""Test that should_not_see() 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.should_not_see("anything")
assert str(exc_info.value) == "No page content available. Call open() before should_not_see()."
def test_i_can_chain_multiple_assertions():
"""Test that assertions can be chained together."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return "<html><body><h1>Welcome</h1><p>Content here</p></body></html>"
# Chain multiple assertions
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():
"""Test that the HTML element containing the text is displayed with parent context."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="container"><p class="content">forbidden text</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("forbidden text")
error_message = str(exc_info.value)
assert "Found in:" in error_message
assert '<p class="content">forbidden text</p>' in error_message
assert '<div class="container">' in error_message
def test_i_can_configure_parent_levels_in_constructor():
"""Test that parent_levels parameter controls the number of parent levels shown."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app, parent_levels=2)
@rt('/')
def get():
return '<body><div class="wrapper"><div class="container"><p>error</p></div></div></body>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("error") client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
class TestMyTestClientFindElement:
error_message = str(exc_info.value) @pytest.mark.parametrize("selector,expected_tag", [
assert '<p>error</p>' in error_message ("#unique-id", '<div class="main wrapper"'),
assert '<div class="container">' in error_message (".home-link", '<a class="link home-link"'),
assert '<div class="wrapper">' in error_message ("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
def test_i_can_find_text_in_nested_elements(): ('[data-type="navigation"]', '<a class="link home-link"'),
"""Test that the smallest element containing the text is found in nested structures.""" ('[class~="link"]', '<a class="link home-link"'),
test_app, rt = fast_app(default_hdrs=False) ('[href^="/home"]', '<a class="link home-link"'),
client = MyTestClient(test_app) ('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
@rt('/') ])
def get(): def test_i_can_find_element(self, selector, expected_tag):
return '<div><section><article><p class="target">nested text</p></article></section></div>' """Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
with pytest.raises(AssertionError) as exc_info: client = MyTestClient(test_app)
client.open("/").should_not_see("nested text")
@rt('/')
error_message = str(exc_info.value) def get():
# Should find the <p> element, not the outer <div> return '''
assert '<p class="target">nested text</p>' in error_message <div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
def test_i_can_find_fragmented_text_across_tags(): <div class="content">
"""Test that text fragmented across multiple tags is correctly found.""" <span class="text">Content</span>
test_app, rt = fast_app(default_hdrs=False) </div>
client = MyTestClient(test_app)
@rt('/')
def get():
return '<p class="message">hel<span>lo</span> world</p>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").should_not_see("hello world")
error_message = str(exc_info.value)
# Should find the parent <p> element that contains the full text
assert '<p class="message">' in error_message
def test_i_do_not_find_text_in_html_attributes():
"""Test that text in HTML attributes is not considered as visible text."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div class="error message" title="error info">Success</div>'
# "error" is in attributes but not in visible text
client.open("/").should_not_see("error")
# "Success" is in visible text
with pytest.raises(AssertionError):
client.should_not_see("Success")
@pytest.mark.parametrize("selector,expected_tag", [
("#unique-id", '<div class="main wrapper"'),
(".home-link", '<a class="link home-link"'),
("div > div", '<div class="content"'),
("div span", "<span"),
("[data-type]", '<a class="link home-link"'),
('[data-type="navigation"]', '<a class="link home-link"'),
('[class~="link"]', '<a class="link home-link"'),
('[href^="/home"]', '<a class="link home-link"'),
('[href$="about"]', '<a href="/about">'),
('[data-author*="john"]', '<a class="link home-link"'),
])
def test_i_can_find_element(selector, expected_tag):
"""Test that find_element works with various CSS selectors."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '''
<div id="unique-id" class="main wrapper">
<a href="/home" class="link home-link" data-type="navigation" data-author="john-doe">Home</a>
<a href="/about">About</a>
<div class="content">
<span class="text">Content</span>
</div> </div>
</div> '''
'''
element = client.open("/").find_element(selector)
element = client.open("/").find_element(selector)
assert element is not None
assert element is not None 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):
"""Test that find_element raises AssertionError when no element matches."""
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_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist(self):
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page(self):
"""Test that find_element 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_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."
def test_i_cannot_find_element_when_none_exists(): class TestMyTestClientFindForm:
"""Test that find_element raises AssertionError when no element matches.""" def test_i_can_find_form(self):
test_app, rt = fast_app(default_hdrs=False) """Test that find_form works fo simple form."""
client = MyTestClient(test_app) 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
@rt('/')
def get():
return '<div class="container"><p>Content</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element("#non-existent")
assert "No element found matching selector '#non-existent'" in str(exc_info.value)
def test_i_cannot_find_element_when_multiple_exist():
"""Test that find_element raises AssertionError when multiple elements match."""
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
@rt('/')
def get():
return '<div><p class="text">First</p><p class="text">Second</p><p class="text">Third</p></div>'
with pytest.raises(AssertionError) as exc_info:
client.open("/").find_element(".text")
error_message = str(exc_info.value)
assert "Found 3 elements matching selector '.text'" in error_message
assert "Expected exactly 1" in error_message
def test_i_cannot_call_find_element_without_opening_page():
"""Test that find_element 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_element("#any-selector")
assert str(exc_info.value) == "No page content available. Call open() before find_element()."

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"