← Back to DevBytes

Testing Flet Applications: Unit Tests to Integration

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles