Banking Web App Stories — Part 5

Nat Retsel
7 min readMay 30, 2023

--

Continuing from part 4 of this series, we will be attempting to write tests for our web app. Since the transactions part of the app hasn’t been explored, we will come up with tests on the user registration, login, roles and user database. We will use Python’s unittest framework for our testing.

Before we start writing tests, let’s first group our user registration, login, logout into a blueprint called auth. Having an organized codebase helps by providing an easier time maintaining the code and simplify development.

app/auth/routes:

from flask import render_template, redirect, url_for, flash, request, Response
from flask_login import current_user, login_user, logout_user
from ..main.forms import RegistrationForm, LoginForm
from app.models import User, Role
from .. import db
from . import auth
from werkzeug.urls import url_parse


@auth.route('/register', methods=['GET', 'POST'])
def register() -> Response:
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RegistrationForm()
if form.validate_on_submit():
user = User(first_name=form.first_name.data, last_name=form.last_name.data, email=form.email.data) # Defaults role to user role
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash('Congratulations, you are now a registered user! Please login')
return redirect(url_for('auth.login'))
return render_template('auth/register.html', form=form)

@auth.route('/login', methods=['GET', 'POST'])
def login() -> Response:
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = LoginForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user is None or not user.check_password(form.password.data):
flash('Invalid email or password')
return redirect(url_for('auth.login'))
login_user(user, remember=form.remember_me.data)
next_page = request.args.get('next')
if not next_page or url_parse(next_page).netloc != '':
next_page = url_for('main.index')
return redirect(next_page)
return render_template('auth/login.html', title='Sign In', form=form)

@auth.route('/logout')
def logout() -> Response:
logout_user()
return redirect(url_for('main.index'))

Be sure to initialize a __init__.py file inside app/auth. Note that we are specifying which blueprint’s route inside render_template() and url_for(). We can also organize our templates by grouping login.html and register.html into a folder inside templates. Let’s name this folder auth.

Understanding Tests

Primarily, we want to write tests to ensure our application is functioning as intended when it goes live to our end users. In addition, testable code speaks a lot about the stability of the architecture. Regular testing after every development allows us to easily identify parts of the code that may break the application for early addressing before it gets difficult with additional developments.

We can consider three levels of testing:

  • Unit testing
  • Functional testing
  • End-to-end testing

Unit testing deals with functionality of individual code blocks without dependencies. Functional tests test multiple components to make sure they are working together properly. End-to-end testing verifies the entire application from start to finish.

Unit tests

Let us brainstorm on the types of unit tests we should perform on our web app:

  • Models are initialized properly given the inputs.
  • Model methods function as expected — set_password()

With that, let’s try writing out our tests as test_user_model.py:

import unittest
from app.models import User

class UserModelTestCase(unittest.TestCase):
def test_password_setter(self):
"""
Given a user model
When the password is supplied
Then verify that the set password function is working as intended (only password hash is stored)
"""
u = User()
password = 'cat'
u.set_password(password)
self.assertTrue(password != u.password_hash)

def test_no_password_getter(self):
"""
Given a user model
When a request is made to retrieve the user's password
Then verify that the user's password is not a readable attribute
"""
u = User()
u.set_password('cat')
with self.assertRaises(AttributeError):
u.password

def test_password_verification(self):
"""
Given a user model
When a user's password is set
Then verify that the check_password method is working as intended.
"""
u = User()
u.set_password('cat')
self.assertTrue(u.check_password('cat'))
self.assertFalse(u.check_password('dog'))

def test_pasword_salts_are_random(self):
"""
Given two user models
When both user's passwords are set to the same
Then verify that their hashes are different
"""
u = User()
u.set_password('cat')
u2 = User()
u2.set_password('cat')
self.assertTrue(u.password_hash != u2.password_hash)

We follow the folder structure:

Let’s also include some basic test case like making sure the application is in the right configuration:

tests/unit/test_basics.py:

import unittest
from flask import current_app
from app import create_app, db

class BasicsTestCase(unittest.TestCase):
def setUp(self):
"""
Create an environment for the test that is close to a running application.
Application is configured for testing and context is activated to ensure that tests have access to current_app like requests do.
Brand new database gets created for tests with create_all().
"""
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()

def tearDown(self) -> None:
"""
Removes application context and database after testing.
"""
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_app_exists(self):
"""
Given app context configured for testing
When unittest is invoked
Then check if the application instance exist
"""
self.assertFalse(current_app is None)

def test_app_is_testing(self):
"""
Given app context configured for testing
When unittest is invoked
Then check if the application configuration is set to testing.
"""
self.assertTrue(current_app.config['TESTING'])

Functional tests

We might immediately think of testing our routes as we think about what to test for functional tests. Yet when we think of routes, how can we test without running it on a live server?

Thankfully, we can make a test client capable of making requests to the application without running a live server.

tests/functional/test_routes.py:

import unittest
from app import create_app, db

class RoutesTestCase(unittest.TestCase):
def setUp(self):
"""
Create an environment for the test that is close to a running application.
Application is configured for testing and context is activated to ensure that tests have access to current_app like requests do.
Brand new database gets created for tests with create_all().
"""
self.app = create_app('testing')
self.app = self.app.test_client()
db.create_all()

def test_home_page(self):
response = self.app.get('/')
self.assertEqual(response.status_code, 200)


def test_index_page(self):
response = self.app.get('/index')
self.assertEqual(response.status_code, 200)


def test_register_page(self):
response = self.app.get('/auth/register')
self.assertEqual(response.status_code, 200)


def test_login_page(self):
response = self.app.get('/auth/login')
self.assertEqual(response.status_code, 200)

The test_client has methods that match the common HTTP request methods, such as .get() and .post(). To make a request, call the method the request should use with the path to the route to test. A TestResponse is returned to examine the response data. It has all the usual properties of a response object. We can look at response.data and response.status_codewhich is the bytes and response code returned by the view.

Let’s create another test case mimicking a mock user registration and login:

tests/functional/test_register_login.py:

import unittest
from app import create_app, db
from app.models import User, Role

class RegisterLoginTestCase(unittest.TestCase):
def setUp(self):
"""
Create an environment for the test that is close to a running application.
Application is configured for testing and context is activated to ensure that tests have access to current_app like requests do.
Brand new database gets created for tests with create_all().
"""
self.app = create_app('testing')
self.app_context = self.app.app_context()
self.app_context.push()
db.create_all()
Role.insert_roles()
self.client = self.app.test_client(use_cookies=True)

def tearDown(self) -> None:
db.session.remove()
db.drop_all()
self.app_context.pop()

def test_register_and_login(self):
response = self.client.post('/auth/register', data={
'first_name': 'loreum',
'last_name': 'ipsum',
'email': 'loreumipsum@email.com',
'password': 'testpassword',
'password2': 'testpassword'
})
self.assertEqual(response.status_code, 302)

# log in with the new account
response = self.client.post('/auth/login', data={
'email': 'loreumipsum@email.com',
'password': 'testpassword'
}, follow_redirects=True)
self.assertEqual(response.status_code, 200)
self.assertIn(b'Hello loreum', response.data)

response = self.client.get('/auth/logout', follow_redirects=True)
self.assertEqual(response.status_code, 200)

Since all forms generated by Flask-WTF have a hidden field with a CSRF token that needs to be submitted along with the form, to enable our test client to submit this form without dealing with the hassle of this token, we will disable CSRF protection in the testing configuration.

config.py

class TestingConfig(Config):
TESTING = True
WTF_CSRF_ENABLED = False
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL') or \
'sqlite://'

Database model modifications

You’d have noticed I’ve included insert_roles() static method in our Roles model. This is done to automate the inclusion of the different roles: Administrator, Moderator, User upon database instantiation. I’ve also included a default role variable pointing to the User role which is automatically assigned to new user creation:

from app import db, login
from werkzeug.security import generate_password_hash, check_password_hash
from flask_login import UserMixin

@login.user_loader
def load_user(id):
return User.query.get(int(id))

class Role(db.Model):
"""Role SQlite ORM model
Columns:
- id (SQLite int): primary key
- name (SQLite str64): role name in system (e.g. Admin, Moderator, User)

"""
__tablename__ = "roles_table"

id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(64), unique=True)
default = db.Column(db.Boolean, default=False, index=True)
users = db.relationship('User', backref='role') # Adds role attribute to User model

def __init__(self, **kwargs):
super(Role, self).__init__(**kwargs)

@staticmethod
def insert_roles():
roles = {'Administrator', 'Moderator', 'User'}
default_role = 'User'
for r in roles:
role = Role.query.filter_by(name=r).first()
if role is None:
role = Role(name=r)
role.default = (role.name == default_role)
db.session.add(role)
db.session.commit()

def __repr__(self):
return '<Role %r>' % self.name

class User(UserMixin, db.Model):
"""User SQlite ORM model
Columns:
- id (SQLite int): primary key
- first_name (SQLite str64): user first name
- last_name (SQLite str64): user last name
- email (SQLite str120): user email
- password_hash (SQLite str128): user hashed password
- role_id (SQLite int): user's role, mapped to Role table

"""

__tablename__ = "users_table"

id = db.Column(db.Integer, primary_key=True)
first_name = db.Column(db.String(64), index=True)
last_name = db.Column(db.String(64), index=True)
email = db.Column(db.String(120), index=True, unique=True)
password_hash = db.Column(db.String(128))
role_id = db.Column(db.Integer, db.ForeignKey('roles_table.id'))

def __init__(self, **kwargs):
super(User, self).__init__(**kwargs)
if self.role is None:
self.role = Role.query.filter_by(default=True).first()

@property
def pasword(self):
raise AttributeError('password is not a readable attribute')

def set_password(self, password: str) -> None:
"""Stores user's password as a hashed value
Reduces risk of compromising user information safety if we store password hash instead.
Uses Werkzeug's security moduyle hashing. Default hashing method 'pbkdf2:sha256', salt length = 8

Args:
password (str): user input in the password field
"""
self.password_hash = generate_password_hash(password)

def check_password(self, password: str) -> bool:
"""Checks if input password matches the one stored in database as a hashed value.

Args:
password (str): user input in the password field

Returns:
bool: True if the input password matches the one stored in database as a hashed value.
"""
return check_password_hash(self.password_hash, password)

def __repr__(self):
return '<User {} {}>'.format(self.first_name, self.last_name)

After modifying the database schema, it is a good idea to set the roles of existing users in the development database via the flask shell command:

flask shell
Role.insert_roles()

default_role = Role.query.filter_by(default=True).first()
for u in User.query.all():
if u.role is None:
u.role = default_role
db.session.commit()

remember to migrate and upgrade the database after the changes.

Running the test

I will be running the test through the command line in the virtual environment. Before that, let’s include a command in our webapp.py for easier implementation of custom commands. The implementation of the test() function invokes the test runner from the unittestpackage.

@app.cli.command()
def test():
import unittest
tests = unittest.TestLoader().discover('tests')
unittest.TextTestRunner(verbosity=2).run(tests)

We execute the test in the command line:

flask test

If all is well, you should receive the same output:

That’s it! Now armed with how we can write tests on how our application should behave, it is best practice to run it before every commit to make sure the new features or changes do not break our application.

--

--