Flask 3 - ‘Flaskr’ Tutorial

Auteur

Fabrice Devaux

Publicatiedatum

16 december 2025

Inleiding

Tijdens deze les volgen we (grotendeels) de officiële Flask tutorial. Die zit goed in elkaar, gebruikt van alle belangrijkste functies, en je kan er gemakkelijk online extra informatie over vinden.

Tijdens de tutorial maken we een eenvoudige blog applicatie, “Flaskr”. Gebruikers zullen kunnen registreren, inloggen en hun eigen posts maken, aanpassen of verwijderen.

Flask is erg flexibel en je kan een project op verschillende manieren organiseren. De tutorial geeft een gestructureerd voorbeeld waar heel wat best-practices in verweven zitten.

Project Layout en Initialisatie

Project nieuwe directory aanmaken

mkdir flask-tutorial
cd flask-tutorial

Alle paden en bestandsnamen die we vanaf nu gebruiken zijn relatief t.o.v. deze directory.

uv of conda environment initialiseren

uv init
uv add "Flask~=3.0"
conda create -n flask
conda activate flask
conda install "Flask~=3.0"

conda activate flask

.gitignore file toevoegen

Als je uv gebruikt dan is de standaard .gitignore een prima start. We voegen *.sqlite zodat we nooit de database bestand(en) willen committen.

.gitignore
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info

# Virtual environments
.venv

# SQLite database files
*.sqlite

Git initialiseren

git init
git add .
git commit -m "Project init"

Zoals bij elke project doe je er goed aan regelmatig je vooruitgang de committen.

Project Structuur

Het project krijgt de volgende basis-structuur:

./flask-tutorial
1├── flaskr/
   ├── __init__.py
2   ├── templates/
3   └── static/
4└── tests/
1
flaskr is een Python package voor de applicatie
2
in templates/ komen alle jinja2 templates
3
in static/ komen alle statische elementen zoals css, afbeeldingen, …
4
in tests/ komen later de unit tests

Application Setup

We weten al dat we één enkele instance van de Flask class zullen hebben, typisch app genoemd. In plaats van dit object rechtstreeks aan te maken (zoals we tot nu toe gedaan hebben) gebruiken we het Factory Pattern. Hierbij maken we een aparte functie die het Flask object terug geeft. Dat is handig om het uiteindelijke object anders te configureren naargelang de omgeving (tests, productie, …).

flaskr/__init__.py
from flask import Flask

1def create_app() -> Flask:
2    app = Flask(__name__)
3    app.secret_key = "dev"
4    app.jinja_options["autoescape"] = True

5    @app.route("/ping")
    def ping():
        return "pong"

    return app
1
Flask verwacht ofwel een globale app variabele, ofwel een create_app factory functie. We zullen dus nergens create_app expliciet moeten aanroepen.
2
Als eerste stap moeten we natuurlijk een Flask app object maken.
3
Omdat we cookies willen gebruiken (via het flask.session object) hebben we een key nodig. Om te starten gebruiken we een dummy key maar later moeten we dit beter beveiligen.
4
Voor het renderen van Jinja templates willen we altijd autoescape gebruiken. Op deze manier kunnen we onze templates de extensie .html.j2 geven.
5
We voegen direct een simpele test-route toe aan de applicatie. Deze code kan je later verwijderen.

SQLite is ideaal om mee te starten omdat er geen server installatie nodig is en alles in één enkel bestand wordt beheerd. We kunnen dan later ook bekijken hoe je dankzij SQLAlchemy gemakkelijk naar een andere database kan overstappen, zoals PostgreSQL.

Bovendien mag SQLite tegenwoordig zeker als een volwaardige database systeem aanzien worden. SQLite ondersteunt ACID-transacties en volgt het SQL-standaarden. Heel wat applicaties gebruiken SQLite standaard als database, bvb. iMessage, Google Chrome of Dropbox.

We testen de functie direct uit. Merk op dat Flask automatisch de create_app functie in de module uitvoert.

$ flask --app flaskr run --debug
 * Serving Flask app 'flaskr'
 * Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
 * Running on http://127.0.0.1:5000
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 644-305-120

Database Toegang

De applicatie zal een SQLite database gebruiken om de gebruikers en hun posts op de slaan. Zoals bij de meeste andere databases zullen we een Connection object nodig hebben om met de database te werken.

Database Connection

Met de volgende module voorzien we helper-functions om op een slimme manier een database connection te verkrijgen en ook terug af te sluiten.

Deze module is ook een mooi voorbeeld van het gebruik van de g en current_app proxies uit Les 1 - De Application Context.

flaskr/db.py
import sqlite3
from pathlib import Path
from flask import g

1LOCAL_DIRECTORY = Path(__file__).parent
SQLITE_DB_FILE = LOCAL_DIRECTORY / "flaskr.sqlite"

def get_db() -> sqlite3.Connection:
2    if "db" not in g:
3        g.db = sqlite3.connect(SQLITE_DB_FILE)
4        g.db.row_factory = sqlite3.Row
    return g.db

def close_db(_exc=None):
    if "db" in g:
5        g.db.close()
1
pathlib is een handige module uit the Python standard library om met bestanden en directories te werken. Met Path(__file__).parent krijgen we de directory waar db.py zich bevindt. Op die manier zijn we zeker dat we altijd met hetzelfde sqlite bestand werken.
2
De sqlite3.Connection voor een bepaalde Request wordt bewaard in het flask.g object. Op die manier wordt er maar één connection per request aangemaakt.
3
Om eenvoudig te starten gebruiken we vast SQLite database bestand. Later zullen we dit configureerbaar willen maken.
4
Deze configuratie zorgt ervoor dat de sqlite3.Connection rows zal returnen als dictionaries i.p.v. tuples. Dit is iets handiger om met de data te werken.
5
Enkel als een connection bestaat proberen we deze te sluiten. We zien later hoe ervoor gezorgd wordt dat deze functie op het einde van elke request wordt uitgevoerd.

Tables

We gebruiken standaard SQL commandos om de tables in de SQLite database aan te maken. We hebben maar 2 tables nodig, één om de gebruikers op de slaan, en één voor de blogposts.

flaskr/schema.sql
DROP TABLE IF EXISTS user;
DROP TABLE IF EXISTS post;

CREATE TABLE user (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT UNIQUE NOT NULL,
  password TEXT NOT NULL
);

CREATE TABLE post (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  author_id INTEGER NOT NULL,
  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  FOREIGN KEY (author_id) REFERENCES user (id)
);

We breiden db.py uit zodat we het kunnen uitvoeren om bovenstaande schema toe te passen op een SQLlite database.

flaskr/db.py
# bestaande code...

def init_db():
1    db = sqlite3.connect(SQLITE_DB_FILE)

2    with open(LOCAL_DIRECTORY / "schema.sql") as schema:
        db.executescript(schema.read())

    print("SQLite schema created. Tables in DB:")
3    result = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
    print(result.fetchall())
\
4if __name__ == "__main__":
    init_db()
1
We maken een verbinding met hetzelfde database bestand.
2
Net als het SQLite bestand verwachten we dat schema.sql in dezelfde directory staat als de db.py module zelf. Met open en read lezen we dat bestand in en voeren het uit als een script in de database, met de functie executescript.
3
Achteraf controleren we welke tables effectief bestaan.
4
Deze code zal enkel worden uitgevoerd als we het db.py bestand rechtstreeks uitvoeren, niet als het gewoon geïmporteerd wordt.

Deze code moeten we één maal uitvoeren zodat de database klaar is voor gebruik.

$ uv run python flaskr/db.py
SQLite schema created. Tables in DB:
[('sqlite_sequence',), ('user',), ('post',)]

Registreren met de Applicatie

We willen dat de close_db functie uit de db.py module uitgevoerd wordt op het einde van elke request.

We passen de factory functie als volgt aan:

flaskr/db.py
from flask import Flask
1from . import db

def create_app() -> Flask:
    app = Flask(__name__)
    app.secret_key = "dev"
    app.jinja_options["autoescape"] = True

    @app.route("/ping")
    def ping():
        return "pong"

2    app.teardown_appcontext(db.close_db)

    return app
1
De `db module moet natuurlijk geïmporteerd worden.
2
teardown_appcontext registreert functies die uitgevoerd moeten worden als de “Application Context” (g object) verwijderd wordt (op het einde van elke request dus).

Blueprints en Views

Een eerste Blueprint

Een view is een functie die bepaalt wat er gebeurt en teruggestuurd wordt voor een bepaalde route.

Een blueprint is een manier om een groep relateerde views te organiseren. De flaskr applicatie zal twee blueprints gebruiken: een voor authenticatie en een voor blogposts.

We starten met de basis voor authenticatie. Om de code gestructureerd en georganiseerd te houden werken we opnieuw in een aparte module.

flaskr/auth.py
from flask import Blueprint

bp = Blueprint("auth", __name__, url_prefix="/auth")

De Blueprint heeft:

  1. Een vrij te kiezen naam, hier gewoon auth
  2. De naam van de package of module waartoe de Blueprint behoort. Net zoals voor de Flask app gebruiken we __name__ (dus uiteindelijk flaskr)
  3. Een url_prefix die voor het pad van elke view route wordt geplaatst om zo het volledige pad van die route te bepalen.

In de factory gebruiken we de app.register_blueprint functie om het Blueprint object toe te voegen aan de applicatie.

flaskr/__init__.py
# Bestaande imports
from . import auth, db


def create_app() -> Flask:
    # Bestaande code

    app.register_blueprint(auth.bp)

    return app

Eerste Auth View: register

Als een gebruiker de /auth/register pagina bezoekt (GET) moet deze view HTML terugsturen met een form die de username en password velden bevat. Bij het verzenden van de form (POST) moet de view beide velden valideren, een nieuwe gebruiker opslaan en doorsturen naar de login pagina.

flaskr/auth.py
from flask import (
    Blueprint,
    flash,
    g,
    redirect,
    render_template,
    request,
    session,
    url_for,
)
from werkzeug.security import check_password_hash, generate_password_hash

from flaskr.db import get_db

bp = Blueprint("auth", __name__, url_prefix="/auth")


1@bp.route("/register", methods=("GET", "POST"))
def register():
2    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        error = None

3        if not username:
            error = "Username is required."
        elif not password:
            error = "Password is required."

        if error is None:
4            db = get_db()
            try:
5                db.execute(
                    "INSERT INTO user (username, password) VALUES (?, ?)",
6                    (username, generate_password_hash(password)),
                )
                db.commit()
7            except db.IntegrityError:
                error = f"User {username} is already registered."
            else:
8                return redirect(url_for("auth.login"))

9        flash(error)

10    return render_template("auth/register.html.j2")
1
Een view aan een blueprint route koppelen werkt op precies delfde manier als rechtstreeks in de app. I.p.v. app.route gebruiken we bp.route.
2
Het request object gebruiken om de HTTP method en form te bekijken kennen we al.
3
Zowel de username als het password mogen uiteraard niet leeg zijn.
4
De get_db functie uit onze eigen db module geeft een Connection object om met de database te werken.
5
Met de Connection.execute kunnen we standaard SQL commandos naar de database sturen. Een commit naar de database moet expliciet gebeuren via de commit functie.
6
Het password bewaren we natuurlijk niet in zijn oorspronkelijke vorm. Met de generate_password_hash functie uit de werkzeug package verkrijgen we een hash van het password.
7
Als er reeds een database row bestaat met dezelfde username zal de execute functie een IntegrityError exception geven (dankzij de UNIQUE constraint in het schema). In dat geval bereiden we een verklarende foutmelding voor.
8
Als alles is gelukt sturen we de gebruiker door naar de login pagina. "auth.login" slaat op de login view in the auth blueprint en die moeten we natuurlijk nog maken.
9
Met de flask.flash functie kunnen we een string registreren die we later in de template zullen gebruiken om de aandacht van de gebruiker te trekken.
10
We gebruiken een template (deze bekijken we in het volgende hoofdstuk) om de registratiepagina terug te sturen. Dit gebeurt in twee scenarios:

Wat gebeurt er als we nu al de pagina http://127.0.0.1:5000/auth/register proberen te bezoeken?

Testen met eenvoudige Template

Om deze eerste route te kunnen uitproberen voegen we een eenvoudige tijdelijke template toe:

flaskr/templates/auth/register.html.j2
<!doctype html>
<html>
<body>
  <h1>Register</h1>
  <p style="color:red; font-weight: bold;">
1    {% for message in get_flashed_messages() %}
    {{ message }}
    {% endfor %}
  </p>

2  <form method="post">
    <label for="username">Username</label>
    <input name="username" id="username" required>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" required>
    <input type="submit" value="Register">
  </form>
</body>
</html>
1
De get_flashed_messages() functie geeft een lijst terug met alle berichten (strings) waarmee de flash uitgevoerd werd tijdens deze request. Op die manier kunnen we (optioneel) problemen uit de laatste POST melden aan de gebruiker.
2
Een typische login form, met velden voor username en password wordt gepresenteerd.

We kunnen nu in principe de route al voor een deel uittesten.

flask --app flaskr run --debug

Via http://127.0.0.1:5000/auth/register kunnen we nu een gebruiker registreren. Als we 2x dezelfde username proberen registreren moeten we een foutmelding krijgen.

Tweede Auth View: login

Nadat een gebruiker geregistreerd is moet deze natuurlijk kunnen inloggen.

Oefening

Probeer zelf als oefening (in groepen) de view functie te schrijven.

De login view volgt dezelfde structuur als de register view:

  • Een GET request geeft een login form terug
  • De ingevulde login form wordt gePOST
  • De form velden worden gevalideerd en dit bepaalt de response
    • Fout: zelfde pagina en toon foutmelding
    • Juist: user_id uit database opslaan in cookie ; doorsturen naar index pagina
Tips
  • Met db.execute("SQL QUERY TEXT").fetchone() krijg je de eerst rij van het resultaat van de query, of None.
  • Met check_password_hash(hash, password) -> bool kan je controleren of hash de hash is die hoort bij password.
  • Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
flaskr/templates/auth/login.html.j2
<!doctype html>
<html>
<body>
  <h1>Login</h1>
  <p style="color:red; font-weight: bold;">
    {% for message in get_flashed_messages() %}
    {{ message }}
    {% endfor %}
  </p>

  <form method="post">
    <label for="username">Username</label>
    <input name="username" id="username" required>
    <label for="password">Password</label>
    <input type="password" name="password" id="password" required>
    <input type="submit" value="Login">
  </form>
</body>
</html>
flaskr/auth.py
# Bestaande code

@bp.route("/login", methods=("GET", "POST"))
def login():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        db = get_db()
        error = None
1        user_row = db.execute(
            "SELECT * FROM user WHERE username = ?", (username,)
        ).fetchone()

2        if user_row is None:
            error = "Incorrect username."
3        elif not check_password_hash(user_row["password"], password):
            error = "Incorrect password."

        if error is None:
4            session.clear()
            session["user_id"] = user_row["id"]
            return redirect(url_for("blog.index"))

        flash(error)

    return render_template("auth/login.html.j2")
1
We gebruiken opnieuw Connection.execute om SQL naar de database te sturen. We zoeken de username op in de user table. Met fetchone krijgen we enkel de eerste row. Door de eerdere configuratie van db.row_factory = sqlite3.Row krijgen we de row als een dictionary terug. Als er geen row is gevonden krijgen we None terug.
2
Als we de username niet kunnen vinden is dit uiteraard een foute request. Is het wel een goed idee om hier een specifieke foutmelding voor te geven?
3
check_password_hash is de tegenhanger van generate_password_hash en controleert of de het eerste argument overeenstemt met de hash van het tweede argument.
4
Als het password correct was gebruiken we het session object (uit les 1 - de application context) om de database ID van de user te bewaren. Daarna sturen we de request door naar de index pagina (die we later zullen maken).
Door de ID van de ingelogde user te bewaren in session zal dit ook opgeslagen worden in een cookie in de browser van de gebruiker en bij volgende request meegestuurd worden. Op die manier kunnen we weten dat volgende requests al ingelogd zijn zonder dat de username en password meegestuurd moeten worden. Maar is dit wel veilig?

User automatisch laden

We willen dus eigenlijk dat voor elke request gecontroleerd wordt of een user_id in de meegestuurde cookie staat. Bovendien zou het handig zijn om direct de details van die user op te halen uit de database. Flask heeft hiervoor een handige functie die we hieronder bekijken. Deze code voegen we toe in auth.py.

flaskr/auth.py
# Bestaande code

1@bp.before_app_request
def load_logged_in_user():
    user_id = session.get('user_id')

2    if user_id is None:
        g.user = None
    else:
3        g.user = get_db().execute(
            'SELECT * FROM user WHERE id = ?', (user_id,)
        ).fetchone()
1
before_app_request is net zoals route een decorator maar i.p.v. een gewone view functie die enkel bij een bepaald route (URL pad) hoort zal deze functie voor elke request worden uitgevoerd. En dit, vóór dat de eigenlijke view functie wordt uitgevoerd.
2
We gaan the de flask.g proxy (uit les 1 - de request context) gebruiken om de details van de user op de slaan. Op dit manier zullen ze beschikbaar zijn voor de volledige request. Als er geen user_id te vinden is in de session kunnen we ook niets ophalen uit de database en initialiseren we g.user met None. Dit wordt later dan ook de aanduiding dat de gebruiker nog moet inloggen.
3
Als er wél een user_id is gebruiken we deze om de volledige row van die user op te zoeken en bewaren het resultaat in g.user.

Om nu te controleren of een request wel van een ingelogde gebruiker komt (een geautoriseerde request) moeten we gewoon controleren of g.user iets bevat (dus niet None is). Hiervoor maken we zelf een nieuwe decorator die we dan kunnen toevoegen aan specifieke toekomstige view functies die enkel toegankelijk zijn voor ingelogde gebruikers. Hoe decorators, en specifiek zelfgemaakte decorators, precies werken komt aan bod in de module Python Architectuur.

flaskr/auth.py
import functools
# Bestaande code

1def login_required(view):
    @functools.wraps(view)
    def wrapped_view(**kwargs):
2        if g.user is None:
            return redirect(url_for("auth.login"))

3        return view(**kwargs)

    return wrapped_view
1
We maken een eigen login_required decorator. Die zullen we kunnen gebruiken met @login_required boven bepaalde view functies.
2
Als er geen g.user data is sturen we de request direct door naar de login pagina. Verder gebeurt er niets in deze decorator.
3
Anders wordt de gedecoreerde functie gewoon uitgevoerd.

Uitloggen

Om een gebruiker uit te loggen moeten we dan eenvoudigweg de informatie opgeslagen in de cookie verwijderen.

flaskr/auth.py
# Bestaande code

@bp.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('blog.index'))

Templates

In dit hoofdstuk bekijken we de verschillende Jinja2 templates die we nodig hebben in de views van the auth module uit het vorig deel:

  • auth/register.html.j2
  • auth/login.html.j2

De Base Layout

Elke pagina zal een aantal identieke basis-elementen bevatten. I.p.v. deze in elke template te herhalen kunnen we template inheritance gebruiken en eenzelfde template als basis maken voor alle andere templates.

flaskr/templates/base.html.j2
<!doctype html>
1<title>{% block title %}{% endblock %} - Flaskr</title>
2<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
3<nav>
  <h1>Flaskr</h1>
  <ul>
4    {% if g.user %}
    <li><span>{{ g.user['username'] }}</span>
    <li><a href="{{ url_for('auth.logout') }}">Log Out</a>
    {% else %}
    <li><a href="{{ url_for('auth.register') }}">Register</a>
    <li><a href="{{ url_for('auth.login') }}">Log In</a>
    {% endif %}
  </ul>
</nav>
<section class="content">
  <header>
    {% block header %}{% endblock %}
  </header>
5  {% for message in get_flashed_messages() %}
  <div class="flash">{{ message }}</div>
  {% endfor %}
  {% block content %}{% endblock %}
</section>
1
De title, header en content blocks blijven hier leeg en zullen worden ingevuld in de individuele templates. Het zijn hier dus louter placeholders.
2
De url_for functie is automatisch beschikbaar in templates.
3
Bovenaan de pagina willen we navigatie-elementen
4
Ook het flask.g object is automatisch beschikbaar. Hier kunnen we direct de user details uit het vorige hoofdstuk gebruiken: als de gebruiker ingelogd is tonen we zijn username en een link naar de logout pagina. Zo niet, dan tonen we links naar de register and login pagina’s.
5
Met de get_flashed_messages functie krijgen we een lijst met alle strings die in the view functies met de flash functie zijn aangemaakt.

De Register Template

flaskr/templates/auth/register.html.j2
1{% extends 'base.html.j2' %}

2{% block header %}
<h1>{% block title %}Register{% endblock %}</h1>
{% endblock %}

{% block content %}
3<form method="post">
  <label for="username">Username</label>
  <input name="username" id="username" required>
  <label for="password">Password</label>
  <input type="password" name="password" id="password" required>
  <input type="submit" value="Register">
</form>
{% endblock %}
1
Alle individuele templates zullen de base template uitbreiden
2
Op deze manier overschrijven we de (initieel lege) header en template blokken uit de base template. The inhoud zal komen te staan op de plaats van deze blokken in de base template.
3
Voor de register pagina tonen we een eenvoudige form waar een username en password worden ingevuld.

De Login Template

De login template is eigenlijk identiek aan de register template. Alleen de title en de submit knop verschillen.

flaskr/templates/auth/login.html.j2
{% extends 'base.html.j2' %}

{% block header %}
<h1>{% block title %}Log In{% endblock %}</h1>
{% endblock %}

{% block content %}
<form method="post">
  <label for="username">Username</label>
  <input name="username" id="username" required>
  <label for="password">Password</label>
  <input type="password" name="password" id="password" required>
  <input type="submit" value="Log In">
</form>
{% endblock %}

Een Eerste Test

Met de uitgewerkte auth views en templates moeten we al in staat zijn een user aan te registreren en in te loggen.

flask --app flaskr run --debug

Een SQLite database bestand is aangemaakt in instance/flaskr.sqlite. Met de sqlite3 CLI kunnen we de database bekijken. Kijk op https://sqlite.org/index.html hoe je dit kan installeren voor jou systeem.

$ sqlite3 instance/flaskr.sqlite
SQLite version 3.45.3 2024-04-15 13:34:05
Enter ".help" for usage hints.

sqlite> .schema
CREATE TABLE user (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  username TEXT UNIQUE NOT NULL,
  password TEXT NOT NULL
);
CREATE TABLE sqlite_sequence(name,seq);
CREATE TABLE post (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  author_id INTEGER NOT NULL,
  created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  title TEXT NOT NULL,
  body TEXT NOT NULL,
  FOREIGN KEY (author_id) REFERENCES user (id)
);

sqlite> select * from user;
1|fdevaux|scrypt:32768:8:1$nDOqwkuk4IMOTlIS$b4c25d6577cd213c2963f31aba0591cffa3d878e6b4af0da5a459410a131583a15c103810dc51020a0abdd3bdc0fbbe66dbfea3bbd6fecc6bc116c270da81689

Static Files

Met CSS zorgen we dat de pagina’s er iets beter uitzien. Het CSS bestand is statisch, want het is hetzelfde voor elke request. Daarom plaatsen we het in een flaskr/static directory (i.p.v. flaskr/templates). Met de functie url_for('static', filename='style.css') kunnen we de juiste URL naar het bestand genereren. Dit hebben we al gebruikt in de base.html.j2 template.

flaskr/static/style.css
html { font-family: sans-serif; background: #eee; padding: 1rem; }
body { max-width: 960px; margin: 0 auto; background: white; }
h1 { font-family: serif; color: #377ba8; margin: 1rem 0; }
a { color: #377ba8; }
hr { border: none; border-top: 1px solid lightgray; }
nav { background: lightgray; display: flex; align-items: center; padding: 0 0.5rem; }
nav h1 { flex: auto; margin: 0; }
nav h1 a { text-decoration: none; padding: 0.25rem 0.5rem; }
nav ul  { display: flex; list-style: none; margin: 0; padding: 0; }
nav ul li a, nav ul li span, header .action { display: block; padding: 0.5rem; }
.content { padding: 0 1rem 1rem; }
.content > header { border-bottom: 1px solid lightgray; display: flex; align-items: flex-end; }
.content > header h1 { flex: auto; margin: 1rem 0 0.25rem 0; }
.flash { margin: 1em 0; padding: 1em; background: #cae6f6; border: 1px solid #377ba8; }
.post > header { display: flex; align-items: flex-end; font-size: 0.85em; }
.post > header > div:first-of-type { flex: auto; }
.post > header h1 { font-size: 1.5em; margin-bottom: 0; }
.post .about { color: slategray; font-style: italic; }
.post .body { white-space: pre-line; }
.content:last-child { margin-bottom: 0; }
.content form { margin: 1em 0; display: flex; flex-direction: column; }
.content label { font-weight: bold; margin-bottom: 0.5em; }
.content input, .content textarea { margin-bottom: 1em; }
.content textarea { min-height: 12em; resize: vertical; }
input.danger { color: #cc2f2e; }
input[type=submit] { align-self: start; min-width: 10em; }

Als je nu http://127.0.0.1:5000/auth/login bekijkt zou het er (iets) beter moeten uitzien.

De Blog Blueprint

Blueprint Basis

In auth.py hebben we een Blueprint gemaakt voor alle authenticatie views. Op dezelfde manier maken we nu een nieuwe blog.py module met een Blueprint voor alle blog views.

Uiteindelijk zullen we volgende views nodig hebben:

  1. Index, om alle blogposts van alle gebruikers te laten zien.
  2. Create, om een ingelogde gebruiker een nieuwe post te laten schrijven.
  3. Update, om een ingelogde gebruiker een bestaande post te laten aanpassen of verwijderen.

We starten met enkel het Blueprint object en de nodige imports.

flaskr/blog.py
from flask import Blueprint, flash, g, redirect, render_template, request, url_for
from werkzeug.exceptions import abort

1from flaskr.auth import login_required
2from flaskr.db import get_db

3bp = Blueprint("blog", __name__)
1
Onze eigen login_required decorator zullen we nodig hebben voor de views waarmee een blogpost bewerkt wordt.
2
Ook get_db hebben we opnieuw nodig, ditmaal om blogpost naar de database weg te schrijven en terug op te halen.
3
In tegenstelling tot de auth Blueprint geven we hier geen url_prefix argument mee. Hierdoor komen alle routes voor deze Blueprint in de ‘root’ van de URL terug (bvb. http://127.0.0.1:5000/create).

Ook deze Blueprint moeten we in de app registreren:

flaskr/__init__.py
# Bestaande imports
from . import auth, blog, db


def create_app() -> Flask:
    # Bestaande code

    app.register_blueprint(blog.bp)

    return app

De Index View

De index pagina zal alle blogpost laten zien, van nieuw naar oud.

flaskr/blog.py
1@bp.route("/")
def index():
    db = get_db()
2    posts = db.execute(
        "SELECT p.id, title, body, created, author_id, username "
        "FROM post p JOIN user u ON p.author_id = u.id "
        "ORDER BY created DESC"
3    ).fetchall()
4    return render_template("blog/index.html.j2", posts=posts)
1
Door enkel / in het pad van de route te gebruiken wordt deze toegankelijke als http://127.0.0.1:5000.
2
Met een JOIN tussen beide tables halen we de informatie op van elke post maar ook van de auteur van de post.
3
Met de fetchall functie krijgen we een lijst van alle rows. Elk item in die lijst stelt 1 row voor, maar krijgen we opnieuw als een dictionary.
4
Door die lijst (posts) mee te geven in de render_template functie zullen we deze kunnen gebruiken in de template.
flaskr/templates/blog/index.html.j2
{% extends 'base.html.j2' %}

{% block header %}
<h1>{% block title %}Posts{% endblock %}</h1>
1{% if g.user %}
<a class="action" href="{{ url_for('blog.create') }}">New</a>
{% endif %}
{% endblock %}

{% block content %}
2{% for post in posts %}
<article class="post">
  <header>
    <div>
3      <h1>{{ post['title'] }}</h1>
      <div class="about">by {{ post['username'] }} on {{ post['created'] }}</div>
    </div>
4    {% if g.user['id'] == post['author_id'] %}
    <a class="action" href="{{ url_for('blog.update', id=post['id']) }}">Edit</a>
    {% endif %}
  </header>
  <p class="body">{{ post['body'] }}</p>
</article>
{% if not loop.last %}
<hr>
{% endif %}
{% endfor %}
{% endblock %}
1
Voor ingelogde gebruikers (m.a.w. als g.user niet None is) voegen we een extra link toe naar de (toekomstige) pagina om een nieuwe post te maken.
2
Voor de inhoud van de pagina itereren we over de lijst van posts die wordt meegegeven aan de template. De post variable zal dus telkens een dictionary zijn met alle velden uit de database query.
3
Zo is post['title'] bijvoorbeeld de titel van één post en gebruiken we dit voor de H1 header.
4
Als de ID van de auteur van een post hetzelfde is als de ID van de ingelogde gebruiker dan voegen we (voor die specifieke post) een link toe naar de pagina om een post aan te passen. In de url_for functie kunnen extra argumenten toegevoegd worden die als parameters zullen worden meegestuurd. Hier geven we de ID van de post mee zodat de blog.update view de juiste post kan laten bewerken.

De Create View

Deze view moet opnieuw zowel GET als POST methodes ondersteunen. Bij een GET wordt een form weergegeven met alle nodige velden om een nieuw post de schrijven. Bij een POST wordt de form data gevalideerd en in de database opgeslagen. Bij success wordt er ook hier geredirect naar de index pagina.

Probeer zelf als oefening (in groepen) de template en view functie te schrijven.

Tips
  • De form moet enkel de titel (title) en body van de blogpost bevatten.
  • Voor de waarde van author_id gebruiken we de user_id, die al beschikbaar is in het g object.
  • Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
flaskr/blog.py
@bp.route("/create", methods=("GET", "POST"))
1@login_required
def create():
    if request.method == "POST":
        title = request.form["title"]
        body = request.form["body"]
        error = None

        if not title:
            error = "Title is required."

        if error is not None:
            flash(error)
        else:
            db = get_db()
            db.execute(
2                "INSERT INTO post (title, body, author_id) VALUES (?, ?, ?)",
                (title, body, g.user["id"]),
            )
            db.commit()
            return redirect(url_for("blog.index"))

    return render_template("blog/create.html.j2")
1
Om een nieuwe post te maken moet de gebruiker ingelogd zijn.
2
Voor de author_id gebruiken we g.user["id"]. Deze is zeker beschikbaar dankzij onze login_required decorator.

De bijhorende template toont een eenvoudige form met een veld voor de post title en een voor de body. Voor die laatste gebruiken we een textarea element (i.p.v. input) om een groter tekstvak te krijgen.

flaskr/templates/blog/create.html.j2
{% extends 'base.html.j2' %}

{% block header %}
<h1>{% block title %}New Post{% endblock %}</h1>
{% endblock %}

{% block content %}
<form method="post">
  <label for="title">Title</label>
  <input name="title" id="title" value="{{ request.form['title'] }}" required>
  <label for="body">Body</label>
  <textarea name="body" id="body">{{ request.form['body'] }}</textarea>
  <input type="submit" value="Save">
</form>
{% endblock %}

Tussendoor Testen

Het is altijd aan te raden nieuwe code te testen van zodra dat mogelijk is. Hoe meer nieuwe code je schrijft tussen twee testen, hoe moeilijker het kan zijn een fout te debuggen.

We zouden nu in staat moeten zijn een post te schrijven en vervolgende de bestaande post(s) te bekijken.

  • Als we als ingelogde gebruiker http://127.0.0.1:5000 bezoeken zien we natuurlijk nog geen posts, maar wel een nieuwe New link.
  • Via die link komen we op http://127.0.0.1:5000/create waar we een post kunnen schrijven en opslaan.
  • We worden terug doorgestuurd naar http://127.0.0.1:5000 maar hier krijgen we een fout … omdat de blog.update view nog niet bestaat. Geen probleem dus. Als we uitloggen zouden we de post wel moeten zien (waarom?)

De Update View

Voor zowel de update als de delete views moet een post uit de database worden opgehaald (op basis van een post ID). Er moet ook gecontroleerd worden dat de ingelogde gebruiker wel degelijk de auteur is van de post.

We maken een aparte functie voor deze gemeenschappelijke functionaliteit:

flaskr/blog.py
def get_post(id: int) -> dict:
    post = (
        get_db()
1        .execute(
            "SELECT p.id, title, body, created, author_id, username"
            " FROM post p JOIN user u ON p.author_id = u.id"
            " WHERE p.id = ?",
            (id,),
        )
        .fetchone()
    )

2    if post is None:
        abort(404, f"Post id {id} doesn't exist.")

    if post["author_id"] != g.user["id"]:
        abort(403)

    return post
1
We gebruiken de gegeven id om een post uit de post table op te halen.
2
De id moet natuurlijk bestaan. De author_id van de post moet kloppen met de id van de ingelogde gebruiker. Als dit niet het geval is moet een fout worden teruggestuurd. Hiervoor gebruiken we de flask.abort functie. Deze stop verdere afhandeling van de request en stuurt een response terug met de aangegeven status code. Zijn 404 en 403 goede codes in dit geval?

Met behulp van deze functie kunnen we nu de update view schrijven. Die lijkt natuurlijk erg op de create view.

flaskr/blog.py
1@bp.route("/<int:id>/update", methods=("GET", "POST"))
@login_required
def update(id):
    post = get_post(id)

    if request.method == "POST":
        title = request.form["title"]
        body = request.form["body"]
        error = None

        if not title:
            error = "Title is required."

        if error is not None:
            flash(error)
        else:
            db = get_db()
            db.execute(
2                "UPDATE post SET title = ?, body = ? WHERE id = ?", (title, body, id)
            )
            db.commit()
            return redirect(url_for("blog.index"))

3    return render_template("blog/update.html.j2", post=post)
1
De ID van de post maakt deel uit van het pad van de route (bvb. /123/update).
2
I.p.v. INSERT gebruiken we UPDATE om een bestaande database row aan te passen
3
De details van de bestaande post worden meegeven aan de template zodat de post kan worden weergegeven in de pagina.

Ook deze template toont een eenvoudige form met velden voor de post informatie.

flaskr/templates/blog/update.html.j2
{% extends 'base.html.j2' %}

{% block header %}
<h1>{% block title %}Edit "{{ post['title'] }}"{% endblock %}</h1>
{% endblock %}

{% block content %}
<form method="post">
  <label for="title">Title</label>
1  <input name="title" id="title" value="{{ request.form['title'] or post['title'] }}" required>
  <label for="body">Body</label>
  <textarea name="body" id="body">{{ request.form['body'] or post['body'] }}</textarea>
  <input type="submit" value="Save">
</form>
<hr>
<form action="{{ url_for('blog.delete', id=post['id']) }}" method="post">
  <input class="danger" type="submit" value="Delete" onclick="return confirm('Are you sure?');">
</form>
{% endblock %}
1
Voor de title en body input velden van de form wordt in principe de informatie van de huidige post al ingevuld (bvb. post['title']). Maar als er een fout optreedt bij het verwerken van de POST komt de gebruiker op dezelfde pagina terug. In dit geval willen we de informatie uit de geposte form behouden (bvb. request.form['body'])

De Delete View

De delete view heeft geen template nodig. De delete knop in de update template POST naar de route van de delete view (/<post_id>/delete). De delete view stuurt de gebruiker gewoon terug naar de index pagina.

flaskr/blog.py
@bp.route("/<int:id>/delete", methods=("POST",))
@login_required
def delete(id):
    get_post(id)
    db = get_db()
    db.execute("DELETE FROM post WHERE id = ?", (id,))
    db.commit()
    return redirect(url_for("blog.index"))

Klaar!

De applicatie is klaar. Test zelf de verschillende functies uit. Controleer bijvoorbeeld dat elke gebruiker enkel zijn eigen posts kan bewerken. Probeer ook wat er gebeurt als je dit probeert te omzeilen.

Testen

Je applicatie handmatig uittesten is leuk maar het wordt snel problematisch als je voor elke toekomstige aanpassing alle bestaande functionaliteit met de hand moet controleren.

Gelukkig voorziet Flask een aantal handige tools om eenvoudig je applicatie functioneel te testen.

Eerste Stappen

Pytest

Om testen uit te voeren gebruiken we pytest.

uv add pytest
conda install pytest

Flask TestClient

De functie app.test_client() geeft een flask.testing.FlaskClient object terug.

Met dit object kan je gemakkelijk verschillende test requests sturen naar het app object, zonder een browser of andere externe tools te moeten gebruiken.

Bijvoorbeeld:

tests/test_auth.py
from flaskr import create_app

def test_register_get():
    app = create_app()
    client = app.test_client()
    response = client.get("/auth/register")

    assert response.status_code == 200
$ pytest tests/test_auth.py
============================== test session starts ==============================
platform darwin -- Python 3.13.5, pytest-8.4.2, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/github/flaskr
configfile: pyproject.toml
collected 1 item

tests/test_auth.py .                                                      [100%]

=============================== 1 passed in 0.08s ===============================
Pythonpath

Het is mogelijk dat je volgende foutmelding krijgt:

ImportError while loading conftest '.../flaskr/tests/test_auth.py'.
tests/conftest.py:4: in <module>
    from flaskr import create_app
E   ModuleNotFoundError: No module named 'flaskr'

Dit komt omdat ons flaskr package niet “geïnstalleerd” is en de directory waaruit we ontwikkelen niet doorzocht wordt door Python.

Hier zijn verschillende oplossing voor maar een van de eenvoudigste en binnen het kader van pytest, is om deze te configureren om de lokale project directory toe te voegen aan het PYTHONPATH tijdens het uitvoeren van de testen.

Dit kan eenvoudig door het volgende toe te voegen in het pyproject.toml bestand:

[tool.pytest.ini_options]
pythonpath = ["."]

Test Database

De vorige test is op zich veilig, omdat het om een eenvoudige GET request gaat die geen wijzigingen uitvoert. Maar wat als we registratie volledige willen testen? Of het maken van nieuwe posts.

Een van de mogelijke oplossing hiervoor is om tijdens de testen een ‘in-memory’ SQLite database te gebruiken. Hierbij bewaart SQLite alles enkel in het geheugen. Dit doen we door aan sqlite3.connect de string :memory: mee te geven (i.p.v. de naam van een bestand).

Een goed moment om onze create_app factory configureerbaar te maken:

flaskr/__init__.py
1def create_app(database: str = db.SQLITE_DB_FILE) -> Flask:
    app = Flask(__name__)
2    app.config["DATABASE"] = database

    # Bestaande code
1
create_app heeft nu een optionele database parameter. Als default behouden we gewoon "flaskr.sqlite".
2
De waarde van die parameter slaan we op onder de zelfgekozen "DATABASE" key in de config dictionary van het Flask app object.

In de get_db functie van de db.py module moeten we dan ook die zelfde configuratie gebruiken om een verbinding met de database te maken:

flaskr/db.py
1from flask import current_app, g

def get_db() -> sqlite3.Connection:
    if "db" not in g:
2        g.db = sqlite3.connect(current_app.config["DATABASE"])
        g.db.row_factory = sqlite3.Row
    return g.db
1
current_app geeft altijd het huidige Flask app object terug.
2
Op die manier krijgen we via current_app.config["DATABASE"] de geconfigureerde database naam.

Nu kunnen we op een veilige manier een test toevoegen voor de POST method:

tests/test_auth.py
def test_register_post():
   app = create_app(":memory:")
   client = app.test_client()
   response = client.post("/auth/register", data={"username": "a", "password": "a"})

   assert response.status_code == 302

Helaas werkt het nog niet helemaal en krijgen we de volgende fout:

  File "./dev/github/flaskr/flaskr/auth.py", line 35, in register
    db.execute(
    ~~~~~~~~~~^
        "INSERT INTO user (username, password) VALUES (?, ?)",
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        (username, generate_password_hash(password)),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    )
    ^
sqlite3.OperationalError: no such table: user
============================ short test summary info =============================
FAILED tests/test_auth.py::test_register_post - assert 500 == 302
=============================== 1 failed in 0.21s ================================

Wat is het probleem?

De database (in-memory!) is nooit geïnitialiseerd en heeft dus geen tables!

In de db module hebben we een functie die precies dat doet, init_db. We kunnen deze op dezelfde manier configureerbaar maken en gebruiken tijdens het opstellen van de test:

flaskr/db.py
def init_db(database: str = SQLITE_DB_FILE):
    db = sqlite3.connect(database)

    # Bestaande code
tests/test_auth.py
from flaskr import create_app, db

def test_register_post():
    app = create_app(":memory:")
    db init_db(":memory:")
    client = app.test_client()
    response = client.post("/auth/register", data={"username": "a", "password": "a"})

    assert response.status_code == 302

Nog steeds krijgen we dezelfde fout: sqlite3.OperationalError: no such table: user! De reden is nu niet zo triviaal.

In-Memory SQLite Valkuil

De in-memory SQLite Connection die onze test gebruikt heeft een bijzonderheid. Als we in de Python SQLite documentatie snuisteren komen we uit op SQLite In Memory Databases, en daar lezen we dit:

Every :memory: database is distinct from every other. So, opening two database connections each with the filename “:memory:” will create two independent in-memory databases

En even verder:

In-memory databases are allowed to use shared cache if they are opened using a URI filename. rc = sqlite3_open("file::memory:?cache=shared", &db);

Terug in de Python documentatie vinden we zelfs een voorbeeld: db = "file:mem1?mode=memory&cache=shared". We zien daar ook dat bij de sqlite3.connect functie het argument uri=True gebruikt moet worden. Dit moeten we dus ook aanpassen zowel in get_db als in init_db in onze db.py module.

flaskr/db.py
def get_db() -> sqlite3.Connection:
    print(f"GET DB WITH {current_app.config['DATABASE']}")
    if "db" not in g:
        g.db = sqlite3.connect(current_app.config["DATABASE"], uri=True)
        g.db.row_factory = sqlite3.Row
    return g.db


def close_db(_exc=None):
    if "db" in g:
        g.db.close()

def init_db(database: str = SQLITE_DB_FILE):
    print(f"INIT DB WITH {database}")
    db = sqlite3.connect(database, uri=True)

    with open(LOCAL_DIRECTORY / "schema.sql") as schema:
        db.executescript(schema.read())

    print("SQLite schema created. Tables in DB:")
    result = db.execute("SELECT name FROM sqlite_master WHERE type='table'")
    print(result.fetchall())

We passen onze test opnieuw aan. We controleren ook direct of we wel worden doorgestuurd (redirect) naar de login pagina.

tests/test_auth.py
def test_register_post():
    app = create_app("file:mem1?mode=memory&cache=shared")
    db.init_db("file:mem1?mode=memory&cache=shared")
    client = app.test_client()
    response = client.post("/auth/register", data={"username": "a", "password": "a"})

    assert response.status_code == 302
    assert response.headers["Location"] == "/auth/login"

De test werkt nu eindelijk!

User controleren in de database

Na een registratie zouden we ook graag controleren dat de nieuwe gebruiker ook daadwerkelijk is aangemaakt in de database. We voegen een extra assert toe op het einde van de test:

tests/test_auth.py
def test_register_post():
    # Bestaande code

    assert (
        db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
        is not None
    )

We krijgen echter een nieuwe fout …

        assert (
>           db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
            ^^^^^^^^^^^
            is not None
        )

tests/test_auth.py:23:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
flaskr/db.py:8: in get_db
    if "db" not in g:
       ^^^^^^^^^^^^^
.venv/lib/python3.13/site-packages/werkzeug/local.py:318: in __get__
    obj = instance._get_current_object()
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

    def _get_current_object() -> T:
        try:
            obj = local.get()
        except LookupError:
>           raise RuntimeError(unbound_message) from None
E           RuntimeError: Working outside of application context.
E
E           This typically means that you attempted to use functionality that needed
E           the current application. To solve this, set up an application context
E           with app.app_context(). See the documentation for more information.

.venv/lib/python3.13/site-packages/werkzeug/local.py:519: RuntimeError

Om deze fout beter te begrijpen blikken we even terug op Les 1 - De Application Context, ofwel op de Flask Application Context documentatie pagina.

get_db inspecteert het flask.g object. En dat object bestaat enkel binnen een application context. Verder lezen we ook dit:

Flask automatically pushes an application context when handling a request.

Maar op het moment dat we hier get_db uitvoeren zitten we niet ‘in een request’. Verder lezen we ook:

If you try to access current_app, or anything that uses it, outside an application context, you’ll get this error message:

RuntimeError: Working outside of application context.

This typically means that you attempted to use functionality that
needed to interface with the current application object in some way.
To solve this, set up an application context with app.app_context().

Onze test wordt dus:

tests/test_auth.py
def test_register_post():
    app = create_app("file:mem1?mode=memory&cache=shared")
    db.init_db("file:mem1?mode=memory&cache=shared")
    client = app.test_client()
    response = client.post("/auth/register", data={"username": "a", "password": "a"})

    assert response.status_code == 302
    assert response.headers["Location"] == "/auth/login"

    with app.app_context():
        assert (
            db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
            is not None
        )

Pytest Fixtures

Een van de beste features in pytest is fixtures.

Met fixtures kunnen data of objecten die in verschillende testen gebruikt worden eenmalig gedefinieerd worden. Vervolgens kan elke test die een fixture nodig heeft deze verkrijgen door simpelweg de naam de de fixture functie in de argumenten van de test-functie te plaatsen.

Als we naar onze eerste twee testen kijken zien we dat ze beiden een test client (FlaskClient) nodig hebben om test requests te versturen. Een prima kandidaat voor een fixture dus.

tests/test_auth.py
1import pytest
from flask import Flask
from flask.testing import FlaskClient

from flaskr import create_app, db

2@pytest.fixture
def app():
    database = "file:mem1?mode=memory&cache=shared"
    app = create_app(database)
    db.init_db(database)
    return app

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

4def test_register_get(client: FlaskClient):
    response = client.get("/auth/register")

    assert response.status_code == 200

5def test_register_post(app: Flask, client: FlaskClient):
    response = client.post("/auth/register", data={"username": "a", "password": "a"})

    assert response.status_code == 302
    assert response.headers["Location"] == "/auth/login"

    with app.app_context():
        assert (
            db.get_db().execute("SELECT * FROM user WHERE username = 'a'").fetchone()
            is not None
        )
1
We moeten pytest importeren om fixtures te gebruiken. Uit flask importeren we ook een paar classes om als type hints te gebruiken.
2
Een eerste fixture maakt de test Flask applicatie aan en geeft het app object terug. We maken van de gelegenheid gebruik om alvast de test database te initialiseren.
3
Fixtures kunnen zelf ook andere fixtures gebruiken! In dit geval gebruiken we de app fixture om daarmee een FlaskClient object terug te geven.
4
Die client fixture kan dan gebruikt worden in de twee bestaande, en toekomstige testen.
5
Voor test_register_post hebben we de app fixture ook nog even nodig, om toegang te krijgen tot de app_context methode.

get_db zullen we hoogstwaarschijnlijk nog vaker willen gebruiken om in andere testen iets te controleren in de database. Het zou handig zijn als we altijd al in een app_context context manager zouden zitten. Dat kan op de volgende manier:

tests/test_auth.py
@pytest.fixture
def app():
    database = "file:mem1?mode=memory&cache=shared"
    app = create_app(database)
    db.init_db(database)
    with app.app_context():
        yield app

De with app.app_context() in de test_register_post test is nu niet meer nodig. De test heeft bijgevolg de app fixture ook niet meer nodig.

Een functie kan zowel met return als met yield een waarde teruggeven. Maar wat is dan het verschil?

  • return geeft een waarde terug en beëindigt meteen de functie.
  • yield geeft een waarde terug, maar pauzeert de functie zodat die later hervat kan worden.

yield wordt vooral gebruikt bij Generators. Maar dus ook bij test fixtures zoals hierboven, waarbij we de context manager pas kunnen verlaten als het app object niet meer nodig is, m.a.w. na de test.

Meer informatie vind je in de Pytest documentatie.

De auth testen verder uitwerken

Registratie: unhappy path

Voor het registratieproces hebben we nu een test voor het zogenaamde happy path. Maar wat als de gegevens in de registratie POST request niet correct zijn?

Bijvoorbeeld als zowel username als password leeg zijn…

tests/test_auth.py
def test_register_no_username(client):
    response = client.post("/auth/register", data={"username": "", "password": ""})
    assert b"Username is required." in response.data

Of als enkel het password leeg is …

tests/test_auth.py
def test_register_no_password(client):
    response = client.post("/auth/register", data={"username": "test", "password": ""})
    assert b"Password is required." in response.data

Of als de username reeds bestaat…

tests/test_auth.py
def test_register_existing_user(client):
    conn = db.get_db()
    conn.execute(
        "INSERT INTO user (username, password) VALUES "
        "('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
    )
    conn.commit()
    response = client.post("/auth/register", data={"username": "test", "password": "b"})
    assert b"already registered" in response.data

Pytest Parametrize

Naast fixtures is parametrize een van de interessantste Pytest features.

De laatste drie testen houden allemaal verband met elkaar en verschillen slechts in een aantal parameters:

  • De gebruikte username en password
  • Het verwachten bericht in de response.data

Met de pytest.mark.parametrize decorator kunnen we de drie testen herleiden tot een enkele:

tests/test_auth.py
@pytest.mark.parametrize(
1    ("username", "password", "message"),
2    (
        ("", "", b"Username is required."),
        ("test", "", b"Password is required."),
        ("test", "test", b"already registered"),
    ),
)
3def test_register_validate_input(client, username, password, message):
    conn = db.get_db()
    conn.execute(
        "INSERT INTO user (username, password) VALUES "
        "('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
    )
    conn.commit()
    response = client.post(
        "/auth/register", data={"username": username, "password": password}
    )
    assert message in response.data
1
Het eerste argument van de decorator is een tuple met de namen (keys) van de verschillende parameters.
2
Het tweede argument is een tuple van tuples, met de waarden (values) die voor de parameters gebruikt zullen worden.
3
De namen van de parameters worden meegegeven aan de test functie (en wel na eventuele fixtures). De test functie zal dan uitgevoerd worden voor elke combinatie parameters.

Login

Probeer als oefening zelf de testen te schrijven voor de login route. Je kan dit op dezelfde manier aanpakken als de register testen die we reeds hebben.

Wat willen we allemaal testen?

  1. Een bestaande gebruiker kan inloggen
    1. GET + POST
    2. Doorgestuurd naar de index (/)
    3. Bij een volgende GET / bevat de session cookie de user_id
  2. Een bestaande gebruiker kan niet inloggen, en krijgt de juiste foutmelding, wanneer de username of password foutief zijn.
Antwoord
tests/test_auth.py
from flask import Flask, session, g

def test_login(client):
    conn = db.get_db()
    conn.execute(
        "INSERT INTO user (username, password) VALUES "
        "('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
    )
    conn.commit()
    assert client.get("/auth/login").status_code == 200
    response = client.post("/auth/login", data={"username": "test", "password": "test"})
    assert response.headers["Location"] == "/"

    with client:
        client.get("/")
        assert session["user_id"] == 1
        assert g.user["username"] == "test"
tests/test_auth.py
@pytest.mark.parametrize(
    ("username", "password", "message"),
    (
        ("a", "test", "Incorrect username."),
        ("test", "a", "Incorrect password."),
    ),
)
def test_login_validate_input(client, username, password, message):
    conn = db.get_db()
    conn.execute(
        "INSERT INTO user (username, password) VALUES "
        "('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
    )
    conn.commit()
    response = client.post(
        "/auth/login", data={"username": username, "password": password}
    )
    assert message in response.text

Het toevoegen van een test user wordt verschillende keren herhaald. Op welke manieren kunnen dit verhelpen?

Test Coverage

Met onze bestaande testen wordt al heel wat code van onze applicatie getest. We hebben niet zo veel code en testen, dus het is hier vrij triviaal om te bepalen wat nog niet getest wordt. In een grotere codebase met honderden testen wordt dat al wat moeilijker.

Met het coverage package krijgen we een exact beeld van de code die wel en niet uitgevoerd wordt door onze testen!

uv add coverage

Dan gebruiken we coverage om met pytest de testen opnieuw uit te voeren:

$ coverage run -m pytest tests/
====================== test session starts =======================
platform darwin -- Python 3.13.5, pytest-8.4.2, pluggy-1.6.0
rootdir: /Users/dfabrice/dev/github/flaskr
configfile: pyproject.toml
collected 8 items

tests/test_auth.py ........                                [100%]

======================= 8 passed in 0.36s ========================

Op het eerste zicht is er niets veranderd, maar er is een .coverage bestand aangemaakt. Dit is een database (SQLite!) waarin coverage alle resultaten bijhoudt. We voegen het best ook toe aan de .gitignore zodat het niet gecommit wordt.

Hieruit kan dan een rapport opgevraagd worden:

$ coverage report -m
Name                 Stmts   Miss  Cover   Missing
--------------------------------------------------
flaskr/__init__.py      18      2    89%   17, 25
flaskr/auth.py          60      5    92%   90-91, 97-100
flaskr/blog.py          58     38    34%   24-43, 47-64, 70-90, 96-100
flaskr/db.py            25      2    92%   40-41
tests/test_auth.py      41      0   100%
--------------------------------------------------
TOTAL                  202     47    77%

De -m (missing) zorgt er voor dat we een overzicht krijgen van de ongeteste blokken code. Met coverage html krijgen we een interactieve HTML pagina die heet nog eenvoudiger maakt de resultaten te interpreteren.

Uit onze auth module testen we al 92% van de code! Bekijk welke code niet getest is en verklaar waarom dit zo is.

Antwoord
  • Voor de logout route hebben simpelweg nog geen test geschreven.
  • De login_required decorator wordt in geen enkele van de auth routes zelf gebruikt. Als we later de blog routes testen komt daar automatisch verandering in.

Let op, 100% code coverage is doorgaans geen must een zeker geen goede doelstelling. Het is een handige metriek om een ruw idee te krijgen van wat wel en niet getest wordt.

Maar…

  • Coverage is geen garantie voor goede testen en zeker niet voor bug-vrije code.
  • Bijgevolg is het een erg slechte doelstelling om aan ontwikkelingsteams op te leggen. Zie ook Goodhart’s law.

De blog module testen

Als we over testen voor de blog module nadenken wordt het al snel duidelijk dat we dezelfde app en client fixtures zullen nodig hebben. Pytest voorziet een handige optie om duplicatie te vermijden: fixtures in een speciaal conftest.py bestand zijn automatisch beschikbaar in alle testen uit dezelfde en onderliggende folders.

We verplaatsen dus eerst de twee fixtures (met bijhorende imports) naar tests/conftest.py en bevestigen dat de bestaande testen nog werken.

Een test blogpost en de index

Om te starten testen we de index (startpagina) en willen bevestigen dat we op deze pagina links hebben om in te loggen of te registreren en test post kunnen zien.

De test database die voor elke test wordt aangemaakt bevat wel al een test user, maar geen post. Voor deze eerste test hebben we al direct een test post nodig en voor latere testen misschien ook. Dus we breiden de app fixture uit om ook een test post in de database toe te voegen.

tests/conftest.py
import pytest
from flask import Flask

from flaskr import create_app, db

@pytest.fixture
def app():
    database = "file:mem1?mode=memory&cache=shared"
    app = create_app(database)
    db.init_db(database)
    with app.app_context():
        conn = db.get_db()
        conn.execute(
            "INSERT INTO user (username, password) VALUES "
            "('test', 'scrypt:32768:8:1$B6EWUB7sblZHpKwE$74951791e0ebcdcf91999e0e4c3e7768fcb87f994c35de0d80e99d83ec36e9f542b76c4d486c57ced5cea72fd76c3f5a64b0a2c31a89a02e3a86a52a6f52fb1c')"
        )
        conn.execute(
            "INSERT INTO user (username, password) VALUES "
            "('other', 'scrypt:32768:8:1$6DDdh5peSL3fmCBy$c997bb4e0ecdeb5ad2e94cd4cde3a58efa290889a7c40497b154cd23df46ffc25f731370f849ab262fd310d9ee48ce5a9950a6910e08b9a8f52ec791acb85c61')"
        )
        conn.execute(
            "INSERT INTO post (title, body, author_id, created) VALUES"
            "('test title', 'test' || x'0a' || 'body', 1, '2025-01-01 00:00:00')"
        )
        conn.commit()

        yield app

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

Een eenvoudige test voor de / route kunnen we dan als volgt schrijven:

tests/test_blog.py
import pytest
from flask.testing import FlaskClient


def test_index(client: FlaskClient):
    response = client.get("/")
    assert b"Log In" in response.data
    assert b"Register" in response.data
    assert b"test title" in response.data
    assert b"by test on 2025-01-01" in response.data
    assert b"test\nbody" in response.data

login_required

De login_required decorator moet er voor zorgen dat enkel ingelogde gebruikers posts kunnen aanmaken, updated of verwijderen. Op welke manier kunnen we functioneel gaat testen dat een niet-ingelogde gebruiker deze mogelijkheden niet heeft?

Probeer zelf de test te schrijven. Tip: de test ziet er precies hetzelfde uit voor elke route die we testen - je kan dus opnieuw gebruik maken van pytest.mark.parametrize.

Oplossing
tests/test_blog.py
@pytest.mark.parametrize("path", ("/create", "/1/update", "/1/delete"))
def test_login_required(client: FlaskClient, path: str):
    response = client.post(path)
    assert response.headers["Location"] == "/auth/login"

Blogpost auteur validatie

  • Een ingelogde user kan geen blogpost updated of verwijderen als deze niet door hem is aangemaakt.
  • Een blogpost die niet bestaat updaten of verwijderen wordt beantwoord met een HTTP 404 status code.
Oplossing
tests/test_blog.py
def test_author_required(client: FlaskClient):
    client.post("/auth/login", data={"username": "other", "password": "other"})

    # current user can't modify other user's post
    assert client.post("/1/update").status_code == 403
    assert client.post("/1/delete").status_code == 403

    # current user doesn't see edit link
    assert b'href="/1/update"' not in client.get("/").data

@pytest.mark.parametrize(
    "path",
    (
        "/2/update",
        "/2/delete",
    ),
)
def test_post_not_exists(client: FlaskClient, path):
    client.post("/auth/login", data={"username": "test", "password": "test"})
    assert client.post(path).status_code == 404

Blogpost maken en updaten

  • Een GET request van een ingelogde user voor zowel de create als update views wordt beantwoord met een HTTP 200 status code.
  • Wanneer een ingelogde user geldige data POST naar de create view wordt een nieuwe post in de database weggeschreven.
  • Wanneer een ingelogde user geldige data POST naar de update view wordt de bestaande post in de database geupdate.
  • Wanneer een ingelogde user ongeldige data (lege title) POST naar beide views wordt een foutmelding teruggestuurd en is er geen update in de database
Oplossing
tests/test_blog.py
def test_create(client: FlaskClient):
    client.post("/auth/login", data={"username": "test", "password": "test"})

    assert client.get("/create").status_code == 200
    client.post(
        "/create", data={"title": "test_create title", "body": "test_create body"}
    )

    conn = db.get_db()
    body = conn.execute(
        "SELECT body FROM post WHERE title = 'test_create title'"
    ).fetchone()[0]
    assert body == "test_create body"

def test_update(client: FlaskClient):
    client.post("/auth/login", data={"username": "test", "password": "test"})

    assert client.get("/1/update").status_code == 200
    client.post("/1/update", data={"title": "updated title", "body": "updated body"})

    conn= db.get_db()
    post = conn.execute("SELECT * FROM post WHERE id = 1").fetchone()
    assert post["title"] == "updated title"
    assert post["body"] == "updated body"

@pytest.mark.parametrize(
    "path",
    (
        "/create",
        "/1/update",
    ),
)
def test_create_update_validate(client: FlaskClient, path: str):
    client.post("/auth/login", data={"username": "test", "password": "test"})

    response = client.post(path, data={"title": "", "body": "invalid post"})
    assert b"Title is required." in response.data

    conn = db.get_db()
    post = conn.execute("SELECT * FROM post WHERE body = 'invalid post'").fetchone()
    assert post is None

Delete

  • Een ingelogde user kan een van zijn eigen blogposts verwijderen en wordt dan teruggestuurd naar de index. De blogpost is dan verwijderd uit de database.
Oplossing
tests/test_blog.py
def test_delete(client: FlaskClient):
    client.post("/auth/login", data={"username": "test", "password": "test"})

    response = client.post("/1/delete")
    assert response.headers["Location"] == "/"

    conn = db.get_db()
    post = conn.execute("SELECT * FROM post WHERE id = 1").fetchone()
    assert post is None

Extras

Lees/kijk-voer en referenties

Referentie Implementatie

Een volledig werkende implementatie gebaseerd op deze cursus vind je op https://github.com/dvx76/flask-tutorial.