Testing Flet Applications: From Unit Tests to Integration
Flet is a powerful Python framework that lets you build interactive multi-platform applications using a Flutter-based UI engine. As your Flet applications grow in complexity, ensuring reliability through a robust testing strategy becomes essential. This tutorial walks you through everything from unit testing individual components to writing integration tests that verify your entire application behaves as expected.
Why Testing Flet Applications Matters
Unlike traditional web frameworks, Flet applications manage state across a connection between your Python backend and the Flutter frontend. This introduces unique challenges: UI controls are objects, event handlers drive logic, and page navigation can create subtle bugs. A solid testing strategy helps you:
- Catch regressions early before they reach users
- Verify that event handlers update UI state correctly
- Ensure navigation and page transitions work as intended
- Refactor with confidence knowing your tests will flag breakages
- Document expected behavior through executable examples
Setting Up Your Test Environment
Before writing tests, you need to install the necessary dependencies. Flet applications are typically tested with pytest, and you may also want pytest-asyncio for testing asynchronous handlers.
pip install flet pytest pytest-asyncio pytest-cov
Create a pytest.ini or add configuration to pyproject.toml to manage asyncio mode:
# pytest.ini
[pytest]
asyncio_mode = auto
testpaths = tests
python_files = test_*.py
python_functions = test_*
Organize your project with a clear structure separating source code from tests:
my_flet_app/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── pages/
│ │ ├── __init__.py
│ │ ├── login.py
│ │ └── dashboard.py
│ └── services/
│ ├── __init__.py
│ └── auth.py
├── tests/
│ ├── __init__.py
│ ├── unit/
│ │ ├── test_auth.py
│ │ └── test_login_page.py
│ └── integration/
│ └── test_app_flow.py
├── pytest.ini
└── requirements.txt
Unit Testing Flet Components
Unit tests focus on testing individual functions, classes, or page-building logic in isolation. The key insight is that Flet page-building functions typically return lists of controls. You can test these functions by calling them and inspecting the returned controls.
Testing a Simple Page Builder
Consider a simple login page that builds UI controls based on a function:
# app/pages/login.py
import flet as ft
def build_login_page(on_login_click):
"""Build and return the login page controls."""
username_field = ft.TextField(
label="Username",
value="",
width=300,
autofocus=True,
)
password_field = ft.TextField(
label="Password",
password=True,
can_reveal_password=True,
width=300,
)
error_text = ft.Text(value="", color=ft.colors.RED, visible=False)
login_button = ft.ElevatedButton(
text="Login",
on_click=lambda e: on_login_click(
username_field.value, password_field.value
),
)
return ft.Column(
controls=[
ft.Text("Welcome", size=32, weight=ft.FontWeight.BOLD),
username_field,
password_field,
error_text,
login_button,
],
alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
), {
"username": username_field,
"password": password_field,
"error": error_text,
"button": login_button,
}
Now write a unit test that verifies the structure of the returned controls:
# tests/unit/test_login_page.py
import pytest
from app.pages.login import build_login_page
def test_build_login_page_returns_column():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
assert isinstance(controls, ft.Column)
def test_login_page_has_required_fields():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
# Check that the column contains expected control types
control_types = [type(c) for c in controls.controls]
assert ft.TextField in control_types
assert ft.ElevatedButton in control_types
def test_login_page_error_text_starts_hidden():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
assert refs["error"].visible is False
assert refs["error"].value == ""
def test_login_page_username_field_is_autofocus():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
assert refs["username"].autofocus is True
def test_login_page_password_field_is_masked():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
assert refs["password"].password is True
Testing Business Logic Services
Your Flet app likely contains service classes that handle business logic independent of the UI. These are the easiest to unit test since they have no Flet dependencies:
# app/services/auth.py
class AuthService:
def __init__(self):
self._users = {
"admin": "secret123",
"user": "password",
}
self.current_user = None
def login(self, username: str, password: str) -> bool:
if username in self._users and self._users[username] == password:
self.current_user = username
return True
return False
def logout(self):
self.current_user = None
def is_authenticated(self) -> bool:
return self.current_user is not None
# tests/unit/test_auth.py
import pytest
from app.services.auth import AuthService
class TestAuthService:
def setup_method(self):
self.auth = AuthService()
def test_successful_login(self):
result = self.auth.login("admin", "secret123")
assert result is True
assert self.auth.current_user == "admin"
assert self.auth.is_authenticated() is True
def test_failed_login_wrong_password(self):
result = self.auth.login("admin", "wrong")
assert result is False
assert self.auth.current_user is None
assert self.auth.is_authenticated() is False
def test_failed_login_unknown_user(self):
result = self.auth.login("unknown", "secret123")
assert result is False
assert self.auth.is_authenticated() is False
def test_logout_clears_session(self):
self.auth.login("admin", "secret123")
self.auth.logout()
assert self.auth.current_user is None
assert self.auth.is_authenticated() is False
Mocking Flet Controls and the Page Object
When testing event handlers that interact with the ft.Page object, you need to mock it. The Page object manages controls, navigation, and client-side updates. Creating a mock allows you to verify that your handlers call the right methods on the page.
Creating a Mock Page
# tests/conftest.py
import pytest
from unittest.mock import MagicMock, AsyncMock
import flet as ft
@pytest.fixture
def mock_page():
"""Create a mock Flet page for testing."""
page = MagicMock(spec=ft.Page)
page.controls = []
page.views = []
page.session = MagicMock()
page.session.get = MagicMock(return_value=None)
page.session.set = MagicMock()
page.go = MagicMock()
page.update = MagicMock()
page.add = MagicMock(side_effect=lambda *controls: page.controls.extend(controls))
page.clean = MagicMock(side_effect=lambda: page.controls.clear())
page.overlay = []
page.dialog = None
page.snack_bar = None
return page
@pytest.fixture
def mock_event():
"""Create a mock control event."""
e = MagicMock()
e.control = MagicMock()
e.page = MagicMock(spec=ft.Page)
return e
Testing Event Handlers with Mocks
Now let's test a page controller that uses the page object for navigation and UI updates:
# app/pages/dashboard.py
import flet as ft
from app.services.auth import AuthService
class DashboardController:
def __init__(self, page: ft.Page, auth: AuthService):
self.page = page
self.auth = auth
self.welcome_text = ft.Text(size=24)
self.counter_text = ft.Text("Count: 0", size=18)
self._count = 0
def build(self):
if not self.auth.is_authenticated():
self.page.go("/login")
return ft.Text("Redirecting...")
self.welcome_text.value = f"Welcome, {self.auth.current_user}!"
return ft.Column(
controls=[
self.welcome_text,
self.counter_text,
ft.ElevatedButton("Increment", on_click=self.on_increment),
ft.ElevatedButton("Logout", on_click=self.on_logout),
]
)
def on_increment(self, e):
self._count += 1
self.counter_text.value = f"Count: {self._count}"
self.counter_text.update()
def on_logout(self, e):
self.auth.logout()
self.page.go("/login")
self.page.snack_bar = ft.SnackBar(ft.Text("Logged out successfully"))
self.page.snack_bar.open = True
self.page.update()
# tests/unit/test_dashboard_page.py
import pytest
from unittest.mock import MagicMock, patch
import flet as ft
from app.pages.dashboard import DashboardController
from app.services.auth import AuthService
class TestDashboardController:
@pytest.fixture
def auth(self):
auth = AuthService()
auth.login("admin", "secret123")
return auth
def test_build_shows_welcome_message(self, mock_page, auth):
controller = DashboardController(mock_page, auth)
result = controller.build()
assert isinstance(result, ft.Column)
assert "admin" in controller.welcome_text.value
def test_build_redirects_when_not_authenticated(self, mock_page):
auth = AuthService()
controller = DashboardController(mock_page, auth)
controller.build()
mock_page.go.assert_called_once_with("/login")
def test_on_increment_updates_counter(self, mock_page, auth):
controller = DashboardController(mock_page, auth)
controller.build()
controller.on_increment(mock_event=None)
assert controller.counter_text.value == "Count: 1"
controller.on_increment(mock_event=None)
assert controller.counter_text.value == "Count: 2"
def test_on_logout_clears_session_and_navigates(self, mock_page, auth):
controller = DashboardController(mock_page, auth)
controller.build()
controller.on_logout(mock_event=None)
assert auth.is_authenticated() is False
mock_page.go.assert_called_with("/login")
assert mock_page.snack_bar is not None
assert mock_page.snack_bar.open is True
Testing Asynchronous Handlers
Flet supports async event handlers, which are common when making API calls or performing I/O operations. Testing these requires pytest-asyncio and async mock objects.
# app/pages/data_loader.py
import flet as ft
import httpx
class DataLoaderController:
def __init__(self, page: ft.Page):
self.page = page
self.status_text = ft.Text("Ready")
self.data_list = ft.Column()
def build(self):
return ft.Column(
controls=[
self.status_text,
ft.ElevatedButton("Load Data", on_click=self.on_load_data),
self.data_list,
]
)
async def on_load_data(self, e):
self.status_text.value = "Loading..."
self.status_text.update()
try:
async with httpx.AsyncClient() as client:
response = await client.get("https://api.example.com/items")
response.raise_for_status()
items = response.json()
self.data_list.controls.clear()
for item in items:
self.data_list.controls.append(ft.Text(item["name"]))
self.status_text.value = f"Loaded {len(items)} items"
except Exception as ex:
self.status_text.value = f"Error: {str(ex)}"
finally:
self.status_text.update()
# tests/unit/test_data_loader.py
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
import flet as ft
from app.pages.data_loader import DataLoaderController
@pytest.mark.asyncio
async def test_load_data_success():
page = MagicMock(spec=ft.Page)
controller = DataLoaderController(page)
controller.build()
mock_response = MagicMock()
mock_response.json.return_value = [
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
]
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_response)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.pages.data_loader.httpx.AsyncClient", return_value=mock_client):
await controller.on_load_data(e=MagicMock())
assert controller.status_text.value == "Loaded 3 items"
assert len(controller.data_list.controls) == 3
assert controller.data_list.controls[0].value == "Item 1"
@pytest.mark.asyncio
async def test_load_data_handles_error():
page = MagicMock(spec=ft.Page)
controller = DataLoaderController(page)
controller.build()
mock_client = AsyncMock()
mock_client.get = AsyncMock(side_effect=Exception("Network error"))
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
with patch("app.pages.data_loader.httpx.AsyncClient", return_value=mock_client):
await controller.on_load_data(e=MagicMock())
assert "Error" in controller.status_text.value
assert "Network error" in controller.status_text.value
Integration Testing Flet Applications
Integration tests verify that multiple components work together correctly. For Flet applications, this means testing the full page lifecycle, navigation between views, and how the UI responds to user interactions across the entire app.
Building a Testable App Structure
First, structure your app so that routing and view creation are testable. Separate the app setup from the entry point:
# app/main.py
import flet as ft
from app.pages.login import build_login_page
from app.pages.dashboard import DashboardController
from app.services.auth import AuthService
def create_app(page: ft.Page, auth: AuthService = None):
"""Configure the Flet app with routing. Returns a route handler."""
if auth is None:
auth = AuthService()
page.title = "My Flet App"
page.theme_mode = ft.ThemeMode.LIGHT
def route_change(e: ft.RouteChangeEvent):
page.views.clear()
if page.route == "/" or page.route == "/login":
controls, refs = build_login_page(
on_login_click=lambda u, p: handle_login(u, p, refs)
)
page.views.append(
ft.View(
route="/login",
controls=[controls],
vertical_alignment=ft.MainAxisAlignment.CENTER,
horizontal_alignment=ft.CrossAxisAlignment.CENTER,
)
)
elif page.route == "/dashboard":
if not auth.is_authenticated():
page.go("/login")
return
dashboard = DashboardController(page, auth)
page.views.append(
ft.View(
route="/dashboard",
controls=[dashboard.build()],
)
)
page.update()
def handle_login(username, password, refs):
if auth.login(username, password):
page.go("/dashboard")
else:
refs["error"].value = "Invalid credentials"
refs["error"].visible = True
refs["error"].update()
def view_pop(e: ft.ViewPopEvent):
page.views.pop()
top_view = page.views[-1]
page.go(top_view.route)
page.on_route_change = route_change
page.on_view_pop = view_pop
page.go(page.route)
return {"auth": auth, "route_change": route_change, "handle_login": handle_login}
def main(page: ft.Page):
create_app(page)
if __name__ == "__main__":
ft.app(target=main)
Writing Integration Tests
Now write integration tests that simulate navigation and verify the full flow:
# tests/integration/test_app_flow.py
import pytest
from unittest.mock import MagicMock
import flet as ft
from app.main import create_app
from app.services.auth import AuthService
class TestAppIntegration:
@pytest.fixture
def auth(self):
return AuthService()
@pytest.fixture
def page(self, auth):
page = MagicMock(spec=ft.Page)
page.controls = []
page.views = []
page.route = "/login"
page.session = MagicMock()
page.session.get = MagicMock(return_value=None)
page.session.set = MagicMock()
page.overlay = []
page.dialog = None
page.snack_bar = None
# Track route changes
def go(route):
page.route = route
if page.on_route_change:
page.on_route_change(MagicMock(route=route))
page.go = MagicMock(side_effect=go)
page.update = MagicMock()
page.add = MagicMock(side_effect=lambda *c: page.controls.extend(c))
page.clean = MagicMock()
return page
def test_app_starts_at_login(self, page, auth):
create_app(page, auth)
assert page.route == "/login"
assert len(page.views) == 1
assert page.views[0].route == "/login"
def test_successful_login_navigates_to_dashboard(self, page, auth):
app = create_app(page, auth)
# Simulate login with correct credentials
app["handle_login"]("admin", "secret123", self._get_error_refs(page))
assert auth.is_authenticated() is True
assert page.route == "/dashboard"
assert len(page.views) >= 1
assert page.views[-1].route == "/dashboard"
def test_failed_login_shows_error(self, page, auth):
app = create_app(page, auth)
refs = self._get_error_refs(page)
app["handle_login"]("admin", "wrongpassword", refs)
assert auth.is_authenticated() is False
assert page.route == "/login"
assert refs["error"].visible is True
assert "Invalid" in refs["error"].value
def test_dashboard_requires_authentication(self, page, auth):
create_app(page, auth)
# Try to navigate to dashboard without logging in
page.go("/dashboard")
# Should redirect back to login
assert page.route == "/login"
def test_full_login_logout_flow(self, page, auth):
app = create_app(page, auth)
# Step 1: Start at login
assert page.route == "/login"
# Step 2: Login successfully
refs = self._get_error_refs(page)
app["handle_login"]("admin", "secret123", refs)
assert page.route == "/dashboard"
# Step 3: Logout
dashboard_view = page.views[-1]
dashboard_controls = dashboard_view.controls[0]
logout_button = self._find_button(dashboard_controls, "Logout")
assert logout_button is not None
# Simulate clicking logout
logout_button.on_click(MagicMock())
assert auth.is_authenticated() is False
assert page.route == "/login"
def _get_error_refs(self, page):
"""Extract the error text reference from the login view."""
login_view = page.views[0]
column = login_view.controls[0]
error_text = None
username_field = None
password_field = None
for control in column.controls:
if isinstance(control, ft.Text) and control.value == "":
error_text = control
elif isinstance(control, ft.TextField) and control.label == "Username":
username_field = control
elif isinstance(control, ft.TextField) and control.label == "Password":
password_field = control
return {"error": error_text, "username": username_field, "password": password_field}
def _find_button(self, column, text):
"""Find a button by its text label."""
for control in column.controls:
if isinstance(control, ft.ElevatedButton) and control.text == text:
return control
return None
Testing with Flet's Built-in Test Utilities
Flet also provides experimental support for testing through its control tree. You can traverse the control hierarchy to find specific elements and verify their properties. Here is a helper utility for traversing controls:
# tests/helpers.py
import flet as ft
from typing import List, Optional, Type
def find_controls(root: ft.Control, control_type: Type[ft.Control]) -> List[ft.Control]:
"""Recursively find all controls of a given type in the control tree."""
results = []
if isinstance(root, control_type):
results.append(root)
# Check common container attributes that hold child controls
for attr in ["controls", "content", "pages", "views", "tabs"]:
children = getattr(root, attr, None)
if children is None:
continue
if isinstance(children, list):
for child in children:
if isinstance(child, ft.Control):
results.extend(find_controls(child, control_type))
elif isinstance(children, ft.Control):
results.extend(find_controls(children, control_type))
return results
def find_first_control(root: ft.Control, control_type: Type[ft.Control]) -> Optional[ft.Control]:
"""Find the first control of a given type in the tree."""
controls = find_controls(root, control_type)
return controls[0] if controls else None
def find_control_by_value(root: ft.Control, value: str) -> Optional[ft.Control]:
"""Find a control by its value or text property."""
for control in find_controls(root, ft.Control):
ctrl_value = getattr(control, "value", None) or getattr(control, "text", None)
if ctrl_value == value:
return control
return None
Use these helpers to make your tests more readable:
# tests/unit/test_helpers_usage.py
import pytest
import flet as ft
from tests.helpers import find_controls, find_first_control, find_control_by_value
from app.pages.login import build_login_page
def test_find_all_text_fields_in_login():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
text_fields = find_controls(controls, ft.TextField)
assert len(text_fields) == 2 # username and password
def test_find_login_button_by_text():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
button = find_control_by_value(controls, "Login")
assert button is not None
assert isinstance(button, ft.ElevatedButton)
def test_find_welcome_text():
controls, refs = build_login_page(on_login_click=lambda u, p: None)
welcome = find_control_by_value(controls, "Welcome")
assert welcome is not None
assert welcome.size == 32
Best Practices for Testing Flet Applications
1. Separate Logic from UI
The most testable Flet applications keep business logic in service classes and use page controllers purely for UI orchestration. This lets you test the bulk of your application without touching Flet controls at all.
# Good: Logic in a service class
class CartService:
def __init__(self):
self.items = []
def add_item(self, item_id: str, price: float, quantity: int = 1):
for existing in self.items:
if existing["id"] == item_id:
existing["quantity"] += quantity
return
self.items.append({"id": item_id, "price": price, "quantity": quantity})
@property
def total(self) -> float:
return sum(item["price"] * item["quantity"] for item in self.items)
def remove_item(self, item_id: str):
self.items = [i for i in self.items if i["id"] != item_id]
# The controller just wires UI to the service
class CartController:
def __init__(self, page: ft.Page, cart: CartService):
self.page = page
self.cart = cart
self.total_text = ft.Text("Total: $0.00")
def on_add_item(self, e):
self.cart.add_item("item1", 9.99)
self.total_text.value = f"Total: ${self.cart.total:.2f}"
self.total_text.update()
2. Use Fixtures for Common Setup
Leverage pytest fixtures to avoid repeating setup code across tests. Create fixtures for mock pages, authenticated sessions, and commonly used controllers:
# tests/conftest.py (extended)
import pytest
from unittest.mock import MagicMock
import flet as ft
from app.services.auth import AuthService
@pytest.fixture
def mock_page():
page = MagicMock(spec=ft.Page)
page.controls = []
page.views = []
page.route = "/"
page.session = MagicMock()
page.session.get = MagicMock(return_value=None)
page.session.set = MagicMock()
page.go = MagicMock()
page.update = MagicMock()
page.add = MagicMock(side_effect=lambda *c: page.controls.extend(c))
page.clean = MagicMock()
page.overlay = []
page.dialog = None
page.snack_bar = None
return page
@pytest.fixture
def authenticated_auth():
auth = AuthService()
auth.login("admin", "secret123")
return auth
@pytest.fixture
def unauthenticated_auth():
return AuthService()
3. Test Both Happy and Error Paths
Always test what happens when things go wrong. Empty inputs, network failures, and invalid state transitions are where bugs hide:
# tests/unit/test_cart_edge_cases.py
import pytest
from app.services.cart import CartService
class TestCartEdgeCases:
def test_add_same_item_increments_quantity(self):
cart = CartService()
cart.add_item("item1", 10.00)
cart.add_item("item1", 10.00)
assert len(cart.items) == 1
assert cart.items[0]["quantity"] == 2
assert cart.total == 20.00
def test_remove_nonexistent_item_does_nothing(self):
cart = CartService()
cart.add_item("item1", 10.00)
cart.remove_item("nonexistent")
assert len(cart.items) == 1
def test_empty_cart_total_is_zero(self):
cart = CartService()
assert cart.total == 0.00
def test_add_item_with_custom_quantity(self):
cart = CartService()
cart.add_item("item1", 5.00, quantity=3)
assert cart.total == 15.00
4. Use Parametrized Tests for Multiple Scenarios
# tests/unit/test_auth_parametrized.py
import pytest
from app.services.auth import AuthService
@pytest.mark.parametrize("username,password,expected", [
("admin", "secret123", True),
("user", "password", True),
("admin", "wrong", False),
("unknown", "secret123", False),
("", "", False),
("admin", "", False),
])
def test_login_scenarios(username, password, expected):
auth = AuthService()
result = auth.login(username, password)
assert result is expected
5. Measure Test Coverage
Use pytest-cov to identify untested code paths:
pytest --cov=app --cov-report=term-missing --cov-report=html
This generates an HTML report showing exactly which lines of code are covered by your tests, helping you find gaps in your testing strategy.
6. Avoid Testing Flet Internals
Do not test that ft.TextField renders correctly or that ft.Column lays out children properly. Flet's own test suite covers these. Focus on testing your application logic, your control configurations, and your event handler behavior.
Running Your Tests
With everything in place, run your full test suite:
# Run all tests
pytest
# Run only unit tests
pytest tests/unit/
# Run only integration tests
pytest tests/integration/
# Run with verbose output
pytest -v
# Run a specific test file
pytest tests/unit/test_auth.py
# Run a specific test class
pytest tests/unit/test_auth.py::TestAuthService
# Run a specific test method
pytest tests/unit/test_auth.py::TestAuthService::test_successful_login
Conclusion
Testing Flet applications follows the same fundamental principles as testing any Python application, with the added consideration of mocking the Flet page and control objects. By separating your business logic into service classes, using page controllers for UI orchestration, and leveraging pytest fixtures and mocks for the Flet-specific parts, you can build a comprehensive test suite that covers unit, integration, and edge-case scenarios. Start with unit tests for your service layer, add mocked tests for your event handlers, and finish with integration tests that verify navigation and full user flows. This layered approach gives you confidence that your Flet application will behave correctly as it grows, and makes refactoring safe and predictable.