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.
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:
- Flask Web Development by Miguel Grinberg,
- Python Testing with Pytest by Brian Okken.
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.
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.
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.
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.
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.
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.
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.
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.