EthicsComputerCourses
Join training +91 90532 10052

Testing Flask & Django Apps

Django & Flask · Lesson 27 of 42 Advanced 7 min read Updated

Every time you change a view, a form or a model, something else might break: a page that used to work now returns 500, a permission check disappears, a price rule stops applying. Clicking through the site after every change is slow and you will miss things. Automated tests check those things for you in seconds, every time.

Both frameworks come with a test client that sends fake requests to your app without a server or a browser. In this lesson you will test the Pune Tiffin Flask app with pytest and fixtures, then test the Jaipur City Library Django app with Django's TestCase: models, pages, forms, redirects and permissions. You will also make the test run faster and see what a failure looks like. For general testing ideas, see Testing & Best Practices.

What to test

You do not need to test Django or Flask themselves. Test your decisions:

  • Pages return the right status code (200, 302, 403, 404) and show the right content.
  • Forms accept good data and reject bad data with the right messages.
  • Login and permission rules hold: anonymous users are redirected, unauthorised users get 403.
  • Model methods (__str__, get_absolute_url(), calculations) return what you expect.
  • Every bug you fix gets a test, so it never comes back.

Flask with pytest

Install pytest in the venv with python -m pip install pytest. The application factory from Blueprints & the App Factory pays off here: a fixture builds a fresh app with test settings for every test.

Python
# tests/conftest.py
import pytest

from tiffin import create_app


@pytest.fixture
def app():
    return create_app({"TESTING": True, "DELIVERY_FEE": 25})


@pytest.fixture
def client(app):
    return app.test_client()

A fixture is a function that prepares something a test needs. pytest sees a test function's parameter named client, runs the client fixture (which itself asks for app), and passes the result in. conftest.py makes fixtures available to every test file in the folder.

Python
# tests/test_tiffin.py
def test_menu_page_lists_dishes(client):
    response = client.get("/")
    assert response.status_code == 200
    assert b"Rajma chawal" in response.data


def test_order_detail_uses_config_fee(client):
    response = client.get("/orders/7")
    assert "delivery fee Rs 25" in response.get_data(as_text=True)


def test_unknown_order_id_is_404(client):
    assert client.get("/orders/abc").status_code == 404


def test_api_menu_returns_json(client):
    data = client.get("/api/menu").get_json()
    assert data["delivery_fee"] == 25
    assert [d["name"] for d in data["dishes"]] == ["Rajma chawal", "Veg pulao"]
Terminal
python -m pytest -v
Output
tests/test_tiffin.py::test_menu_page_lists_dishes PASSED                 [ 25%]
tests/test_tiffin.py::test_order_detail_uses_config_fee PASSED           [ 50%]
tests/test_tiffin.py::test_unknown_order_id_is_404 PASSED                [ 75%]
tests/test_tiffin.py::test_api_menu_returns_json PASSED                  [100%]
============================== 4 passed in 0.21s ==============================

response.data is bytes, so compare with b"...", or use get_data(as_text=True) for a string. get_json() parses a JSON response. For POST requests, use client.post("/url", data={...}) for forms or json={...} for JSON.

When a test fails, pytest shows exactly which line and which values were involved. If someone changed the expected fee to 20 by mistake:

Output
>       assert data["delivery_fee"] == 20
E       assert 25 == 20

tests\test_tiffin.py:18: AssertionError
=========================== short test summary info ===========================
FAILED tests/test_tiffin.py::test_api_menu_returns_json - assert 25 == 20
1 failed, 3 passed in 0.23s

For a Flask app with a database, make the fixture use a separate test database (for example "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:") and call db.create_all() inside with app.app_context():.

Django's TestCase

Django's tests live in each app's tests.py (or a tests/ package). django.test.TestCase gives every test a test database, created fresh for the run and destroyed afterwards, and wraps each test in a transaction that is rolled back, so tests cannot affect each other.

Python
# catalog/tests.py
from decimal import Decimal

from django.contrib.auth.models import Permission, User
from django.test import TestCase
from django.urls import reverse

from .forms import BookForm
from .models import Author, Book


class CatalogTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        cls.premchand = Author.objects.create(name="Munshi Premchand", born=1880)
        cls.godaan = Book.objects.create(
            title="Godaan", slug="godaan", author=cls.premchand,
            year=1936, price=Decimal("250.00"), copies=3, available=1,
        )

    def test_str_and_url(self):
        self.assertEqual(str(self.godaan), "Godaan (1936)")
        self.assertEqual(self.godaan.get_absolute_url(), "/books/godaan/")

    def test_detail_page_shows_book(self):
        response = self.client.get(reverse("catalog:detail", args=["godaan"]))
        self.assertContains(response, "<h1>Godaan</h1>", html=True)
        self.assertTemplateUsed(response, "catalog/book_detail.html")

    def test_unknown_book_is_404(self):
        response = self.client.get("/books/kafka/")
        self.assertEqual(response.status_code, 404)

    def test_form_rejects_negative_price(self):
        form = BookForm(data={"title": "Nirmala", "slug": "nirmala", "author": self.premchand.pk,
                              "language": "hi", "year": 1927, "price": "-5", "copies": 1})
        self.assertFalse(form.is_valid())
        self.assertEqual(form.errors["price"], ["Price must be more than zero."])

setUpTestData() runs once for the class and creates data shared by all its tests (each test still gets a clean copy thanks to the transaction rollback). self.client is Django's test client. Useful assertions:

AssertionChecks
assertContains(response, text)Status 200 and the text appears (html=True compares HTML structure)
assertNotContains(response, text)The text does not appear
assertRedirects(response, url)A redirect to that URL, and that the target page loads
assertTemplateUsed(response, name)Which template rendered the page
assertEqual, assertTrue, assertFalseNormal unittest checks

Testing login and permissions

Security rules deserve tests more than anything else, because breaking them is silent:

Python
def test_create_needs_login_then_permission(self):
    url = reverse("catalog:create")
    self.assertRedirects(self.client.get(url), f"/accounts/login/?next={url}")

    user = User.objects.create_user("isha", password="Lake-Pichola-5")
    self.client.force_login(user)
    self.assertEqual(self.client.get(url).status_code, 403)

    user.user_permissions.add(Permission.objects.get(codename="add_book"))
    response = self.client.post(url, {
        "title": "Nirmala", "slug": "nirmala", "author": self.premchand.pk,
        "language": "hi", "year": 1927, "price": "180", "copies": 2,
    })
    self.assertRedirects(response, "/books/nirmala/")
    self.assertTrue(Book.objects.filter(slug="nirmala").exists())

force_login() logs a user in without checking a password, which is quicker than login(). Run all tests of the app:

Terminal
python manage.py test catalog
Output
Found 5 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.....
----------------------------------------------------------------------
Ran 5 tests in 2.968s

OK
Destroying test database for alias 'default'...

Each dot is a passing test; a failure shows F and an error shows E, followed by a traceback. Add -v 2 to see each test's name.

Faster tests

Almost three seconds for five tests is slow, and the reason is security: Django hashes passwords with 1,500,000 PBKDF2 iterations, and create_user() pays that cost. Tests do not need strong hashes, so use a test settings file with a fast hasher:

Python
# config/test_settings.py
from .settings import *  # noqa: F401,F403

PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
Terminal
python manage.py test catalog --settings=config.test_settings
Output
Ran 5 tests in 0.828s
OK

MD5 is far too weak for real passwords; this setting belongs only in test settings. Other speed tips: create data in setUpTestData() rather than setUp(), and use SimpleTestCase for tests that do not touch the database.

Common mistakes

  • Tests that depend on each other's data. Each test must set up what it needs; Django rolls back between tests anyway.
  • Testing against the real database. Django's test runner always creates a separate test database; never point tests at production.
  • Only testing the happy path. Test bad input, missing objects (404) and missing permissions (302/403) too.
  • Comparing bytes with strings in Flask. "Rajma" in response.data raises TypeError; use b"Rajma" or get_data(as_text=True).
  • Slow suites nobody runs. Use the fast hasher and setUpTestData() so running tests stays painless.

Key points

  • Test clients send fake requests to your app: app.test_client() in Flask, self.client in Django.
  • In Flask, pytest fixtures in conftest.py build a fresh app from the factory with test config.
  • Django's TestCase uses a separate test database and rolls back after each test; setUpTestData() creates shared data once.
  • Use assertContains, assertRedirects, assertTemplateUsed and status codes to check pages, forms and permissions.
  • Speed up tests with a fast password hasher in test-only settings, and add a test for every bug you fix.

Exercise

Write two more Django tests for the library: (1) the BookListView at /books/all/ paginates three books per page, so with four books page 2 shows exactly one book; (2) GET /accounts/logout/ must return 405, while POST /accounts/logout/ redirects to the catalogue index at /books/.

Show solution
Python
class ExtraTests(TestCase):
    @classmethod
    def setUpTestData(cls):
        author = Author.objects.create(name="R. K. Narayan")
        for n in range(4):
            Book.objects.create(title=f"Book {n}", slug=f"book-{n}", author=author,
                                year=1940 + n, price=Decimal("100"))

    def test_second_page_has_one_book(self):
        response = self.client.get("/books/all/?page=2")
        self.assertEqual(len(response.context["book_list"]), 1)
        self.assertEqual(response.context["page_obj"].number, 2)

    def test_logout_must_be_post(self):
        self.assertEqual(self.client.get("/accounts/logout/").status_code, 405)
        self.assertRedirects(self.client.post("/accounts/logout/"), "/books/")
Output
Ran 2 tests in 0.197s

OK

response.context gives access to the template variables, so the test checks the data rather than searching the HTML. The logout test protects the "logout is POST only" rule from being undone by a future change.

Quick Quiz

1. Why does a Flask test fixture call create_app({"TESTING": True, ...})?

2. What does Django's TestCase do with the database?

3. Which assertion checks both the status code 200 and that some text appears?

4. Why use MD5PasswordHasher in test settings?

Learn Django & Flask with a mentor

Free lessons get you started. Our Python Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call