Test a Flask App with Selenium WebDriver - Part 1

Mbithe Nzomo

Ever wondered how to write tests for the front-end of your web application? You may already have functional back-end tests, for example to ensure that your models and views are working. However, you may be unsure how to simulate a user of your app for testing. How can you test front-end functions like registration and logging in, which are done in a browser?

In this two-part tutorial, I will show you how to write front-end tests for an existing Python/Flask web application. You should therefore already have a functional application, along with a virtual environment with the necessary software dependencies installed. We will use Project Dream Team, a CRUD web app I built in a three-part tutorial (here is Part One, Part Two and Part Three). I recommend that you go through the tutorial and build out the app, especially if you are new to Flask or to testing in Flask. If you have built and tested a Flask app before, or don't want to go through the tutorial, you can clone the complete app from my GitHub repository, here and follow the set-up instructions in the README file.

Testing Overview

To recap, the application we will be testing is an employee management app. The app allows users to register as employees and login. It also allows admin users to view all employees, as well as to create departments and roles, and then assign them to each employee.

We will therefore write tests to ensure all these functions are achieved in the app. If you followed the tutorial, you should already have a test database set up. If not, you can create one and grant your database user privileges on it, as follows:

$ mysql -u root

mysql> CREATE DATABASE dreamteam_test;
Query OK, 1 row affected (0.00 sec)

mysql> GRANT ALL PRIVILEGES ON dreamteam_test . * TO 'dt_admin'@'localhost';
Query OK, 0 rows affected (0.00 sec)

We use the test database to run our tests to prevent our test data from being saved into the main database we are using for development.

The back-end tests, which were already written in the app tutorial, test that the app's models and views are working as expected. The front-end tests, which we will write in this tutorial, will simulate a user using the app in the browser. For these tests, we will create test data, and then test the registration, login, and CRUD functionality of the app.

Test Directory Structure

We previously had only one test file, tests.py, in the root directory. Rather than have all the tests in one file, we shall create a tests directory. In it, create two new files: __init__.py and test_front_end.py. Move the tests.py file to the tests directory and rename it to test_back_end.py. Your tests directory should now look like this:

└── tests
    ├── __init__.py
    ├── test_back_end.py
    └── test_front_end.py

The __init__.py file initializes the tests directory as a Python package. test-back_end.py will contain all our back-end tests, while test_front_end.py will contain all our front-end tests. Update the test_back_end.py file as follows:

# tests/test_back_end.py

import unittest

from flask import abort, url_for
from flask_testing import TestCase

from app import create_app, db
from app.models import Department, Employee, Role

class TestBase(TestCase):

    def create_app(self):

        # pass in test configurations
        config_name = 'testing'
        app = create_app(config_name)
        app.config.update(
            SQLALCHEMY_DATABASE_URI='mysql://dt_admin:dt2016@localhost/dreamteam_test'
        )
        return app

    def setUp(self):
        """
        Will be called before every test
        """

        db.session.commit()
        db.drop_all()
        db.create_all()

        # create test admin user
        admin = Employee(username="admin", password="admin2016", is_admin=True)

        # create test non-admin user
        employee = Employee(username="test_user", password="test2016")

        # save users to database
        db.session.add(admin)
        db.session.add(employee)
        db.session.commit()

    def tearDown(self):
        """
        Will be called after every test
        """

        db.session.remove()
        db.drop_all()

class TestModels(TestBase):

    def test_employee_model(self):
        """
        Test number of records in Employee table
        """
        self.assertEqual(Employee.query.count(), 2)

    def test_department_model(self):
        """
        Test number of records in Department table
        """

        # create test department
        department = Department(name="IT", description="The IT Department")

        # save department to database
        db.session.add(department)
        db.session.commit()

        self.assertEqual(Department.query.count(), 1)

    def test_role_model(self):
        """
        Test number of records in Role table
        """

        # create test role
        role = Role(name="CEO", description="Run the whole company")

        # save role to database
        db.session.add(role)
        db.session.commit()

        self.assertEqual(Role.query.count(), 1)

class TestViews(TestBase):

    def test_homepage_view(self):
        """
        Test that homepage is accessible without login
        """
        response = self.client.get(url_for('home.homepage'))
        self.assertEqual(response.status_code, 200)

    def test_login_view(self):
        """
        Test that login page is accessible without login
        """
        response = self.client.get(url_for('auth.login'))
        self.assertEqual(response.status_code, 200)

    def test_logout_view(self):
        """
        Test that logout link is inaccessible without login
        and redirects to login page then to logout
        """
        target_url = url_for('auth.logout')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

    def test_dashboard_view(self):
        """
        Test that dashboard is inaccessible without login
        and redirects to login page then to dashboard
        """
        target_url = url_for('home.dashboard')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

    def test_admin_dashboard_view(self):
        """
        Test that dashboard is inaccessible without login
        and redirects to login page then to dashboard
        """
        target_url = url_for('home.admin_dashboard')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

    def test_departments_view(self):
        """
        Test that departments page is inaccessible without login
        and redirects to login page then to departments page
        """
        target_url = url_for('admin.list_departments')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

    def test_roles_view(self):
        """
        Test that roles page is inaccessible without login
        and redirects to login page then to roles page
        """
        target_url = url_for('admin.list_roles')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

    def test_employees_view(self):
        """
        Test that employees page is inaccessible without login
        and redirects to login page then to employees page
        """
        target_url = url_for('admin.list_employees')
        redirect_url = url_for('auth.login', next=target_url)
        response = self.client.get(target_url)
        self.assertEqual(response.status_code, 302)
        self.assertRedirects(response, redirect_url)

class TestErrorPages(TestBase):

    def test_403_forbidden(self):
        # create route to abort the request with the 403 Error
        @self.app.route('/403')
        def forbidden_error():
            abort(403)

        response = self.client.get('/403')
        self.assertEqual(response.status_code, 403)
        self.assertTrue("403 Error" in response.data)

    def test_404_not_found(self):
        response = self.client.get('/nothinghere')
        self.assertEqual(response.status_code, 404)
        self.assertTrue("404 Error" in response.data)

    def test_500_internal_server_error(self):
        # create route to abort the request with the 500 Error
        @self.app.route('/500')
        def internal_server_error():
            abort(500)

        response = self.client.get('/500')
        self.assertEqual(response.status_code, 500)
        self.assertTrue("500 Error" in response.data)

if __name__ == '__main__':
    unittest.main()

Because we now have multiple test files, we need a way to run all the tests at once. It would be inconvenient to have to run each test file individually. For this we will use nose2, a package that extends Python's unit testing framework, unittest, and makes testing easier.

$ pip install nose2

Now, we will run the tests using the nose2 command. This command looks for all files whose names begin with "test", and runs the methods inside these files whose names begin with "test". This means that if you name your test files and methods incorrectly, they will not be run by nose2. First, remember to ensure that your MySQL server is running. You can do this by running the following command:

$ mysqld

Then, in another terminal window, run the tests:

$ nose2
..............
----------------------------------------------------------------------
Ran 14 tests in 2.582s

OK

Introduction to Selenium WebDriver

Selenium is a suite of tools to automate web browsers for a variety of purposes. Selenium WebDriver in particular is useful when writing browser-based tests.

Begin by installing Selenium:

$ pip install selenium

Selenium Set-Up

Now, let's set up Selenium. We are using Flask Testing, an extension that provides unit testing utilities for Flask. To use Selenium with Flask Testing, we need to inherit from the LiveServerTestCase class, which will allow us to run a live server during our tests.

We will use ChromeDriver, a WebDriver for Chrome. This will allow us to run the front-end tests on the Chrome browser. If you are on MacOS, you can install it simply using the brew install chromedriver command. For other platforms, follow this Getting Started guide to download and properly set up ChromeDriver. Note that you will need to have Chrome browser installed.

If you prefer to use Firefox broswer for your tests, you can install and set up FirefoxDriver instead.

For the front-end tests, we will begin by creating test data that will be added to the database before each test runs. This includes two test users, two test departments, and two test roles. Add the following code to tests/test_front_end.py:

# tests/front_end_tests.py

import unittest
import urllib2

from flask_testing import LiveServerTestCase
from selenium import webdriver

from app import create_app, db
from app.models import Employee, Role, Department

# Set test variables for test admin user
test_admin_username = "admin"
test_admin_email = "admin@email.com"
test_admin_password = "admin2016"

# Set test variables for test employee 1
test_employee1_first_name = "Test"
test_employee1_last_name = "Employee"
test_employee1_username = "employee1"
test_employee1_email = "employee1@email.com"
test_employee1_password = "1test2016"

# Set test variables for test employee 2
test_employee2_first_name = "Test"
test_employee2_last_name = "Employee"
test_employee2_username = "employee2"
test_employee2_email = "employee2@email.com"
test_employee2_password = "2test2016"

# Set variables for test department 1
test_department1_name = "Human Resources"
test_department1_description = "Find and keep the best talent"

# Set variables for test department 2
test_department2_name = "Information Technology"
test_department2_description = "Manage all tech systems and processes"

# Set variables for test role 1
test_role1_name = "Head of Department"
test_role1_description = "Lead the entire department"

# Set variables for test role 2
test_role2_name = "Intern"
test_role2_description = "3-month learning position"

class TestBase(LiveServerTestCase):

    def create_app(self):
        config_name = 'testing'
        app = create_app(config_name)
        app.config.update(
            # Specify the test database
            SQLALCHEMY_DATABASE_URI='mysql://dt_admin:dt2016@localhost/dreamteam_test',
            # Change the port that the liveserver listens on
            LIVESERVER_PORT=8943
        )
        return app

    def setUp(self):
        """Setup the test driver and create test users"""
        self.driver = webdriver.Chrome()
        self.driver.get(self.get_server_url())

        db.session.commit()
        db.drop_all()
        db.create_all()

        # create test admin user
        self.admin = Employee(username=test_admin_username,
                              email=test_admin_email,
                              password=test_admin_password,
                              is_admin=True)

        # create test employee user
        self.employee = Employee(username=test_employee1_username,
                                 first_name=test_employee1_first_name,
                                 last_name=test_employee1_last_name,
                                 email=test_employee1_email,
                                 password=test_employee1_password)

        # create test department
        self.department = Department(name=test_department1_name,
                                     description=test_department1_description)

        # create test role
        self.role = Role(name=test_role1_name,
                         description=test_role1_description)

        # save users to database
        db.session.add(self.admin)
        db.session.add(self.employee)
        db.session.add(self.department)
        db.session.add(self.role)
        db.session.commit()

    def tearDown(self):
        self.driver.quit()

    def test_server_is_up_and_running(self):
        response = urllib2.urlopen(self.get_server_url())
        self.assertEqual(response.code, 200)

if __name__ == '__main__':
    unittest.main()

At the beginning of the file, right after the imports, we begin by creating test variables which we will use in the tests. These include details for three test users, one admin and the other two non-admin, as well as two test departments and two test roles.

Next, we create a TestBase class, which subsequent test classes will inherit from. In it, we have the create_app method, which Flask Testing requires to return an app instance. In this method, we specify the Flask configuration and test database, just like we did in the back-end tests. We also specify the port that the live server will run on. Flask uses port 5000 by default. Changing this for the live server will ensure that the tests run on another port (in this case, port 8943), thus preventing any conflict with another Flask app that could be running.

In the setUp method, we set up the test driver as the ChromeDriver. (If using another webdriver, such as Firefox for example, use self.driver = webdriver.Firefox().) Take note of the self.get_server_url method; we use this to get the home URL. We also create a test employee, test admin user, test department and test role using the variables we created initially. In the tearDown method, we quit the webdriver, and clear the test database. The setUp method is called before every test, while the tearDown method is called after each test. It is important to have the test data before the tests run because most of our tests will require existing users, departments and roles. It would be repetitive to create new users, departments and roles for each test, so it is simpler and DRY-er to do it in the setUp method.

We have one test method in the base class, test_server_is_up_and_running. This method simply ensures that the test server is working as expected and returns a 200 OK status code.

Run the tests using the nose2 command:

$ nose2
...............
----------------------------------------------------------------------
Ran 15 tests in 4.313s

OK

It shows that it ran 15 tests, which includes the 14 back-end tests that we had initially, as well as 1 front-end test.

Finding Elements

So far, we have only one test in the test_front_end.py file. We need to write additional tests that simulate a user actually using the app, such as clicking links and buttons, entering data into forms, and so on. To tell Selenium to click a particular button or enter data in a particular field in a form, we need to use unique identifiers for these web elements.

Selenium provides methods that we can call to find web elements in a variety of ways, such as by id, by name, by XPath, and by CSS selector. I find that locating an element by id is one of the simplest ways; it only requires you to give an id attribute to the element that needs to be located.

We'll start by assigning id attributes to the menu links in the app so that we can direct the test user to click them as required. Open the templates/base.html file and update the menu items with id attributes as follows:

<!-- app/templates/base.html -->

<!-- Modify nav bar menu -->
<div class="collapse navbar-collapse" id="bs-example-navbar-collapse-1">
    <ul class="nav navbar-nav navbar-right">
        {% if current_user.is_authenticated %}
          {% if current_user.is_admin %}
            <li id="dashboard_link_admin"><a href="{{ url_for('home.admin_dashboard') }}">Dashboard</a></li>
            <li id="departments_link"><a href="{{ url_for('admin.list_departments') }}">Departments</a></li>
            <li id="roles_link"><a href="{{ url_for('admin.list_roles') }}">Roles</a></li>
            <li id="employees_link"><a href="{{ url_for('admin.list_employees') }}">Employees</a></li>
          {% else %}
            <li id="dashboard_link_employee"><a href="{{ url_for('home.dashboard') }}">Dashboard</a></li>
          {% endif %}
          <li id="logout_link"><a href="{{ url_for('auth.logout') }}">Logout</a></li>
          <li id="username_greeting"><a><i class="fa fa-user"></i>  Hi, {{ current_user.username }}!</a></li>
        {% else %}
          <li id="home_link"><a href="{{ url_for('home.homepage') }}">Home</a></li>
          <li id="register_link"><a href="{{ url_for('auth.register') }}">Register</a></li>
          <li id="login_link"><a href="{{ url_for('auth.login') }}">Login</a></li>
        {% endif %}
    </ul>
</div>

Most other web elements that we will need to locate already have id attributes. For those that don't, we will find them by their CSS class names and selectors.

Testing the Auth Blueprint

We will begin by writing tests for the Auth blueprint, which handles registration, login, and logout. These tests need to handle edge cases, such as users entering invalid data, so that we can ensure the app responds appropriately.

Registration Tests

We'll start by writing the registration tests. Add the following code to the test_front_end.py file, after the TestBase class:

# tests/front_end_tests.py

# update imports
import time
from flask import url_for

class TestRegistration(TestBase):

    def test_registration(self):
        """
        Test that a user can create an account using the registration form
        if all fields are filled out correctly, and that they will be 
        redirected to the login page
        """

        # Click register menu link
        self.driver.find_element_by_id("register_link").click()
        time.sleep(1)

        # Fill in registration form
        self.driver.find_element_by_id("email").send_keys(test_employee2_email)
        self.driver.find_element_by_id("username").send_keys(
            test_employee2_username)
        self.driver.find_element_by_id("first_name").send_keys(
            test_employee2_first_name)
        self.driver.find_element_by_id("last_name").send_keys(
            test_employee2_last_name)
        self.driver.find_element_by_id("password").send_keys(
            test_employee2_password)
        self.driver.find_element_by_id("confirm_password").send_keys(
            test_employee2_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(1)

        # Assert that browser redirects to login page
        assert url_for('auth.login') in self.driver.current_url

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully registered" in success_message

        # Assert that there are now 3 employees in the database
        self.assertEqual(Employee.query.count(), 3)

    def test_registration_invalid_email(self):
        """
        Test that a user cannot register using an invalid email format
        and that an appropriate error message will be displayed
        """

        # Click register menu link
        self.driver.find_element_by_id("register_link").click()
        time.sleep(1)

        # Fill in registration form
        self.driver.find_element_by_id("email").send_keys("invalid_email")
        self.driver.find_element_by_id("username").send_keys(
            test_employee2_username)
        self.driver.find_element_by_id("first_name").send_keys(
            test_employee2_first_name)
        self.driver.find_element_by_id("last_name").send_keys(
            test_employee2_last_name)
        self.driver.find_element_by_id("password").send_keys(
            test_employee2_password)
        self.driver.find_element_by_id("confirm_password").send_keys(
            test_employee2_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(5)

        # Assert error message is shown
        error_message = self.driver.find_element_by_class_name(
            "help-block").text
        assert "Invalid email address" in error_message

    def test_registration_confirm_password(self):
        """
        Test that an appropriate error message is displayed when the password 
        and confirm_password fields do not match
        """

        # Click register menu link
        self.driver.find_element_by_id("register_link").click()
        time.sleep(1)

        # Fill in registration form
        self.driver.find_element_by_id("email").send_keys(test_employee2_email)
        self.driver.find_element_by_id("username").send_keys(
            test_employee2_username)
        self.driver.find_element_by_id("first_name").send_keys(
            test_employee2_first_name)
        self.driver.find_element_by_id("last_name").send_keys(
            test_employee2_last_name)
        self.driver.find_element_by_id("password").send_keys(
            test_employee2_password)
        self.driver.find_element_by_id("confirm_password").send_keys(
            "password-won't-match")
        self.driver.find_element_by_id("submit").click()
        time.sleep(5)

        # Assert error message is shown
        error_message = self.driver.find_element_by_class_name(
            "help-block").text
        assert "Field must be equal to confirm_password" in error_message

We've created a class, TestRegistration, that inherits from the TestBase class, and has three test methods. Note that the if __name__ == '__main__': section in the file should always be at the bottom of the file, so any new code additions will go before it.

The test_registration method tests that a user can successfully create an account if they fill out all fields in the registration form correctly. It also tests that the user is redirected to the login page after registration and that a message is displayed inviting them to login. The test_registration_invalid_email method tests that entering an invalid email format in the email field will prevent the registration form from being submitted, and display an appropriate error message. The test_registration_confirm_password method tests that the data in the "Password" and "Confirm password" fields must match, and that an appropriate error message is displayed if they do not.

Take note of the send_keys method, which we use to enter data into form fields, and the click method, which we use to click on web elements such as buttons and links. Additionally, take note of the time.sleep method. We use this to pause the tests to give the browser time to load all web elements completely before proceeding to the next step.

Login Tests

The next tests are for logging in. Add a TestLogin class as follows:

# tests/front_end_tests.py

class TestLogin(TestBase):

    def test_login(self):
        """
        Test that a user can login and that they will be redirected to
        the homepage
        """

        # Click login menu link
        self.driver.find_element_by_id("login_link").click()
        time.sleep(1)

        # Fill in login form
        self.driver.find_element_by_id("email").send_keys(test_employee1_email)
        self.driver.find_element_by_id("password").send_keys(
            test_employee1_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert that browser redirects to dashboard page
        assert url_for('home.dashboard') in self.driver.current_url

        # Assert that welcome greeting is shown
        username_greeting = self.driver.find_element_by_id(
            "username_greeting").text
        assert "Hi, employee1!" in username_greeting

    def test_admin_login(self):
        """
        Test that an admin user can login and that they will be redirected to
        the admin homepage
        """

        # Click login menu link
        self.driver.find_element_by_id("login_link").click()
        time.sleep(1)

        # Fill in login form
        self.driver.find_element_by_id("email").send_keys(test_admin_email)
        self.driver.find_element_by_id("password").send_keys(
            test_admin_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert that browser redirects to dashboard page
        assert url_for('home.admin_dashboard') in self.driver.current_url

        # Assert that welcome greeting is shown
        username_greeting = self.driver.find_element_by_id(
            "username_greeting").text
        assert "Hi, admin!" in username_greeting

    def test_login_invalid_email_format(self):
        """
        Test that a user cannot login using an invalid email format
        and that an appropriate error message will be displayed
        """

        # Click login menu link
        self.driver.find_element_by_id("login_link").click()
        time.sleep(1)

        # Fill in login form
        self.driver.find_element_by_id("email").send_keys("invalid")
        self.driver.find_element_by_id("password").send_keys(
            test_employee1_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert error message is shown
        error_message = self.driver.find_element_by_class_name(
            "help-block").text
        assert "Invalid email address" in error_message

    def test_login_wrong_email(self):
        """
        Test that a user cannot login using the wrong email
        and that an appropriate error message will be displayed
        """

        # Click login menu link
        self.driver.find_element_by_id("login_link").click()
        time.sleep(1)

        # Fill in login form
        self.driver.find_element_by_id("email").send_keys(test_employee2_email)
        self.driver.find_element_by_id("password").send_keys(
            test_employee1_password)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert that error message is shown
        error_message = self.driver.find_element_by_class_name("alert").text
        assert "Invalid email or password" in error_message

    def test_login_wrong_password(self):
        """
        Test that a user cannot login using the wrong password
        and that an appropriate error message will be displayed
        """

        # Click login menu link
        self.driver.find_element_by_id("login_link").click()
        time.sleep(1)

        # Fill in login form
        self.driver.find_element_by_id("email").send_keys(test_employee1_email)
        self.driver.find_element_by_id("password").send_keys(
            "invalid")
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert that error message is shown
        error_message = self.driver.find_element_by_class_name("alert").text
        assert "Invalid email or password" in error_message

The test_login method tests that when the correct email and password combination is entered for a registered user, the user is redirected to the dashboard, where their username is displayed. It is the same as the test_admin_login method, except the latter tests login for admin users who are redirected to the admin dashboard. The test_invalid_email_format tests that users cannot submit the login form if they enter a non-email in the email field. The test_login_wrong_email and test_login_wrong_password methods test that a user cannot login using invalid credentials, and that an appropriate error message is displayed.

Run the tests again. You'll notice that for the front-end tests, a browser window will open. It will simulate a user clicking links and filling out forms as specified in the tests we wrote. The output of running the tests should be similar to this:

$ nose2
........................
----------------------------------------------------------------------
Ran 25 tests in 96.581s

OK

Testing the Admin Blueprint

Next, we will write tests for the Admin blueprint, which is where the CRUD functionality of the application is. In these tests, we will simulate an admin user creating, viewing, updating, and deleting departments and roles. We will also simulate an admin user assigning departments and roles to employees.

Because we will need to log in multiple times before being able to do any of this, we will create a method that we will call every time we need to log in as a user.

In your front_end_tests.py file, add a CreateObjects class, just after the test variables and before the TestRegistration class:

# tests/front_end_tests.py

# after initialization of test variables

class CreateObjects(object):

    def login_admin_user(self):
        """Log in as the test employee"""
        login_link = self.get_server_url() + url_for('auth.login')
        self.driver.get(login_link)
        self.driver.find_element_by_id("email").send_keys(test_admin_email)
        self.driver.find_element_by_id("password").send_keys(
            test_admin_password)
        self.driver.find_element_by_id("submit").click()

    def login_test_user(self):
        """Log in as the test employee"""
        login_link = self.get_server_url() + url_for('auth.login')
        self.driver.get(login_link)
        self.driver.find_element_by_id("email").send_keys(test_employee1_email)
        self.driver.find_element_by_id("password").send_keys(
            test_employee1_password)
        self.driver.find_element_by_id("submit").click()

Now, if we need to be logged in as a particular type of user in any of our tests, we can simply call the relevant method.

Department Tests

Next, we'll write our tests. Add a TestDepartments class after the TestLogin class, as follows:

# tests/front_end_tests.py

class TestDepartments(CreateObjects, TestBase):

    def test_add_department(self):
        """
        Test that an admin user can add a department
        """

        # Login as admin user
        self.login_admin_user()

        # Click departments menu link
        self.driver.find_element_by_id("departments_link").click()
        time.sleep(1)

        # Click on add department button
        self.driver.find_element_by_class_name("btn").click()
        time.sleep(1)

        # Fill in add department form
        self.driver.find_element_by_id("name").send_keys(test_department2_name)
        self.driver.find_element_by_id("description").send_keys(
            test_department2_description)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully added a new department" in success_message

        # Assert that there are now 2 departments in the database
        self.assertEqual(Department.query.count(), 2)

    def test_add_existing_department(self):
        """
        Test that an admin user cannot add a department with a name
        that already exists
        """

        # Login as admin user
        self.login_admin_user()

        # Click departments menu link
        self.driver.find_element_by_id("departments_link").click()
        time.sleep(1)

        # Click on add department button
        self.driver.find_element_by_class_name("btn").click()
        time.sleep(1)

        # Fill in add department form
        self.driver.find_element_by_id("name").send_keys(test_department1_name)
        self.driver.find_element_by_id("description").send_keys(
            test_department1_description)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert error message is shown
        error_message = self.driver.find_element_by_class_name("alert").text
        assert "Error: department name already exists" in error_message

        # Assert that there is still only 1 department in the database
        self.assertEqual(Department.query.count(), 1)

    def test_edit_department(self):
        """
        Test that an admin user can edit a department
        """

        # Login as admin user
        self.login_admin_user()

        # Click departments menu link
        self.driver.find_element_by_id("departments_link").click()
        time.sleep(1)

        # Click on edit department link
        self.driver.find_element_by_class_name("fa-pencil").click()
        time.sleep(1)

        # Fill in add department form
        self.driver.find_element_by_id("name").clear()
        self.driver.find_element_by_id("name").send_keys("Edited name")
        self.driver.find_element_by_id("description").clear()
        self.driver.find_element_by_id("description").send_keys(
            "Edited description")
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully edited the department" in success_message

        # Assert that department name and description has changed
        department = Department.query.get(1)
        self.assertEqual(department.name, "Edited name")
        self.assertEqual(department.description, "Edited description")

    def test_delete_department(self):
        """
        Test that an admin user can delete a department
        """

        # Login as admin user
        self.login_admin_user()

        # Click departments menu link
        self.driver.find_element_by_id("departments_link").click()
        time.sleep(1)

        # Click on edit department link
        self.driver.find_element_by_class_name("fa-trash").click()
        time.sleep(1)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully deleted the department" in success_message

        # Assert that there are no departments in the database
        self.assertEqual(Department.query.count(), 0)

The test_add_department method tests that an admin user can add a department using the add department form, while the test_add_existing_department method tests that they cannot add a department name that already exists. The test_edit_department method tests that a department can be edited using the edit form. Take note of the clear method, which allows us to clear a textbox of any exisiting text before entering some other text. Lastly, the test_delete_department tests than a department can be deleted using the delete link in the departments page.

Role Tests

We will write similar tests for roles:

# tests/front_end_tests.py

class TestRoles(CreateObjects, TestBase):

    def test_add_role(self):
        """
        Test that an admin user can add a role
        """

        # Login as admin user
        self.login_admin_user()

        # Click roles menu link
        self.driver.find_element_by_id("roles_link").click()
        time.sleep(1)

        # Click on add role button
        self.driver.find_element_by_class_name("btn").click()
        time.sleep(1)

        # Fill in add role form
        self.driver.find_element_by_id("name").send_keys(test_role2_name)
        self.driver.find_element_by_id("description").send_keys(
            test_role2_description)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully added a new role" in success_message

        # Assert that there are now 2 roles in the database
        self.assertEqual(Role.query.count(), 2)

    def test_add_existing_role(self):
        """
        Test that an admin user cannot add a role with a name
        that already exists
        """

        # Login as admin user
        self.login_admin_user()

        # Click roles menu link
        self.driver.find_element_by_id("roles_link").click()
        time.sleep(1)

        # Click on add role button
        self.driver.find_element_by_class_name("btn").click()
        time.sleep(1)

        # Fill in add role form
        self.driver.find_element_by_id("name").send_keys(test_role1_name)
        self.driver.find_element_by_id("description").send_keys(
            test_role1_description)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert error message is shown
        error_message = self.driver.find_element_by_class_name("alert").text
        assert "Error: role name already exists" in error_message

        # Assert that there is still only 1 role in the database
        self.assertEqual(Role.query.count(), 1)

    def test_edit_role(self):
        """
        Test that an admin user can edit a role
        """

        # Login as admin user
        self.login_admin_user()

        # Click roles menu link
        self.driver.find_element_by_id("roles_link").click()
        time.sleep(1)

        # Click on edit role link
        self.driver.find_element_by_class_name("fa-pencil").click()
        time.sleep(1)

        # Fill in add role form
        self.driver.find_element_by_id("name").clear()
        self.driver.find_element_by_id("name").send_keys("Edited name")
        self.driver.find_element_by_id("description").clear()
        self.driver.find_element_by_id("description").send_keys(
            "Edited description")
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully edited the role" in success_message

        # Assert that role name and description has changed
        role = Role.query.get(1)
        self.assertEqual(role.name, "Edited name")
        self.assertEqual(role.description, "Edited description")

    def test_delete_role(self):
        """
        Test that an admin user can delete a role
        """

        # Login as admin user
        self.login_admin_user()

        # Click roles menu link
        self.driver.find_element_by_id("roles_link").click()
        time.sleep(1)

        # Click on edit role link
        self.driver.find_element_by_class_name("fa-trash").click()
        time.sleep(1)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully deleted the role" in success_message

        # Assert that there are no roles in the database
        self.assertEqual(Role.query.count(), 0)

Exception Handling

You may also need to edit the add_department and add_role views to handle an SQLAlchemy exception that may occur during transaction rollback when attempting to add a department or role that already exists. To handle this exception, edit the try-except block in the add_department and add_role views, as follows:

# app/admin/views.py

def add_department(self):

    # existing code remains

    if form.validate_on_submit():
        department = Department(name=form.name.data,
                                description=form.description.data)
        try:
            # add department to the database
            db.session.add(department)
            db.session.commit()
            flash('You have successfully added a new department.')
        except:
            # in case department name already exists
            db.session.rollback()
            flash('Error: department name already exists.')

def add_role(self):

    # existing code remains

   if form.validate_on_submit():
        role = Role(name=form.name.data,
                    description=form.description.data)

        try:
            # add role to the database
            db.session.add(role)
            db.session.commit()
            flash('You have successfully added a new role.')
        except:
            # in case role name already exists
            db.session.rollback()
            flash('Error: role name already exists.')

Run your tests now:

$ nose2
...................................
----------------------------------------------------------------------
Ran 35 tests in 234.457s

OK

Employee Tests

Now we will write tests where the admin user assigns departments and roles to employees.

# tests/front_end_tests.py

# update imports
from selenium.webdriver.support.ui import Select

class TestEmployees(CreateObjects, TestBase):

    def test_assign(self):
        """
        Test that an admin user can assign a role and a department
        to an employee
        """

        # Login as admin user
        self.login_admin_user()

        # Click employees menu link
        self.driver.find_element_by_id("employees_link").click()
        time.sleep(1)

        # Click on assign link
        self.driver.find_element_by_class_name("fa-user-plus").click()
        time.sleep(1)

        # Department and role already loaded in form
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully assigned a department and role" in success_message

        # Assert that department and role has been assigned to employee
        employee = Employee.query.get(2)
        self.assertEqual(employee.role.name, test_role1_name)
        self.assertEqual(employee.department.name, test_department1_name)

    def test_reassign(self):
        """
        Test that an admin user can assign a new role and a new department
        to an employee
        """

        # Create new department
        department = Department(name=test_department2_name,
                                description=test_department2_description)

        # Create new role
        role = Role(name=test_role2_name,
                    description=test_role2_description)

        # Add to database
        db.session.add(department)
        db.session.add(role)
        db.session.commit()

        # Login as admin user
        self.login_admin_user()

        # Click employees menu link
        self.driver.find_element_by_id("employees_link").click()
        time.sleep(1)

        # Click on assign link
        self.driver.find_element_by_class_name("fa-user-plus").click()
        time.sleep(1)

        # Select new department and role
        select_dept = Select(self.driver.find_element_by_id("department"))
        select_dept.select_by_visible_text(test_department2_name)
        select_role = Select(self.driver.find_element_by_id("role"))
        select_role.select_by_visible_text(test_role2_name)
        self.driver.find_element_by_id("submit").click()
        time.sleep(2)

        # Assert success message is shown
        success_message = self.driver.find_element_by_class_name("alert").text
        assert "You have successfully assigned a department and role" in success_message

        # Assert that employee's department and role has changed
        employee = Employee.query.get(2)
        self.assertEqual(employee.role.name, test_role2_name)
        self.assertEqual(employee.department.name, test_department2_name)

The test_assign method assigns the exisiting department and role to the existing user using the assign link in the Employees page. The test_reassign method adds a new department and role to the database, and then assigns them to the existing employee. Take note of the Select class that we have imported from Selenium. From it, we use the select_by_visible_text method to select the department and role from a dropdown menu.

Let's run the tests one more time:

$ nose2
......................................
----------------------------------------------------------------------
Ran 38 tests in 181.709s

OK

Conclusion

That's it for Part One! To recap, in this tutorial you've learnt about Selenium WebDriver and how it can be used to run front-end tests by simulating a user of your app. You've also learnt about web elements and how to find them using some of Selenium's in-built methods. We have written tests for registration, login, and performing CRUD operations on departments and roles.

In Part Two, we will write tests for permissions, to ensure that only authorised users can access certain resources. Part Two will also cover continuous integration and linking our app with CircleCI, a continuous integration and delivery platform.

Mbithe Nzomo

7 posts

I am a software developer. I work primarily with Python, and have experience with frameworks such as Django and Flask. I consider myself a limitless learner, and love learning new tools and technologies that make me a better developer. I am excited about travelling, good music, and increasing diversity in the tech industry.