issues.app (1): Getting started with Flask and Pytest

The beginning of my journey into the world of web app development.

My biggest regret is not studying software design as an undergraduate.

Although I’ve been writing code for more than a decade, most of the code behind my programs tends to be stuffed into one or two files. This is fine when the programs are small, but a few months ago I had to fix a bug in one of my more complicated apps, which allows a user to load an audio file and manually annotate speech through a graphical interface. Having forgotten entirely about how my app is structured, I had no choice but to trace through the 3300+ lines of code just to reorient myself, before making what turned out to be a simple change. I have since decided that investing time into becoming a good software engineer will pay massive dividends and do wonders for my future sanity.

This is the start of what will become a series about web app development using Flask and Pytest. After some research, I singled out these frameworks because they are extensible, well-designed and don’t require loads of boilerplate code, which gets in the way of understanding. My main drive is to learn more about application architecture and test-driven development. Along the way I will distill what I learn into this guide, so that it can help other software engineering novices who have similar goals and working knowledge.

Speaking of knowledge, you should be comfortable with Python to get the most out of this guide. You should also be familiar with relational databases, client-server interactions, and the basics of web development (e.g., writing simple pages in HTTP/CSS/JavaScript).

Table of Contents

Resources

I’m not a coding novice but there are a lot of uncharted waters here. Throughout the writeup I’ll reference docpages from both Flask and Pytest as they are well presented and unusually helpful for those getting started. Aside from the documentation and the odd forum post, my primary resources are these two excellent books:

Let’s get this show started.

Overview

My target application is a fully operational issue tracker (see here for a slick tour through an established product). Sure, it’s not the most exciting idea, but getting an app like this up and running calls for a lot of design decisions. There’s also enough complexity to make test-driven development worth the effort. To get a feel for what we’re talking about, consider just two aspects of the final product:

  • a clean and functional user interface,
  • data storage to keep track of users, issues, projects, and their relationships.

The implementation of each of these will heavily depend on how we want to use the issue tracker. We also want some way to authenticate users and securely handle their passwords. Basic account management (e.g. validating new accounts, resetting passwords) will be handled by email. Each of these aspects will be the topic of a separate writeup that builds on the codebase from the previous writeup.

Codebase

It’s now time to introduce the initial codebase. The rest of this article goes over the details of how it works as well as the rationale behind its structure.

If you’re looking for a good IDE, I highly recommend Visual Studio Code. It’s free, widely supported by an extensive list of add-ons, and easy to work with. It is truly a thing of beauty.

Project structure

Our starting codebase is held in the root directory issues-project (Fig. 1). Inside we have the src/main folder, containing three Python files that comprise our app’s source code. The tests folder on the same level holds code that will be used to test the application. We also have setup.py, which will be invoked to setup the virtual environment.

Fig. 1. Directory listing for issues-project.

There are of course many ways to structure a project. This particular layout allows us to play nice with Pytest and build on the codebase without too much difficulty, but there are other advantages that you can read about in this comprehensive post. As the application grows I will likely bundle unit tests and functional tests into separate folders, but for now this structure will do just fine.

Configuration

Most large applications need some configuration. Each instance of a Flask application comes with a config attribute that can be modified as if it were a dictionary:

app = Flask(__name__)
app.config['TESTING'] = True

A few config values that we use are listed below:

  • SECRET_KEY, used to encrypt session cookies (discussed later),
  • DEBUG, toggles debug mode, which shows an interactive debugger for unhandled exceptions and reloads the development server for code changes,
  • TESTING, toggles testing mode, which tells the app to allow exceptions to propagate, such that they can be handled by a testing framework.

The configuration file is shown below. We have three different configuration structures: one for development, another for testing, and yet another for production. This is useful because often a developer will want to use separate resources for each of these activities. You probably don’t want to test CRUD operations on your production database!

Each configuration inherits from the base Config class, which contains settings that are shared across all configuration types. The secret key is assumed to be stored as an environment variable.

src/main/config.py

import os

class Config:
    SECRET_KEY = os.environ.get('SECRET_KEY')

class DevelopmentConfig(Config):
    DEBUG = True

class TestingConfig(Config):
    TESTING = True

class ProductionConfig(Config):
    PLACEHOLDER = True

config = {
    'development': DevelopmentConfig,
    'testing': TestingConfig,
    'production': ProductionConfig,
    'default': DevelopmentConfig
}

We can now use the global config dictionary to easily configure an instance of our application to suit our purpose, whether it be for testing or development:

app = Flask(__name__)
app.config.from_object(config['testing'])

Protecting your cookies

It’s worth talking a bit more about that secret key and how it affects the user session. Most web applications need to maintain some kind of state with each user without having to dive into (slower) persistent storage. While handling an HTTP request, Flask makes the user session available to the application using the session object. This allows the application to keep track of information across multiple requests using key-value pairs called cookies. We will see how these can be useful later when we start to use them.

For now, just know that Flask will not allow you to use user sessions without defining the secret key. This key should be a long string of text that is not easily guessable and sufficiently random. Flask will use this key to cryptographically sign each cookie, such that a bad actor cannot impersonate you (or the application server) by forging your signature.

Do not store secrets in cookies. Although your signature cannot be (easily) forged, the cookie payload can be very easily decrypted.
Make sure that your secrets (including things like API keys) are securely stored outside of source code. Never commit your secrets to version control!

Application

Here we have our application code. If you’re looking at this code and thinking there’s something missing, well… alright. It’s quite spartan. What we do have in our package constructor (__init__.py) is a factory function. We can use create_app() to create multiple instances of our app and import different configuration sets for each one using app.config.from_object(). This is great for unit testing, as you will soon see.

src/main/__init__.py

from flask import Flask
from src.main.config import config

def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])
    return app

The issues.py file is used to instantiate the application. It first looks for the ISSUES_CONFIG environment variable to see which configuration to use, but if that fails, the application is configured with the default (development) settings.

Note that the create_app() function has significance to Flask’s command line utility, which we will use to launch the app. We will discuss this shortly.

src/main/issues.py

import os
from . import create_app

app = create_app(os.getenv('ISSUES_CONFIG') or 'default')

And there we have it. That’s the app. Take a moment to appreciate just how lean it is. It won’t always be this way!

Test code

We now turn our attention to Pytest and what it will do for us. Taking a look at test_suite.py, we find a rag-tag collection of unit tests. The first test is not particularly useful, but Pytest doesn’t care. All it cares about is whether the logic after any assert keyword evaluates to True. If it doesn’t, any remaining code in that function is skipped, the function is failed, and Pytest moves on to the next function. If all assertions in the test function are true, the function is passed.

If you have used other testing frameworks you will appreciate that this is an incredibly beautiful programming construct. You don’t need to use things like assertLess(a, b): just write assert a < b.

tests/test_suite.py

def test_sanity():
    assert 1 + 1 == 2

def test_config(app):
    assert app.config['TESTING']

def test_response(client):
    response = client.get('/')
    assert response.status_code == 200

The remaining two test functions have arguments that sound more relevant. Where do app and client come from? That brings us to our next file.

Within the conftest.py file, Pytest expects to find fixtures. These are functions that can be used to prepare something (data, initialization, teardown, etc.) for a test function. In the code below, the pytest.fixture decorator is used to tell Pytest that the function app() is a fixture. Now Pytest knows to run the function whenever it encounters app in the argument list of a test function. The test_config() function in our test suite, for example, gets a fresh instance of our application that has been configured for testing.

tests/conftest.py

from src.main import create_app
import pytest

@pytest.fixture
def app():
    # initializes the app with the testing config
    app = create_app('testing')
    return app

Note that there is another fixture called client that hasn’t been defined. This fixture is automatically made available to us courtesy of the pyflask-test package (installed in our upcoming virtual environment), which looks for an app fixture and uses it to create a test client. We will use the test client to generate browser requests and see whether we are getting back an expected response from our application.

Execution

Now that we have introduced the codebase, it’s time to fire it up. We will run issues within a virtual environment that is managed by Miniconda, a lightweight version of the Anaconda package management system.

Virtual environment

We’ll go ahead and use Miniconda to create our Python 3.7 environment. Once it’s ready, activate and use pip to install our requisite packages. Don’t forget that trailing period.

ROOTDIR> conda create -n issues python=3.7
ROOTDIR> conda activate issues
(issues) ROOTDIR> pip install -e .

When invoked by the command above, pip will search the current directory for the setup.py file, which includes a list of dependencies needed for issues to run properly. Each package listed in install_requires will be installed into the virtual environment.

setup.py

from setuptools import setup, find_packages

setup(
    name='issues',
    version='0.1',
    packages=find_packages(),
    install_requires=["flask", "pytest", "pytest-flask"],
)

What about that -e? Running pip install with the editable option installs a link within the virtual environment to each local package discovered by find_packages() (i.e., main). One of the major benefits of installing our project packages in this way is that our test files can now import them without resorting to hacky system path workarounds. Even better, the editable option means that we can continue to change the source code without having to reinstall the packages.

At first glance importing a package from a neighboring directory doesn’t seem to be such a big problem, but take a look at the age of this Stack Overflow post. People have been dealing with this issue for a very long time…

Environment variables

Remember that we need to set up a couple of environment variables. The syntax used to do this will vary depending on your shell (see this link for some examples). I’m using Visual Studio Code on Windows, whose terminal uses PowerShell. Note that your secret key should be more complex than this random headline I took from the New York Times.

$env:FLASK_APP='src/main/issues.py'
$env:ISSUES_CONFIG='development'
$env:SECRET_KEY='Are you overpraising your child?'

There’s a variable here that has not yet been introduced to us. As you might suspect, FLASK_APP holds the location of our app. We’ll see how Flask uses this variable in the next section.

Launching the app

Once installed inside the virtual environment, Flask gives us access to flask, a command line utility. Entering flask run will first query the FLASK_APP variable to discover our application. Since we have specified a path to a Python file, Flask will look for the create_app() factory function in this file and use it to instantiate the application. Flask will then start up a development server and host the app on http://localhost:5000/. You can find other ways to configure FLASK_APP in this writeup.

If you visit the server right now, you will be greeted with a 404 Not Found error. That’s expected, since we haven’t yet told Flask how to handle any request. We’ll get to that shortly but for now let’s kill the server and see how to run our test suite.

Running some tests

We are going to invoke Pytest through pytest-flask, which gives us access to the test client, as described earlier. We can run the test suite with this command:

(issues) ROOTDIR> py.test

Pytest will now go off and search through any test code (i.e. files that look like test_*.py or *_test.py) within your current directory and all subdirectories. Test functions should start with “test”, like test_response().

Running py.test results in a big chunk of text that starts with the following:

============================= test session starts =============================
platform win32 -- Python 3.7.7, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
plugins: flask-1.0.0
collected 3 items

tests\test_suite.py ..F   

The last line tells the story: Pytest found three tests in test_suite.py, of which two tests passed (indicated with a .) and one test failed (F).

At the end of the test output, we see the following:

=========================== short test summary info ===========================
FAILED tests/test_suite.py::test_response - AssertionError: assert 404 == 200
========================= 1 failed, 2 passed in 0.08s =========================

Seems like test_response() failed. Let’s take another look at the function:

def test_response(client):
    response = client.get('/')
    assert response.status_code == 200

Pytest is telling us that after our test client made a GET request for the server’s root URL, the server did not return with a successful (200 OK) response. We were expecting this since the server gave our browser a 404 Not Found response earlier. Since the returned status code was not equal to 200, the assertion failed and caused our test function to fail.

Test-driven development

We have now set ourselves up to do some test-driven development. Under this methodology, each new feature in our application begins life as a test. The goal is then to implement the feature by writing as little code as possible so as to pass the test. This extremely short development cycle of writing and passing tests is repeated many times until you have yourself an application.

Our first “feature” is very simple: deliver a successful response to the client that requests the root URL.

Handling route requests

While the web server is running, it passes all received requests to app, the Flask application instance. The app needs to know how to respond to each requested URL. More specifically, Flask needs to know what function to use to create the response. This is achieved using routes, which associate a URL with a response function. We can use the app.route decorator to specify a route for the root URL:

@app.route('/')
def index():
    return '<h1>Show me the money!</h1>'

In this way, the index() function has now been assigned to handle the response for the root URL. We will add this code to the create_app() function within the issues package constructor:

src/main/__init__.py

from flask import Flask
from src.main.config import config

def create_app(config_name):
    app = Flask(__name__)
    app.config.from_object(config[config_name])

    @app.route('/')
    def index():
       return '<h1>Show me the money!</h1>'

    return app

After revising the code and launching the app with flask run, you will find that a trip to the server root no longer results in a 404 Not Found.

Fig. 2. Success!

Let’s run pytest once more.

(issues) ROOTDIR> py.test
============================= test session starts =============================
platform win32 -- Python 3.7.7, pytest-6.0.1, py-1.9.0, pluggy-0.13.1
plugins: flask-1.0.0
collected 3 items

tests\test_suite.py ...                                                  [100%]

============================== 3 passed in 0.06s ==============================

As expected, we have passed the tests and restored order to the universe.

If you’ve cloned the project repository, you can run git checkout fe2b7ce to get the current version of the source code.

Summary

That concludes the initialization of the issue tracker project. Although it takes a bit more work to distribute a Flask application over several source files, this modular design should allow for a more streamlined development and testing experience.

There is a lot of code between what we have and a working issue tracker. In the next article I’ll create a basic user interface for the app and motivate the use of templates, which allow for the clean separation of presentation logic and application data.

Alex Hadjinicolaou
Scientist | Developer | Pun Advocate

“I can't write five words but that I change seven” – Dorothy Parker

Related