Examples

This package provides ready to use page features and features for CRUD operations like CREATING, UPDATING, RETRIEVING and DELETING with a single element or with multiple elements. This section shows some examples how you can use these features in your own project.

All data related examples in this section use a simple bookstore application with the models Book, Author and Category, which are managed over the default Django admin.

Quickstart

The following table provides a quick overview of the main components and their usage:

Component

Link

Description

Django Admin Page Objects

Link

Ready-to-use page objects for login, index, change list and change form views

CRUD Integration

More Details: Link

Fully Implemented CREATE, READ, UPDATE operations for Django admin interface

Data Environment

Link

Load test data from Django fixtures or define custom data items for testing

Use Django Admin Page Objects

The package provides page objects for the most common Django admin pages:

Since the package cannot know where your application is running, you have to subclass the page objects and provide the URL schema of your deployment:

# file `lib/pages/django_admin_index_page.py`
import os

from balderhub.django.lib.pages.admin.index_page import IndexPage
from balderhub.url.lib.utils import Url


class DjangoAdminIndexPage(IndexPage):

    @property
    def applicable_on_url_schema(self) -> Url:
        return Url(f"http://example.com/admin/")

    def open(self):
        self.driver.navigate_to(self.applicable_on_url_schema)

Now you can assign the page objects to a device and use them inside your scenario or setup.

Note

If you use the contrib.crud features of this package, you can define the url by using the balderhub.django.contrib.data.scenario_features.GeneralAdminModelConfig.

When using this config feature, you do not need to overwrite any implementation of the pages. Just use the page subclass of the contribution packages AutoAddItemFormPage, AdminChangeItemFormPage, AdminChangeListPage, AutoIndexPage:

import os
import balder

import balderhub.django.contrib.data.pages
import balderhub.django.contrib.data.scenario_features

@balderhub.data.register_for_data_item(AuthorDataItem)
class AuthorAdminModelConfig(balderhub.django.contrib.data.scenario_features.GeneralAdminModelConfig):
    admin_root_url = Url(f"http://example.com/admin")
    app_name = 'bookstore'
    model_name = 'author'

class SetupExample(balder.Setup):

    class Client(balder.Device):

        author_config = AuthorAdminModelConfig()
        add_new_page = balderhub.django.contrib.crud.pages.admin.AutoAddItemFormPage()
        detail_page = balderhub.django.contrib.crud.pages.admin.AdminChangeItemFormPage()
        list_page = balderhub.django.contrib.crud.pages.admin.AdminChangeListPage()
        ...

The following example shows a fixture to log in into the django admin by using the LoginPage:

import os
import balder

from tests.lib.pages import DjangoAdminLoginPage, DjangoAdminIndexPage


class SetupExample(balder.Setup):

    class SuperuserClient(balder.Device):
        login_page = balderhub.django.lib.pages.admin.LoginPage()
        index_page = balderhub.django.lib.pages.admin.IndexPage()
        ...

    @balder.fixture('variation')
    def make_sure_to_be_logged_in(self):
        self.SuperuserClient.login_page.open()
        if self.SuperuserClient.login_page.is_applicable():
            username = ...
            password = ...

            self.SuperuserClient.login_page.input_username.type_text(username, clean_before=True)
            self.SuperuserClient.login_page.input_password.type_text(password, clean_before=True)
            self.SuperuserClient.login_page.btn_login.click()
            self.SuperuserClient.index_page.wait_for_page()

Work with the change-list page

The change-list page object gives you comfortable access to the header, the breadcrumbs, the search bar, the filter sidebar and of course the result table. The following scenario shows some typical interactions:

import balder
import balderhub.webdriver.lib.scenario_features

from tests.lib.pages import DjangoAdminChangeListPage


class ScenarioAdminChangeList(balder.Scenario):

    class Django(balder.Device):
        pass

    @balder.connect(Django, over_connection=balder.Connection)
    class Browser(balder.Device):
        webdriver = balderhub.webdriver.lib.scenario_features.WebdriverControlFeature()
        change_list_page = DjangoAdminChangeListPage()

    def test_list_table(self):
        # open the change list of the model `book` in the app `book`
        self.Browser.change_list_page.open(app='book', model='book')
        self.Browser.change_list_page.wait_for_page()

        table = self.Browser.change_list_page.content.result_table

        # access the column headers
        header_title = table.get_table_column_header_for('title')
        assert header_title.text == "TITLE"

        # access rows and single cells
        rows = table.get_rows()
        first_row = table.get_row_at(0)
        cell_title = first_row.get_cell_for('title')
        assert cell_title.text == "A Christmas Carol"

        # or address a cell directly by column name and row index
        cell_author = table.get_table_cell_for('author', 0)
        assert cell_author.text == "Dickens, Charles"

    def test_list_filters(self):
        self.Browser.change_list_page.open(app='book', model='book')
        self.Browser.change_list_page.wait_for_page()

        filter_sidebar = self.Browser.change_list_page.content.filter_sidebar
        filters = filter_sidebar.get_filters()
        author_filter = filters[1]
        assert author_filter.h3_title.text == "By author"
        choices = author_filter.get_choices()

Other useful elements of the change-list page are self.Browser.change_list_page.header (site name, username, logout button, theme toggle, …), self.Browser.change_list_page.breadcrumbs, self.Browser.change_list_page.btn_add (the ADD button) and self.Browser.change_list_page.content with input_search, btn_search, select_action, btn_action_go and span_result_count.

Work with the change-form page

The change-form page object knows the fieldsets and form fields of the Django admin form. The provided field containers support the standard admin widgets (text inputs, text areas, dates, foreign keys and many-to-many relations):

def test_change_book_title(self):
    # open the change form of the book with the primary key 1
    self.Browser.change_form_page.open(app='book', model='book', item_id=1)
    self.Browser.change_form_page.wait_for_page()

    form = self.Browser.change_form_page.content.form
    field_title = form.get_form_field_container_for(django_identifier='title')
    field_title.set_value('A new title')

    self.Browser.change_form_page.content.submit_row.btn_save.click()

Create a Data Environment

This is a short overview how you can create a data environment. Please refer to the balderhub-data documentation <https://hub.balder.dev/projects/data> for more details.

A data environment describes the data your test expects on the server side. It works with so called data items, which are simple typed classes that describe one record of your model. Similar to the existing Django models you can create the same data structure by defining subclass of balderhub.data.lib.utils.SingleDataItem.

Note

Note that the data structure you define here in the test does not have to be exactly the same as in the Django model. Always try to tailor it to your testing scope

# file `lib/utils/data/book_data_item.py`
from typing import Optional
import datetime

from balderhub.data.lib.utils import SingleDataItem

from .author_data_item import AuthorDataItem
from .category_data_item import CategoryDataItem


class BookDataItem(SingleDataItem):
    id: int
    title: str
    author: AuthorDataItem
    categories: list[CategoryDataItem]
    isbn: str
    summary: Optional[str]
    publication_date: Optional[datetime.date]
    price: Optional[float]
    pages: Optional[int]

    def get_unique_identification(self):
        return self.id

As you can see, data items can reference each other. The author field holds another data item and the categories field holds a list of data items - exactly like the ForeignKey and ManyToManyField of the Django model.

The data environment itself is a feature that derives from balderhub.data.lib.scenario_features.DataEnvironmentFeature. You have to implement the load_data() method, in which you add all data items that exist on the server:

# file `lib/setup_features/basic_data_environment_feature.py`
import balderhub.data.lib.scenario_features

from tests.lib.utils.data import AuthorDataItem


class BasicDataEnvironmentFeature(balderhub.data.lib.scenario_features.DataEnvironmentFeature):

    def load_data(self):
        self._add_data([
            AuthorDataItem(id=1, first_name='Jane', last_name='Austen'),
            AuthorDataItem(id=2, first_name='Charles', last_name='Dickens'),
            ...
        ])

Of course, adding every single item by hand does not scale. If your Django project already provides django fixtures, you can load them directly - see the next section.

Use the Django Mixin

If you would like to load test data from django fixtures you can also use the balderhub.django.contrib.data.utils.DataEnvironmentForDjangoMixin:

Assume your Django app ships fixtures like this one:

# file `app/bookstore/book/fixtures/books.yaml`
- model: book.book
  pk: 1
  fields:
    title: Pride and Prejudice
    author: 1
    isbn: '9780141439518'
    summary: A romantic novel following Elizabeth Bennet as she navigates issues of manners, morality, and marriage.
    publication_date: 1813-01-28
    price: '12.99'
    pages: 432
    categories: [1, 2, 3]
    created_at: '1970-01-01 00:00:00+00:00'
    updated_at: '1970-01-01 00:00:00+00:00'

Simply add the mixin to your data environment feature and use its load_from_django_fixture method inside load_data():

# file `lib/setup_features/basic_data_environment_feature.py`
import balderhub.data.lib.scenario_features

from balderhub.django.contrib.data.utils import DataEnvironmentForDjangoMixin
from tests.lib.utils.data import CategoryDataItem, AuthorDataItem, BookDataItem


class BasicDataEnvironmentFeature(
        balderhub.data.lib.scenario_features.DataEnvironmentFeature,
        DataEnvironmentForDjangoMixin,
):

    def load_data(self):
        # LOAD Category
        self._add_data(
            self.load_from_django_fixture(
                'app/bookstore/book/fixtures/categories.yaml',
                CategoryDataItem,
            )
        )

        # LOAD Author
        self._add_data(
            self.load_from_django_fixture(
                'app/bookstore/book/fixtures/authors.yaml',
                AuthorDataItem,
            )
        )

        # LOAD Book
        self._add_data(
            self.load_from_django_fixture(
                'app/bookstore/book/fixtures/books.yaml',
                BookDataItem,
                type_mapping=dict(price=float),
                ignore_fields=['created_at', 'updated_at'],
            )
        )

Some things worth knowing about load_from_django_fixture():

  • Relations are resolved automatically: If a data item field is another SingleDataItem (or a list of them), the mixin resolves the primary keys from the fixture to the already loaded data items. That is why the categories and authors are loaded before the books in the example above.

  • Type conversion: Django fixtures often store values as strings (like the price in the example). With the type_mapping argument you can provide a converter callable per field.

  • Ignoring fields: Fields that exist in the fixture but not in your data item (like created_at and updated_at) can be skipped with the ignore_fields argument.

  • Primary key field: By default the primary key of the fixture is mapped to the field id of your data item. If one of your data items uses a different primary key field name, overwrite the class attribute PK_FIELDS:

    class MyDataEnvironment(
            balderhub.data.lib.scenario_features.DataEnvironmentFeature,
            DataEnvironmentForDjangoMixin,
    ):
        PK_FIELDS = {
            BookDataItem: 'id',
            IsbnEntryDataItem: 'isbn',
        }
    

Use the CRUD Feature Factories for testing Django Admin Operations

The real power of this package is its integration with balderhub-crud. Instead of writing your own tests for CREATE, READ and UPDATE operations, you can use the ready-made scenarios of balderhub-crud and only provide the configuration for your models. The setup features balderhub.django.contrib.crud.setup_features.AdminSingleReader, balderhub.django.contrib.crud.setup_features.AdminSingleCreator, balderhub.django.contrib.crud.setup_features.AdminSingleUpdater and balderhub.django.contrib.crud.setup_features.AdminMultipleReader already know how the Django admin renders its forms and lists, so they work out of the box for default admin configurations.

Provide a model configuration

For every model you want to test, you need a balderhub.django.contrib.crud.scenario_features.GeneralAdminModelConfig. It describes where the admin lives and which fields are shown in the different admin views. Register it for your data item with @balderhub.data.register_for_data_item:

import os

import balderhub.data
import balderhub.django.contrib.crud.scenario_features
from balderhub.url.lib.utils import Url

from tests.lib.utils.data import BookDataItem


@balderhub.data.register_for_data_item(BookDataItem)
class GeneralAdminModelConfig(balderhub.django.contrib.crud.scenario_features.GeneralAdminModelConfig):
    admin_root_url = Url(f"http://example.com/admin")
    app_name = 'bookstore'
    model_name = 'book'

    def get_multiple_read_fields(self) -> list[str]:
        # by default all data item fields are expected in the change list -
        # remove the fields that are not part of `list_display` of your ModelAdmin
        result = super().get_multiple_read_fields()
        result.remove('summary')
        result.remove('categories')
        return result

Provide example data

The CRUD scenarios need to know which data they should use for creating and updating objects. For this, implement the example providers of balderhub-crud and register them for your data item as well:

import balder
import balderhub.data
import balderhub.crud.lib.scenario_features
from balderhub.data.lib.utils import NOT_DEFINABLE

from tests.lib.setup_features.basic_data_environment_feature import BasicDataEnvironmentFeature
from tests.lib.utils.data import BookDataItem, CategoryDataItem, AuthorDataItem


@balderhub.data.register_for_data_item(BookDataItem)
class CreateExampleProvider(balderhub.crud.lib.scenario_features.SingleCreateExampleProvider):

    class Server(balder.VDevice):
        env = BasicDataEnvironmentFeature()

    def get_valid_examples(self):
        category = self.Server.env.get(CategoryDataItem, 1)
        author = self.Server.env.get(AuthorDataItem, 1)
        return [
            self.NamedExample(
                name='New Book',
                data_item=BookDataItem(
                    id=NOT_DEFINABLE,  # the id will be assigned by the server
                    title='New very nice Book',
                    author=author,
                    categories=[category],
                    isbn='1234567890',
                    summary="This is a very nice book with an interesting content",
                    publication_date=None,
                    price=1.99,
                    pages=123,
                )
            )
        ]

    def get_invalid_examples(self):
        return []

Assemble the setup

Now everything comes together in a setup class. Use the factories to create data-item bound versions of the setup features - calling get_for(<YourDataItem>) returns a feature class that is registered for exactly this data item:

import balder

import balderhub.data.lib.setup_features.factories
import balderhub.crud.lib.setup_features.factories
import balderhub.django.contrib.crud.pages
import balderhub.django.contrib.crud.setup_features

from tests.lib.setup_features.basic_data_environment_feature import BasicDataEnvironmentFeature
from tests.lib.setup_features.data import book
from tests.lib.setup_features.selenium_feature import SeleniumFeature
from tests.lib.pages import DjangoAdminIndexPage, DjangoAdminLoginPage
from tests.lib.utils.data import BookDataItem


class SetupBook(balder.Setup):

    class Server(balder.Device):
        env = BasicDataEnvironmentFeature()
        initial_data = balderhub.data.lib.setup_features.factories.AutoInitialDataConfigFactory.get_for(BookDataItem)()

    @balder.connect(Server, over_connection=balder.Connection)
    class SuperuserClient(balder.Device):

        selenium = SeleniumFeature()

        # the model configuration and the example providers from above
        admin_model_config = book.GeneralAdminModelConfig()
        example_create = book.CreateExampleProvider(Server='Server')
        example_update = book.UpdateFieldExampleProvider()
        example_single_read = balderhub.crud.lib.setup_features.factories.AutoSingleReadExampleFactory.get_for(BookDataItem)()

        # the page objects
        login_page = DjangoAdminLoginPage()
        index_page = DjangoAdminIndexPage()
        page_add = balderhub.django.contrib.crud.pages.AdminAddItemFormPage()
        page_update = balderhub.django.contrib.crud.pages.AdminChangeItemFormPage()
        page_list = balderhub.django.contrib.crud.pages.AdminChangeListPage()

        # the CRUD setup features, created by the factories of this package
        multiple_reader = balderhub.django.contrib.crud.setup_features.factories.AutoAdminMultipleReaderFactory.get_for(BookDataItem)()
        single_reader = balderhub.django.contrib.crud.setup_features.factories.AutoAdminSingleReaderFactory.get_for(BookDataItem)()
        single_creator = balderhub.django.contrib.crud.setup_features.factories.AutoAdminSingleCreatorFactory.get_for(BookDataItem)()
        single_updater = balderhub.django.contrib.crud.setup_features.factories.AutoAdminSingleUpdaterFactory.get_for(BookDataItem)()

        multiple_data_with_auth = balderhub.data.lib.setup_features.factories.AutoAccessibleInitialDataConfigFactory.get_for(BookDataItem)(Master="Server")

Activate the ready-made scenarios

Finally, activate the CRUD scenarios of balderhub-crud by simply importing them into your test environment:

from balderhub.crud.scenarios import (
    ScenarioSingleRead,
    ScenarioMultipleRead,
    ScenarioSingleUpdate,
    ScenarioSingleCreate,
)

That’s all. When you now run Balder, the imported scenarios will match with your setup and automatically test that:

  • a single object can be read over the admin change form and shows the expected data (ScenarioSingleRead)

  • the admin change list shows all expected objects with the expected values (ScenarioMultipleRead)

  • new objects can be created over the admin add form (ScenarioSingleCreate)

  • existing objects can be updated over the admin change form (ScenarioSingleUpdate)

  • optional fields are really optional, vice versa for mandatory fields

  • specific defined invalid examples show the correct and expected error messages

If your admin views deviate from the default configuration (custom widgets, custom field rendering, …), you can subclass the setup features (for example balderhub.django.contrib.crud.setup_features.AdminSingleReader) instead of using the factories and overwrite the relevant methods like item_mapping() or load().