Build, Test, and Deploy a Flask Application: Part 4

Restructuring testing

Qiang Hao
Better Programming

--

Photo by Yomex Owo on Unsplash

Meta Information About This Tutorial

Learning goals

  • Unit testing

Note: This tutorial is part of the series Learn Flask in a scientific way.

Source code

Install Your Application as a Package

As we move to use Application Factory and Blueprints, our testing code also needs some upgrade. To make our application that adopts Application Factory and Blueprints visible to testing code underneath a different folder, we need to install our application as a package first. There are three steps to achieve this.

First, we need to record all the package dependencies. This is important, especially when you need to replicate what you have in a different machine/virtual environment (e.g., deployment). To do so, navigate to the root of the directory (mean-review-collector) through your terminal and type:

(env) $ pip freeze >requirements.txt

You will see a file requirements.txt generated at the root directory that contains both package names and their exact version numbers.

Flask==1.1.1
Jinja2==2.10.3
...
wcwidth==0.1.7
Werkzeug==0.16.0
zipp==0.6.0

When you want to build a replica in a different virtual environment, you can just type:

(env) $ pip install -r requirements.txt MANIFEST.in

Second, we need to describe our project. The description relies on two files: setup.py and MANIFEST.IN. These two files are needed to make our app installable. setup.py contains the basic information about our application:

from setuptools import find_packages, setupsetup(
name='app',
version='0.1',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=[
'flask',
],
)

include_package_data=True indicates that we want to include extra data for this project. MANIFEST.IN includes basic information about the data of this project, such as templates and sql file:

include app/schema.sql
graft app/static
graft app/templates
global-exclude *.pyc

Third, we need to install our own code in app as a package. Using pip install, the installation is only one line:

(env)$ pip install -e .

However, install deserves some detailed explanation. Normally, when you install Python packages through pip nstall, pip looks for the target package in the Python Package Index. However, pip can also look for packages which are in other places (e.g., code in your local machine).

The above one-line code essentially copies your code over to the env folder and treats it like any other package you've installed in this virtual environment. The dot refers to your current directory. The flag -e makes it an editable install — if you make further changes to the files inside the project folder, those will be automatically reflected in the env folder as well.

Now you can check all the installed packages in this virtual environment:

(env)$ pip list

You will see something like:

Package            Version Location                               
------------------ ------- ---------------------------------------
app 0.1 /home/qiang/repos/mean-review-collector
attrs 19.3.0
Click 7.0
Flask 1.1.1
...

Unit Testing

The next thing we do is to rewrite our unit testing cases. We used unittest simply for convenience. From now on, we will switch from unittest to pytest. Although pytest is not in the standard library of Python, it is better than unittestin many ways. To install pytest and another tool coverage (that measures code coverage of your testing), we just need to type through your terminal:

(env)$ pip install pytest coverage

By default, pytest only identifies the test files or methods whose names start with test_ or end with _test. We have at least three areas to test, including the factory, the database, and the authentication, so we need three files underneath tests directory:

mean-review-collector
- env
- app
- instance
- tests
- test_auth.py
- test_db.py
- test_factory.py

When all the test code is ready, you can run all of them by simply typing pytest to your terminal, and you may see something similar to:

(env)$ pytest
=============== test session starts ===========================
platform linux -- Python 3.7.4, pytest-5.3.1, py-1.8.0, pluggy-0.13.1
rootdir: /home/qiang/repos/mean-review-collector
collected 7 items
tests/test_auth.py .. [ 42%]
tests/test_db.py .. [ 71%]
tests/test_factory.py .. [100%]

Before we rush to write the testing code in each file, we should recognize that certain code always needs to be executed first before any unit testing, such as connecting to the database and initiating an application object. pytest uses fixture to take care of such code. The purpose of fixtures is to provide a fixed baseline upon which tests can reliably and repeatedly execute. To achieve this, we would put such code in a different file named conftest.py. For instance, we certainly need some code to help us initiate an application object:

#conftest.py@pytest.fixture
def app():
db_fd, db_path = tempfile.mkstemp()
app = create_app({
'TESTING': True,
'DATABASE': db_path,
})
with app.app_context():
init_db()
yield app os.close(db_fd)
os.unlink(db_path)

pytest uses the decorator to identify a function as a fixture — a function that should be executed first before each testing.

Although the testing on database and authentication was rather similar to our old testing code that used unittest, there are still two differences that we want to highlight.

First, parametrize can be used to test various inputs against a function:

# test_auth.py@pytest.mark.parametrize(('username', 'password', 'message'), (
('', '', b'Username is required.'),
('a@c.com', '', b'Password is required.'),
('a@b.com', 'test', b'already registered'),
))
def test_register_validate_input(client, username, password, message):
response = client.post(
'/register',
data={'username': username, 'password': password}
)
assert message in response.data

This is achieved using pytest.mark decorators for testing functions.

Second, monkeypatch can be a useful tool for testing. The following code essentially follows the guidance of Flask’s official tutorial:

def test_init_db_command(runner, monkeypatch):
class Recorder(object):
called = False
def fake_init_db():
Recorder.called = True
monkeypatch.setattr('app.db.init_db', fake_init_db)
result = runner.invoke(args=['init-db'])
assert 'Initialized' in result.output
assert Recorder.called

However, the official tutorial doesn’t really explain monkeypatch. monkeypatch dynamically changes a piece of software (e.g., a module, object, method, or function) at runtime. Pytest uses this feature to allow the testing of functions or methods that you don’t want to actually execute.

For instance, we have a function named get_info as the following:

def get_info():
"""
call GET for http://XXX/get
returns status code and url in HTTP response
"""
r = requests.get(base_url + 'get')

if r.status_code != 200:
return r.status_code, ''
else:
response_data = r.json()
return r.status_code, response_data["url"]

We can test this function out without making it actually send an HTTP request by using monkeypatch:

def test_get_info(monkeypatch):
class ResponseMock(object):
def __init__(self):
self.status_code = 200
self.url = ''
self.headers = {}

def json(self):
return {'account': '123','url': 'http://test.com'}

def get_patched(url):
return ResponseMock()

monkeypatch.setattr(requests, 'get', get_patched)
assert get_info() == (200, 'http://test.com')

What monkeypatch realizes here is essentially to replace the behavior of requests.get() with what you have defined under get_patched. Similarly, in our test_init_db_command method:

monkeypatch.setattr('app.db.init_db', fake_init_db)

We essentially attached some new behavior to app.db.init_db. The new behavior is being defined under fake_init_db. The only thing this method actually achieves is adding a new field Recorder.called for app.db.init_db to keep track of whether it gets executed or not.

Please check out the final code of our restructured unit testing here.

--

--

Assistant Professor of Computer Science at Western Washington University; human; cat lover; qhao.info