It's difficult to make sure that the change you're making to your API doesn't break existing behaviors.

One way to do that would be to use TDD when developing the new functionality.

To use TDD on an existing API, you'll first need to load the app into the test suite.

Assuming your app is in a file called app.py, this is how to do it:

from app import app

def test_import_app():
    pass

Run the test:

$ pytest test_app.py

The test fails. Error is:

ImportError while importing test module '/home/patryk/Workshop/things/ghost-pwa/server/test_app.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib64/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
test_app.py:3: in <module>
    from app import app
E   ModuleNotFoundError: No module named 'app'

This is because I don't yet have an app like that in that file. Creating the file app.py!

from flask import Flask

app = Flask(__name__)

Run the test:

$ pytest test_app.py
__________________ ERROR collecting test_app.py _________________
ImportError while importing test module '/home/patryk/Workshop/things/ghost-pwa/server/test_app/test_app.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib64/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
test_app.py:3: in <module>
    from app import app
app.py:1: in <module>
    from flask import Flask
E   ModuleNotFoundError: No module named 'flask'
==================== short test summary info ====================
ERROR test_app.py
!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!
==================== 1 error in 0.11s ====================

Error again!

What is the error this time? It's ModuleNotFoundError (the line above the "test summary").

Let's install the missing module.

To avoid adding packages on the system level, we'll use Poetry.

Poetry is a dependency management system designed for Python package creators.

It uses a configuration file named pyproject.toml to track dependencies of your package, and offers a quick way to install all of them into an automatically created virtual environment.

This way, all system packages remain unaffected, so you don't risk breaking all your other flask apps by installing the newest version of Flask for use with this tutorial:)

To use Poetry with the app, we first initialize the project:

$ poetry init --no-interaction

We use the flag --no-interaction this time to be able to quickly proceed to specifying dependencies. Without that flag, we would need to provide details about our project that are not relevant for now.

The result of poetry init --no-interaction is a new file called pyproject.toml.

$ cat
cat pyproject.toml 
[tool.poetry]
name = "test-app"
version = "0.1.0"
description = ""
authors = ["Patryk Kocielnik <[email protected]>"]
readme = "README.md"
packages = [{include = "test_app"}]

[tool.poetry.dependencies]
python = "^3.11"


[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

We can now specify that our project requires the Flask package:

$ poetry add flask   
Using version ^2.3.2 for flask

Updating dependencies
Resolving dependencies... (0.4s)

Writing lock file

Package operations: 7 installs, 0 updates, 0 removals

  • Installing markupsafe (2.1.3)
  • Installing blinker (1.6.2)
  • Installing click (8.1.6)
  • Installing itsdangerous (2.1.2)
  • Installing jinja2 (3.1.2)
  • Installing werkzeug (2.3.6)
  • Installing flask (2.3.2)

Let's add PyTest too, even though it was already in the system, for the sake of completeness. We'll install it into the dev group of packages.

$ poetry add --group dev pytest
poetry add --group dev pytest
Using version ^7.4.0 for pytest

Updating dependencies
Resolving dependencies... (1.1s)

Writing lock file

Package operations: 4 installs, 0 updates, 0 removals

  • Installing iniconfig (2.0.0)
  • Installing packaging (23.1)
  • Installing pluggy (1.2.0)
  • Installing pytest (7.4.0)

The "dev" group contains packages that can be omitted when installing the package for users.

These packages are still required by us, the developers, so we want to keep their list around.

Let's run the test again:

$ pytest test_app.py 
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.2.2, pluggy-1.0.0
rootdir: /home/patryk/Workshop/things/test_app
plugins: dvc-3.2.1, hydra-core-1.3.2, anyio-3.5.0
collected 0 items / 1 error                                                    

==================================== ERRORS ====================================
_________________________ ERROR collecting test_app.py _________________________
ImportError while importing test module '/home/patryk/Workshop/things/test_app/test_app.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib64/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
/home/patryk/Workshop/things/ghost-pwa/server/test_app/test_app.py:3: in <module>
    ???
app.py:1: in <module>
    from flask import Flask
E   ModuleNotFoundError: No module named 'flask'
=========================== short test summary info ============================
ERROR test_app.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
=============================== 1 error in 0.11s ===============================

Right, same error. Before we can access Flask in our app, we need to enter Poetry shell.

$ poetry shell
poetry shell
Spawning shell within /home/patryk/.cache/pypoetry/virtualenvs/test-app-FjGf7Qen-py3.11
$ emulate bash -c '. /home/patryk/.cache/pypoetry/virtual
envs/test-app-FjGf7Qen-py3.11/bin/activate'
(test-app-py3.11) $

The last line indicates we are now in a sub-shell with Python 3.11 as the default and a configuration named test-app-py3.11.

Let's try running our tests again.

$ pytest test_app.py 
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/test_app
collected 1 item                                                               

test_app.py .                                                            [100%]

============================== 1 passed in 0.19s ===============================

Passed this time!

To make sure our API works, we need to query it.

We'll do it by using a test interface within the app. Using that interface resembles using the Python Requests module that may already be familiar to you.

Let's create a test client to the API:

#!/usr/bin/env pytest

from app import app


def test_import_app():
    test_client = app.test_client()

Note I've added a shebang (#!) line at the top.

Let's make sure this file can be called as a script to simplify our testing:

$ chmod +x test_app.py

Run the test now... it passes!

$ ./test_app.py
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/test_app
collected 1 item                                                               

test_app.py .                                                            [100%]

============================== 1 passed in 0.19s ===============================

No surprises here!

Let's see what our API serves under the default route.

def test_import_app():
    test_client = app.test_client()
    res = test_client.get('/')

Test it (pytest test_app.py):

============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/test_app
collected 1 item                                                               

test_app.py .                                                            [100%]

============================== 1 passed in 0.19s ===============================

Still passes! I expected it to fail at this point, but... not yet!

What do you expect the API you're working with to return from the root endpoint (/)?

I expect my endpoint to return... The value 42, as a JSON. Let's use that and afterwards, you'll substitute this value with the one you need to see.

 def test_import_app():
     test_client = app.test_client()
     res = test_client.get('/')
+    data = res.json()

Re-run the test:

./test_app.py 
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/etudes/python/test_app
collected 1 item                                                               

test_app.py F                                                            [100%]

=================================== FAILURES ===================================
_______________________________ test_import_app ________________________________

    def test_import_app():
        test_client = app.test_client()
        res = test_client.get('/')
>       data = res.json()
E       TypeError: 'NoneType' object is not callable

test_app.py:9: TypeError
=========================== short test summary info ============================
FAILED test_app.py::test_import_app - TypeError: 'NoneType' object is not callable
============================== 1 failed in 0.23s ===============================

Whoops! Got an error already at the stage of obtaining the JSON from the response object.

Let's add the necessary function to the app:

--- a/python/test_app/app.py
+++ b/python/test_app/app.py
@@ -1,3 +1,7 @@
 from flask import Flask
 
 app = Flask(__name__)
+
[email protected]('/')
+def get_root():
+    return 42

Test again:

$ ./test_app.py
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/etudes/python/test_app
collected 1 item

test_app.py F                                                            [100%]

=================================== FAILURES ===================================
_______________________________ test_import_app ________________________________

    def test_import_app():
        test_client = app.test_client()
        res = test_client.get('/')
>       data = res.json()
E       TypeError: 'NoneType' object is not callable

test_app.py:9: TypeError
------------------------------ Captured log call -------------------------------
ERROR    app:app.py:1414 Exception on / [GET]
Traceback (most recent call last):
  File "/home/patryk/.cache/pypoetry/virtualenvs/test-app-FjGf7Qen-py3.11/lib/python3.11/site-packages/flask/app.py", line 2190, in wsgi_app
    response = self.full_dispatch_request()
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/patryk/.cache/pypoetry/virtualenvs/test-app-FjGf7Qen-py3.11/lib/python3.11/site-packages/flask/app.py", line 1487, in full_dispatch_request
    return self.finalize_request(rv)
           ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/patryk/.cache/pypoetry/virtualenvs/test-app-FjGf7Qen-py3.11/lib/python3.11/site-packages/flask/app.py", line 1506, in finalize_request
    response = self.make_response(rv)
               ^^^^^^^^^^^^^^^^^^^^^^
  File "/home/patryk/.cache/pypoetry/virtualenvs/test-app-FjGf7Qen-py3.11/lib/python3.11/site-packages/flask/app.py", line 1837, in make_response
    raise TypeError(
TypeError: The view function did not return a valid response. The return type must be a string, dict, list, tuple with headers or status, Response instance, or WSGI callable, but it was a int.
=========================== short test summary info ============================
FAILED test_app.py::test_import_app - TypeError: 'NoneType' object is not callable
============================== 1 failed in 0.23s ===============================

So we can't just return "42". We need to make it a str or dict, for example.

 @app.route('/')
 def get_root():
-    return 42
+    return {"value": 42}

Test it:

$ ./test_app.py
./test_app.py 
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/etudes/python/test_app
collected 1 item                                                               

test_app.py F                                                            [100%]

=================================== FAILURES ===================================
_______________________________ test_import_app ________________________________

    def test_import_app():
        test_client = app.test_client()
        res = test_client.get('/')
>       data = res.json()
E       TypeError: 'dict' object is not callable

test_app.py:9: TypeError
=========================== short test summary info ============================
FAILED test_app.py::test_import_app - TypeError: 'dict' object is not callable
============================== 1 failed in 0.23s ===============================

Right, the .json part should go without brackets. It's how the interface is defined here.

In Requests, this logic is written as .json() -- that's a difference.

Fixing that:

 def test_import_app():
     test_client = app.test_client()
     res = test_client.get('/')
-    data = res.json()
+    data = res.json
$ ./test_app.py
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/etudes/python/test_app
collected 1 item

test_app.py .                                                            [100%]

============================== 1 passed in 0.19s ===============================

It passed, finally!

Let's check for the exact data we expect:

     res = test_client.get('/')
     data = res.json
+    assert data == {"value": 42}

Test it... Passes too!

You can check for HTTP error codes as well.

In this case, the code should be 200. Let's check it!

     data = res.json
     assert data == {"value": 42}
+    assert res.status_code == 200
$ ./test_app.py 
============================= test session starts ==============================
platform linux -- Python 3.11.4, pytest-7.4.0, pluggy-1.2.0
rootdir: /home/patryk/Workshop/things/etudes/python/test_app
collected 1 item                                                               

test_app.py .                                                            [100%]

============================== 1 passed in 0.20s ===============================

Yes! Still passes!

Now you can adjust the expected result to what you need to see in your API.

Afterwards, you will be able to make the necessary updates and still check if the API functions properly.

All that without having to start a separate server process and using complicated hacks to detect when exactly that server will be ready to handle your requests.

A final remark!

I once understood TDD as a way to develop an entire application without talking to your users.

However, the Agile movement, of which TDD is the core part, emphasizes talking to your users on a regular basis to see if the new functionality is heading in the direction that will be the most useful to them.

Once you've added tens of new functions, your users won't be able to test them as well as if you had implemented a few and sought feedback on that few.

It will also be more difficult for you to hear at that stage: "I don't need this." about your shiny new features.

For this reason, it's better to seek feedback early and frequently.

The fact that you already have (or will already have) tests for your key logic makes you much better prepared for changing it to your users' needs and taste.

This post first appeared at https://sbctricks.com/tdd-example-testing-a-flask-app.