Part 1: Develop a Login Test from Scratch

As described in the Balder Intro Example, we want to test the official Nextcloud app. For this, we set up Docker to be able to test the application locally.

Note

Make sure you have completed the preparation steps and installed Docker, Docker Compose, and Balder. You can find an explanation about that in Balder Intro Example.

In this first part of this tutorial, we’ll test the login process of the Nextcloud app. Normally, we would use the ScenarioSimpleLogin from the balderhub-auth package, since the test is already implemented there. However, for this tutorial, we’ll implement the test from scratch to help you understand the process better.

Prepare Environment

Before we start, we need to make sure that our test environment is ready.

Start Docker container

Let’s verify that the docker containers are running:

$ docker compose up -d

Open the browser and go to http://localhost:8000. You should see the NextCloud login page, we want to test now.

Create initial filestructure

Let’s start by creating some files in an easy-to-use file structure:

- <your project name>
    | - src
        | - lib
            | - scenario_features.py
            | - setup_features.py
        | - scenarios/
            | - scenario_login.py
        | - setups/
            | - setup_docker.py

This design adheres to the design that is frequently used in Balderhub projects and also by the template generator for BalderHub projects.

Install balderhub-html

We want to develop a test from scratch, but we want to develop a web test. Our live will get much simpler, when we are using the package balderhub-html. This project provides html components, we can directly use in our tests, which provides different methods to make sure that it reduces flaky tests, which is often the case for asynchron web apps.

We want to develop a test from scratch, specifically a web test. Our lives will become much simpler if we use the package balderhub-html for that. This package provides HTML components that we can directly use in our tests. These components offer various methods to reduce flaky tests, which are common in web apps.

Note

When using balderhub-html, you can write web tests without worrying about how to control them. This package requires a control feature that implements the balderhub-guicontrol interface, but as an end user, this detail is completely irrelevant. Simply install the GUI control package of your choice (we’re using balderhub-selenium in this tutorial, but you can also use balderhub-appium (coming soon), balderhub-playwright (coming soon), or any other package that supports the balderhub-guicontrol interface).

You can install this package with:

$ pip install balderhub-html

Developing the Test Scenario

So let’s start by developing a test scenario. For this, we need to define what is needed to execute the contained test.

We want to write a test that opens the login page, enters a username and password, and presses the login button. After that, we also want to check if the user is really logged in.

So let’s start defining such a scenario. Often, it helps to just write down what you want to have. In Balder, everything is organized around devices, and these devices have features. For example, we could have a LoginFeature feature, and we can think about having two different devices: a Browser and a WebServer. So let’s start with that:

# file `scenarios/scenario_login.py`

import balder

from lib.scenario_features import LoginFeature

class ScenarioLogin(balder.Scenario):

    class WebServer(balder.Device):
        pass

    @balder.connect('WebServer', over_connection=balder.Connection())
    class Browser(balder.Device):
        login = LoginFeature()

    def test_login(self):
        username = "admin"  # TODO
        password = "Admin12345"  # TODO

        assert not self.Browser.login.user_is_logged_in(), "some user is already logged in"

        self.Browser.login.type_username(username)
        self.Browser.login.type_password(password)
        self.Browser.login.submit_login()

        assert self.Browser.login.user_is_logged_in(), "user was not logged in"

Note that we’ve set hardcoded values for the username and password for now. These will be replaced later, as we’re going to develop a scenario that can be used for all kinds of logins - not just our specific case with NextCloud and with the specific username and password. For the time being, we’ll leave them as they are.

We’ve added an import for our future feature LoginFeature, which isn’t implemented yet. Since we’re working on the scenario, this feature should be placed in lib/scenario_features.py. Now, let’s define it. Before doing that, take a look at our test method itself: Which methods do we need in our future feature?

We are using the methods user_is_logged_in(), type_username(), type_password(), and submit_login(). Okay, that’s it - these will be the methods for our future feature LoginFeature. Let’s define it now.

Define our Scenario-Level-Feature

On the scenario level, we define what is needed without necessarily providing an exact implementation of how it is realized.

With that in mind, we’ll define this feature using abstract methods only:

# file `lib/scenario_features.py`

import balder

class LoginFeature(balder.Feature):

    def user_is_logged_in(self):
        raise NotImplementedError

    def type_username(self, username: str):
        raise NotImplementedError

    def type_password(self, password: str):
        raise NotImplementedError

    def submit_login(self):
        raise NotImplementedError

That’s it. Everything on the scenario level is now defined.

That concludes the first part. We’ve created a login test that can be reused for various purposes. It doesn’t matter whether you want to test the login of a website (as we’re doing here) or something entirely different, like the login on an electric door gate, for example.

Provide the implementation with a Setup

When we run Balder later, it will try to find matches between the scenario and our defined setup classes. To do this, Balder checks if there is at least one device in the setup that provides an implementation for every feature in our scenario device. An implementation is provided by a feature that is a subclass of the corresponding scenario feature.

If we use more than one feature in the scenario class, Balder will also check for other devices that fulfill the same feature implementation conditions. Additionally, it validates that these devices are connected using the exact connections specified.

You can read more about the mechanism of how Balder works in this guide. For details on how connections can be used to select specific variations, see this guide.

Define the Setup

But for now, let’s start by defining a setup that can be used for our specific case:

import balder
from lib.setup_features import LoginFeature

class SetupDocker(balder.Setup):

    class NextCloud(balder.Device):
        pass

    @balder.connect("NextCloud", over_connection=balder.Connection())
    class SeleniumBrowser(balder.Device):
        login_func = LoginFeature()

We directly imported a non-existent feature called LoginFeature from lib.setup_features. This feature doesn’t exist yet, but we’ll define it shortly to provide the implementation for our scenario feature lib.scenario_features.LoginFeature.

Define the Setup-Based LoginFeature

Now, let’s define this feature by creating a new class in lib/setup_features.py. This class should inherit directly from lib.scenario_features.LoginFeature and provide implementations for all the abstract methods:

# file `lib/setup_features.py`

import balder
import lib.scenario_features

class LoginFeature(lib.scenario_features.LoginFeature):

    def user_is_logged_in(self):
        # todo provide an implementation
        pass

    def type_username(self, username: str):
        # todo provide an implementation
        pass

    def type_password(self, password: str):
        # todo provide an implementation
        pass

    def submit_login(self):
        # todo provide an implementation
        pass

Here, we’ll add our implementation soon. But for now, this is enough to run Balder:

$ balder --working-dir src
+----------------------------------------------------------------------------------------------------------------------+
| BALDER Testsystem                                                                                                    |
|  python version 3.12.3 (main, Aug 14 2025, 17:47:21) [GCC 13.3.0] | balder version 0.1.0b14                          |
+----------------------------------------------------------------------------------------------------------------------+
Collect 1 Setups and 1 Scenarios
  resolve them to 1 valid variations

================================================== START TESTSESSION ===================================================
SETUP SetupDocker
  SCENARIO ScenarioLogin
    VARIATION ScenarioLogin.Browser:SetupDocker.SeleniumBrowser | ScenarioLogin.WebServer:SetupDocker.NextCloud
      TEST ScenarioLogin.test_login [X]
================================================== FINISH TESTSESSION ==================================================
TOTAL NOT_RUN: 0 | TOTAL FAILURE: 1 | TOTAL ERROR: 0 | TOTAL SUCCESS: 0 | TOTAL SKIP: 0 | TOTAL COVERED_BY: 0

Traceback (most recent call last):
  File "/home/user/temp_balder_tutorial/.venv/lib/python3.12/site-packages/_balder/executor/testcase_executor.py", line 132, in _body_execution
    self.base_testcase_callable(self=self.base_testcase_obj, **all_args)
  File "/home/user/temp_balder_tutorial/src/scenarios/scenario_login.py", line 25, in test_login
    assert self.Browser.login.user_is_logged_in(), "user was not logged in"
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: user was not logged in

Of course, we get an error, because we haven’t provided any implementation and the test does not really do something (the methods are still empty), but everything gets collected and Balder can find the match.

Of course, we get an error because we haven’t provided any implementation yet, and the test doesn’t really do anything (the methods are still empty). However, everything gets collected, and Balder can find the match.

Provide an Implementation for the Scenario-Based LoginFeature

When we want to provide an implementation for the lib.setup_features.LoginFeature, we can either write it from scratch - importing Selenium, setting it up, handling waiting functions, and so on - or we can simply use balderhub-html and balderhub-selenium.

So let’s make sure, that we have installed them:

$ pip install balderhub-html balderhub-selenium

Before we add the html elements, let’s add the selenium feature. We need a feature that supports the guicontrol interface (see balderhub-guicontrol). The balderhub-guicontrol packages handle all the required management for you, so you do not need to do something, except using one of the features that implements its interfaces.

Before we add the HTML elements, let’s incorporate the Selenium feature. We need a feature that supports the guicontrol interface (see balderhub-guicontrol). The balderhub-guicontrol packages handle all the required management for you, so you don’t need to do anything special - except to use one of the features that implements this interface.

# file `lib/setup_features.py`

import balder
import lib.scenario_features
from balderhub.selenium.lib.scenario_features import SeleniumFeature

class LoginFeature(lib.scenario_features.LoginFeature):

    selenium = SeleniumFeature()

    ...

As you can see, we’ve added a SeleniumFeature from balderhub-selenium. But wait - this is a feature within a feature. What does that mean? It means Balder will ensure that whenever this feature is used, the instantiating device must also have a SeleniumFeature (or a subclass of it). Balder will then automatically assign that instance to the selenium class attribute of our LoginFeature. You don’t need to do anything special for this; Balder handles it all behind the scenes. However, you can certainly make use of it in your code.

So, let’s take advantage of it and provide the implementation:

# file `lib/setup_features.py`

import balder
import lib.scenario_features
from balderhub.html.lib.utils import Selector
import balderhub.html.lib.utils.components as html
from balderhub.selenium.lib.scenario_feature import SeleniumFeature

class LoginFeature(lib.scenario_features.LoginFeature):

    selenium = SeleniumFeature()

    # the url to navigate to be able to login
    login_url = "http://nextcloud/login"

    @property
    def input_username(self):
        # html <input> element where we can type in the username
        return html.inputs.HtmlTextInput.by_selector(self.selenium.driver, Selector.by_name('user'))

    @property
    def input_password(self):
        # html <input> element where we can type in the password
        return html.inputs.HtmlPasswordInput.by_selector(self.selenium.driver, Selector.by_name('password'))

    @property
    def btn_login(self):
        # html <button> element that needs to be clicked to submit the login
        return html.HtmlButtonElement.by_selector(self.selenium.driver, Selector.by_xpath('.//button[@type="submit"]'))

    def user_is_logged_in(self):
        self.selenium.driver.navigate_to(self.login_url)
        return self.selenium.driver.current_url != self.login_url

    def type_username(self, username: str):
        self.input_username.wait_to_be_clickable_for(3).type_text(username, clean_before=True)

    def type_password(self, password: str):
        self.input_password.wait_to_be_clickable_for(3).type_text(password, clean_before=True)

    def submit_login(self):
        self.btn_login.wait_to_be_clickable_for(3).click()

That’s it. The test is now ready.

Note

For larger projects, it is recommended to use the balderhub.html.lib.scenario_features.HtmlPage. class. This class follows the page-object model, where you directly describe your webpage, including all its HTML elements, and interact with them in a straightforward way. Everything is organized within its own dedicated feature.

Using this approach, the implementation would look like the example shown below:

class LoginFeature(lib.scenario_features.LoginFeature):

    # instead of adding all the html elements within this feature, we define them in the html page
    # `NextcloudLoginPage` and using them here
    login_page = NextcloudLoginPage()

    def user_is_logged_in(self):
        self.login_page.open()
        return not self.login_page.is_applicable()

    def type_username(self, username: str):
        self.login_page.input_username.wait_to_be_clickable_for(3).type_text(username, clean_before=True)

    def type_password(self, password: str):
        self.login_page.input_password.wait_to_be_clickable_for(3).type_text(password, clean_before=True)

    def submit_login(self):
        self.login_page.btn_login.wait_to_be_clickable_for(3).click()

Set up Selenium in the Setup

Before we finally run Balder, we need to add our Selenium feature. This is required because, with the class attribute selenium = SeleniumFeature(), we’ve specified that Balder should ensure our setup provides a Selenium feature as well.

As we are using selenium-grid within a own docker container, we need to use the balderhub.selenium.lib.setup_features.SeleniumRemoteWebdriverFeature. As we are using firefox, we also need to specify the correct selenium_options. You can read more about that in the balderhub-selenium documentation

So, let’s do this:

import balder
from lib.setup_features import LoginFeature
from selenium import webdriver
from balderhub.selenium.lib.setup_features import SeleniumRemoteWebdriverFeature

class SeleniumManagerFeature(SeleniumRemoteWebdriverFeature):
    # use this feature if you are using selenium grid as docker container
    selenium_options = webdriver.FirefoxOptions()

class SetupDocker(balder.Setup):

    class NextCloud(balder.Device):
        pass

    @balder.connect("NextCloud", over_connection=balder.Connection())
    class SeleniumBrowser(balder.Device):
        selenium = SeleniumManagerFeature()
        login_func = LoginFeature()

Note

In our example, we’re using the SeleniumRemoteWebdriverFeature, which allows us to connect to a Selenium Grid container. If you prefer to use Selenium with a browser on your host machine instead, you’ll need to select the appropriate driver for that browser. You can find more details about this in the balderhub-selenium documentation.

Last but not least, we need to make sure that Selenium is set up before the test is executed. For that, let’s use fixtures (see Fixtures):

import balder
from lib.setup_features import LoginFeature
from selenium import webdriver
from balderhub.selenium.lib.setup_features import SeleniumRemoteWebdriverFeature

...

class SetupDocker(balder.Setup):

    ...

    # register this fixture as a session fixture - meaning it will be executed once before/after the whole test session
    @balder.fixture('session')
    def selenium_manager(self):
        # creates a new selenium connection before the test run
        self.SeleniumBrowser.selenium.create()
        yield # can be used to separate construction code (before session) and teardown code (after session)
        # shuts down selenium after the test run
        self.SeleniumBrowser.selenium.quit()

Let’s run Balder and verify if it executes successfully. You can observe the test run directly in your browser or through the Selenium Grid website at http://localhost:4444 (if you’ve added the container to Docker Compose).

$ balder --working-dir src
+----------------------------------------------------------------------------------------------------------------------+
| BALDER Testsystem                                                                                                    |
|  python version 3.12.3 (main, Aug 14 2025, 17:47:21) [GCC 13.3.0] | balder version 0.1.0b14                          |
+----------------------------------------------------------------------------------------------------------------------+
Collect 1 Setups and 1 Scenarios
  resolve them to 1 valid variations

================================================== START TESTSESSION ===================================================
SETUP SetupDocker
  SCENARIO ScenarioLogin
    VARIATION ScenarioLogin.Browser:SetupDocker.SeleniumBrowser | ScenarioLogin.WebServer:SetupDocker.NextCloud
      TEST ScenarioLogin.test_login [.]
================================================== FINISH TESTSESSION ==================================================
TOTAL NOT_RUN: 0 | TOTAL FAILURE: 0 | TOTAL ERROR: 0 | TOTAL SUCCESS: 1 | TOTAL SKIP: 0 | TOTAL COVERED_BY: 0

Okay, but now it’s time to install tests. This significantly speeds up the test development process. So, let’s jump to Part 2 of this tutorial and install tests for validating file operations within the Nextcloud web app.