Add comprehensive test suite with pytest

- Add tests for URL parsing, cookies, HTML/CSS parsing
- Add tests for browser/tab management and history
- Add tests for layout and rendering components
- Configure pytest with coverage reporting
- Add test documentation and runner commands
- All 54 tests passing
This commit is contained in:
Benedikt Willi 2026-01-09 13:37:21 +01:00
parent f1e4957e70
commit ae6fcbfab4
14 changed files with 601 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
__pycache__/

View file

@ -18,6 +18,37 @@ uv sync
uv run bowser
```
### Testing
Run the test suite:
```bash
# Install dev dependencies
uv sync --extra dev
# Run all tests
uv run pytest
# Run with verbose output
uv run pytest -v
# Run with coverage report
uv run pytest --cov=src --cov-report=html
# Run specific test file
uv run pytest tests/test_browser.py
```
### Development
```bash
# Format code
uv run black src tests
# Lint code
uv run ruff check src tests
# Type check
uv run mypy src
```
## Project Structure
```

View file

@ -47,3 +47,29 @@ python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-ra",
"--strict-markers",
"--strict-config",
"--showlocals",
]
[tool.coverage.run]
source = ["src"]
omit = ["tests/*", "*/conftest.py"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]

64
tests/README.md Normal file
View file

@ -0,0 +1,64 @@
# Bowser Test Suite
This directory contains the test suite for the Bowser browser.
## Running Tests
Run all tests:
```bash
uv run pytest
```
Run with verbose output:
```bash
uv run pytest -v
```
Run specific test file:
```bash
uv run pytest tests/test_browser.py
```
Run with coverage:
```bash
uv run pytest --cov=src --cov-report=html
```
View coverage report:
```bash
open htmlcov/index.html
```
## Test Organization
- `test_url.py` - URL parsing and resolution
- `test_parser.py` - HTML/CSS parsing
- `test_browser.py` - Browser and tab management
- `test_cookies.py` - Cookie jar functionality
- `test_layout.py` - Layout engine components
- `test_render.py` - Rendering primitives
- `conftest.py` - Shared fixtures and configuration
## Writing Tests
Tests use pytest. Example:
```python
def test_feature():
# Arrange
obj = MyClass()
# Act
result = obj.method()
# Assert
assert result == expected
```
Use mocks for GTK components:
```python
@patch('src.browser.browser.Gtk')
def test_with_gtk(mock_gtk):
browser = Browser()
# test code
```

22
tests/conftest.py Normal file
View file

@ -0,0 +1,22 @@
"""Pytest configuration and fixtures."""
import pytest
import logging
@pytest.fixture(autouse=True)
def configure_logging():
"""Configure logging for tests."""
logging.basicConfig(
level=logging.WARNING, # Only show warnings/errors in tests
format="%(name)s %(levelname)s: %(message)s",
)
@pytest.fixture
def mock_browser():
"""Create a mock browser for testing."""
from unittest.mock import Mock
browser = Mock()
browser._log = Mock()
return browser

210
tests/test_browser.py Normal file
View file

@ -0,0 +1,210 @@
"""Tests for browser tab management."""
import pytest
from unittest.mock import Mock, patch
from src.browser.browser import Browser
from src.browser.tab import Tab
from src.network.url import URL
class TestTab:
def test_tab_creation(self):
browser = Mock()
tab = Tab(browser)
assert tab.browser is browser
assert tab.current_url is None
assert tab.history == []
assert tab.history_index == -1
def test_tab_title_new(self):
browser = Mock()
tab = Tab(browser)
assert tab.title == "New Tab"
def test_tab_title_with_url(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
assert "example.com" in tab.title
def test_tab_load_adds_history(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
url1 = URL("https://example.com")
url2 = URL("https://other.com")
tab.load(url1)
assert len(tab.history) == 1
assert tab.history_index == 0
tab.load(url2)
assert len(tab.history) == 2
assert tab.history_index == 1
def test_tab_go_back(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
url1 = URL("https://example.com")
url2 = URL("https://other.com")
tab.load(url1)
tab.load(url2)
result = tab.go_back()
assert result is True
assert tab.history_index == 0
def test_tab_go_back_at_start(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
result = tab.go_back()
assert result is False
assert tab.history_index == 0
def test_tab_go_forward(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
tab.load(URL("https://other.com"))
tab.go_back()
result = tab.go_forward()
assert result is True
assert tab.history_index == 1
def test_tab_go_forward_at_end(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
result = tab.go_forward()
assert result is False
def test_tab_reload(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
result = tab.reload()
assert result is True
assert tab.history_index == 0
def test_tab_history_truncation(self):
browser = Mock()
browser._log = Mock()
tab = Tab(browser)
tab.load(URL("https://example.com"))
tab.load(URL("https://other.com"))
tab.load(URL("https://third.com"))
tab.go_back() # now at other.com
tab.load(URL("https://new.com")) # should truncate third.com
assert len(tab.history) == 3
assert tab.history_index == 2
@patch('src.browser.browser.Gtk')
class TestBrowser:
def test_browser_creation(self, mock_gtk):
browser = Browser()
assert browser.tabs == []
assert browser.active_tab is None
def test_new_tab(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
tab = browser.new_tab("https://example.com")
assert len(browser.tabs) == 1
assert browser.active_tab is tab
assert tab in browser.tabs
def test_set_active_tab(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.chrome.paint = Mock()
browser.chrome.tabs_box = Mock()
tab1 = browser.new_tab("https://example.com")
tab2 = browser.new_tab("https://other.com")
browser.set_active_tab(tab1)
assert browser.active_tab is tab1
def test_close_tab(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.chrome.paint = Mock()
browser.chrome.tabs_box = Mock()
tab1 = browser.new_tab("https://example.com")
tab2 = browser.new_tab("https://other.com")
browser.close_tab(tab1)
assert len(browser.tabs) == 1
assert tab1 not in browser.tabs
assert browser.active_tab is tab2
def test_close_active_tab_selects_previous(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.chrome.paint = Mock()
browser.chrome.tabs_box = Mock()
tab1 = browser.new_tab("https://example.com")
tab2 = browser.new_tab("https://other.com")
tab3 = browser.new_tab("https://third.com")
browser.close_tab(tab3)
assert browser.active_tab is tab2
def test_close_last_tab(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.chrome.paint = Mock()
browser.chrome.tabs_box = Mock()
tab = browser.new_tab("https://example.com")
browser.close_tab(tab)
assert len(browser.tabs) == 0
assert browser.active_tab is None
def test_navigate_to(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.chrome.paint = Mock()
tab = browser.new_tab("https://example.com")
browser.navigate_to("https://other.com")
assert len(tab.history) == 2
def test_navigate_to_no_active_tab(self, mock_gtk):
browser = Browser()
browser.chrome.rebuild_tab_bar = Mock()
browser.chrome.update_address_bar = Mock()
browser.navigate_to("https://example.com")
assert len(browser.tabs) == 1
assert browser.active_tab is not None

41
tests/test_cookies.py Normal file
View file

@ -0,0 +1,41 @@
"""Tests for cookie management."""
import pytest
from src.network.cookies import CookieJar
class TestCookieJar:
def test_cookie_jar_creation(self):
jar = CookieJar()
assert jar._cookies == {}
def test_set_cookies(self):
jar = CookieJar()
jar.set_cookies("https://example.com", "session=abc123")
cookies = jar.get_cookie_header("https://example.com")
assert "session=abc123" in cookies
def test_get_cookie_header_empty(self):
jar = CookieJar()
cookies = jar.get_cookie_header("https://example.com")
assert cookies == ""
def test_multiple_cookies_same_origin(self):
jar = CookieJar()
jar.set_cookies("https://example.com", "session=abc123")
jar.set_cookies("https://example.com", "user=john")
cookies = jar.get_cookie_header("https://example.com")
assert "session=abc123" in cookies or "user=john" in cookies
def test_cookies_isolated_by_origin(self):
jar = CookieJar()
jar.set_cookies("https://example.com", "session=abc123")
jar.set_cookies("https://other.com", "session=xyz789")
cookies1 = jar.get_cookie_header("https://example.com")
cookies2 = jar.get_cookie_header("https://other.com")
assert "abc123" in cookies1
assert "xyz789" in cookies2

58
tests/test_layout.py Normal file
View file

@ -0,0 +1,58 @@
"""Tests for layout components."""
import pytest
from unittest.mock import Mock
from src.layout.document import DocumentLayout
from src.layout.block import BlockLayout, LineLayout
from src.layout.inline import TextLayout
from src.parser.html import Element, Text
class TestDocumentLayout:
def test_document_layout_creation(self):
node = Element("html")
layout = DocumentLayout(node)
assert layout.node is node
assert layout.children == []
def test_document_layout(self):
node = Element("html")
layout = DocumentLayout(node)
result = layout.layout(800, 1.0)
assert result == 800.0
class TestBlockLayout:
def test_block_layout_creation(self):
node = Element("div")
layout = BlockLayout(node)
assert layout.node is node
assert layout.children == []
def test_block_layout_with_parent(self):
parent_node = Element("body")
child_node = Element("div")
parent_layout = BlockLayout(parent_node)
child_layout = BlockLayout(child_node, parent=parent_layout)
assert child_layout.parent is parent_layout
class TestLineLayout:
def test_line_layout_creation(self):
node = Element("span")
layout = LineLayout(node)
assert layout.node is node
class TestTextLayout:
def test_text_layout_creation(self):
node = Text("Hello")
layout = TextLayout(node, "Hello")
assert layout.node is node
assert layout.word == "Hello"
def test_text_layout_length(self):
node = Text("Hello")
layout = TextLayout(node, "Hello")
result = layout.layout()
assert result == 5

52
tests/test_parser.py Normal file
View file

@ -0,0 +1,52 @@
"""Tests for HTML parsing."""
import pytest
from src.parser.html import Text, Element, print_tree
class TestHTMLElements:
def test_text_node(self):
text = Text("Hello World")
assert text.text == "Hello World"
assert text.parent is None
def test_text_node_with_parent(self):
parent = Element("div")
text = Text("Hello", parent=parent)
assert text.parent is parent
def test_element_node(self):
elem = Element("div", {"class": "container"})
assert elem.tag == "div"
assert elem.attributes == {"class": "container"}
assert elem.children == []
def test_element_default_attributes(self):
elem = Element("p")
assert elem.attributes == {}
def test_element_parent(self):
parent = Element("body")
child = Element("div", parent=parent)
assert child.parent is parent
class TestPrintTree:
def test_print_single_element(self, capsys):
elem = Element("div")
print_tree(elem)
captured = capsys.readouterr()
assert "Element('div'" in captured.out
def test_print_tree_with_children(self, capsys):
root = Element("html")
body = Element("body", parent=root)
text = Text("Hello", parent=body)
root.children = [body]
body.children = [text]
print_tree(root)
captured = capsys.readouterr()
assert "Element('html'" in captured.out
assert "Element('body'" in captured.out
assert "Text('Hello')" in captured.out

55
tests/test_render.py Normal file
View file

@ -0,0 +1,55 @@
"""Tests for rendering primitives."""
import pytest
from src.render.paint import PaintCommand, DrawText
from src.render.composite import CompositedLayer
from src.render.fonts import get_font, linespace
class TestPaintCommands:
def test_paint_command_creation(self):
cmd = PaintCommand((0, 0, 100, 100))
assert cmd.rect == (0, 0, 100, 100)
def test_draw_text_creation(self):
cmd = DrawText(10, 20, "Hello", ("Arial", 12), "black")
assert cmd.text == "Hello"
assert cmd.font == ("Arial", 12)
assert cmd.color == "black"
class TestCompositedLayer:
def test_composited_layer_creation(self):
layer = CompositedLayer()
assert layer.items == []
def test_composited_layer_with_item(self):
item = "mock_item"
layer = CompositedLayer(item)
assert len(layer.items) == 1
assert layer.items[0] == item
def test_add_item(self):
layer = CompositedLayer()
layer.add("item1")
layer.add("item2")
assert len(layer.items) == 2
class TestFonts:
def test_get_font(self):
font = get_font(14)
assert font == (14, "normal", "normal")
def test_get_font_with_weight(self):
font = get_font(16, weight="bold")
assert font == (16, "bold", "normal")
def test_get_font_with_style(self):
font = get_font(12, style="italic")
assert font == (12, "normal", "italic")
def test_linespace(self):
font = (14, "normal", "normal")
space = linespace(font)
assert space == int(14 * 1.2)

41
tests/test_url.py Normal file
View file

@ -0,0 +1,41 @@
"""Tests for URL parsing and resolution."""
import pytest
from src.network.url import URL
class TestURL:
def test_parse_simple_url(self):
url = URL("https://example.com")
assert str(url) == "https://example.com"
def test_parse_url_with_path(self):
url = URL("https://example.com/path/to/page")
assert str(url) == "https://example.com/path/to/page"
def test_parse_url_with_query(self):
url = URL("https://example.com/search?q=test")
assert str(url) == "https://example.com/search?q=test"
def test_origin(self):
url = URL("https://example.com:8080/path")
assert url.origin() == "https://example.com:8080"
def test_origin_default_port(self):
url = URL("https://example.com/path")
assert url.origin() == "https://example.com"
def test_resolve_relative_path(self):
base = URL("https://example.com/dir/page.html")
resolved = base.resolve("other.html")
assert str(resolved) == "https://example.com/dir/other.html"
def test_resolve_absolute_path(self):
base = URL("https://example.com/dir/page.html")
resolved = base.resolve("/root/page.html")
assert str(resolved) == "https://example.com/root/page.html"
def test_resolve_full_url(self):
base = URL("https://example.com/page.html")
resolved = base.resolve("https://other.com/page.html")
assert str(resolved) == "https://other.com/page.html"