Links
NOTES
TODO:
- Git commit point voor testen
- Testen vereenvoudigen. Pytest al gegeven door Karsten. Focus op test client.
- Kort intro hoofdstuk SQLite en sqlite3 package
- SQLite 'toy database'? -> https://www.youtube.com/watch?v=ZP7ef4eVnac
- Views/templates moeten direct werken en geen read redirects hebben
- De Index View ->
url_for("blog.create")+url_for("blog.update") - Eerste auth view ->
url_for("auth.login") - Tweede auth view ->
url_for("blog.index") - In de plaatse placeholder code (gewoon string bvb) en later aanpassen.
- De Index View ->
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¶
Alle paden en bestandsnamen die we vanaf nu gebruiken zijn relatief t.o.v. deze directory.
uv of conda environment initialiseren¶
.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.
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# SQLite database files
*.sqlite
Git initialiseren¶
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
├── flaskr/ # (1)
│ ├── __init__.py # (1)
│ ├── templates/ # (2)
│ └── static/ # (3)
└── tests/ # (4)
flaskris een Python package voor de applicatie- in
templates/komen alle jinja2 templates - in
static/komen alle statische elementen zoals css, afbeeldingen, ... - 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 | |
|---|---|
- Flask verwacht ofwel een globale
appvariabele, ofwel eencreate_appfactory functie. We zullen dus nergenscreate_appexpliciet moeten aanroepen. - Als eerste stap moeten we natuurlijk een
Flaskapp object maken. - Omdat we cookies willen gebruiken (via het
flask.sessionobject) hebben we een key nodig. Om te starten gebruiken we een dummy key maar later moeten we dit beter beveiligen. - Voor het renderen van Jinja templates willen we altijd autoescape gebruiken. Op deze manier kunnen we onze templates de extensie
.html.j2geven. - We voegen direct een simpele test-route toe aan de applicatie. Deze code kan je later verwijderen.
sqlite?!
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](1_inleiding.md#de-application-context).
```python title="flaskr/db.py" linenums="1"
import sqlite3
from pathlib import Path
from flask import g
LOCAL_DIRECTORY = Path(__file__).parent # (1)
SQLITE_DB_FILE = LOCAL_DIRECTORY / "flaskr.sqlite" # (1)
def get_db() -> sqlite3.Connection:
if "db" not in g: # (2)
g.db = sqlite3.connect(SQLITE_DB_FILE) # (3)
g.db.row_factory = sqlite3.Row # (4)
return g.db
def close_db(_exc=None):
if "db" in g:
g.db.close() # (5)
pathlibis een handige module uit the Python standard library om met bestanden en directories te werken. MetPath(__file__).parentkrijgen we de directory waardb.pyzich bevindt. Op die manier zijn we zeker dat we altijd met hetzelfde sqlite bestand werken.- De
sqlite3.Connectionvoor een bepaaldeRequestwordt bewaard in hetflask.gobject. Op die manier wordt er maar één connection per request aangemaakt. - Om eenvoudig te starten gebruiken we vast SQLite database bestand. Later zullen we dit configureerbaar willen maken.
- Deze configuratie zorgt ervoor dat de
sqlite3.Connectionrows zal returnen als dictionaries i.p.v. tuples. Dit is iets handiger om met de data te werken. - 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 commando's 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.
We breiden db.py uit zodat we het kunnen uitvoeren om bovenstaande schema toe te passen op een SQLlite database.
- We maken een verbinding met hetzelfde database bestand.
- Net als het SQLite bestand verwachten we dat
schema.sqlin dezelfde directory staat als dedb.pymodule zelf. Metopenenreadlezen we dat bestand in en voeren het uit als een script in de database, met de functieexecutescript. - Achteraf controleren we welke tables effectief bestaan.
- Deze code zal enkel worden uitgevoerd als we het
db.pybestand rechtstreeks uitvoeren, niet als het gewoon geïmporteerd wordt.
Deze code moeten we één maal uitvoeren zodat de database klaar is voor gebruik.
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 | |
|---|---|
- De `db module moet natuurlijk geïmporteerd worden.
teardown_appcontextregistreert functies die uitgevoerd moeten worden als de "Application Context" (gobject) 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.
De Blueprint heeft:
- Een vrij te kiezen naam, hier gewoon
auth - De naam van de package of module waartoe de
Blueprintbehoort. Net zoals voor deFlaskapp gebruiken we__name__(dus uiteindelijkflaskr) - Een
url_prefixdie 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 | |
|---|---|
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.
- Een view aan een blueprint route koppelen werkt op precies delfde manier als rechtstreeks in de app. I.p.v.
app.routegebruiken webp.route. - Het
requestobject gebruiken om de HTTP method en form te bekijken kennen we al. - Zowel de username als het password mogen uiteraard niet leeg zijn.
- De
get_dbfunctie uit onze eigendbmodule geeft eenConnectionobject om met de database te werken. - Met de
Connection.executekunnen we standaard SQL commando's naar de database sturen. Een commit naar de database moet expliciet gebeuren via decommitfunctie. - Het password bewaren we natuurlijk niet in zijn oorspronkelijke vorm. Met de
generate_password_hashfunctie uit dewerkzeugpackage verkrijgen we een hash van het password. - Als er reeds een database row bestaat met dezelfde username zal de
executefunctie eenIntegrityErrorexception geven (dankzij deUNIQUEconstraint in het schema). In dat geval bereiden we een verklarende foutmelding voor. - Als alles is gelukt sturen we de gebruiker door naar de login pagina.
"auth.login"slaat op deloginview in theauthblueprint en die moeten we natuurlijk nog maken. - Met de
flask.flashfunctie kunnen we een string registreren die we later in de template zullen gebruiken om de aandacht van de gebruiker te trekken. - We gebruiken een template (deze bekijken we in het volgende hoofdstuk) om de registratiepagina terug te sturen. Dit gebeurt in twee scenarios:
- De gebruiker bezocht de pagina rechtstreeks en moet de form dus nog invullen (
request.method == "GET") - Er was een fout or probleem bij het verwerken van de form (POST)
- De gebruiker bezocht de pagina rechtstreeks en moet de form dus nog invullen (
Extra
Meer weten over:
- Placeholders (bound parameters) in sqlite commando's: https://docs.python.org/3/library/sqlite3.html#sqlite3-placeholders
- Werkzeug: https://werkzeug.palletsprojects.com/en/stable/
generate_password_hash: https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.security.generate_password_hash
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:
- De
get_flashed_messages()functie geeft een lijst terug met alle berichten (strings) waarmee deflashuitgevoerd werd tijdens deze request. Op die manier kunnen we (optioneel) problemen uit de laatste POST melden aan de gebruiker. - Een typische login form, met velden voor
usernameenpasswordwordt gepresenteerd.
We kunnen nu in principe de route al voor een deel uittesten.
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_iduit 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`](https://werkzeug.palletsprojects.com/en/stable/utils/#werkzeug.security.check_password_hash) kan je controleren of `hash` de hash is die hoort bij `password`.
- Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
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 | |
|---|---|
before_app_requestis net zoalsrouteeen 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.- We gaan the de
flask.gproxy (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 geenuser_idte vinden is in desessionkunnen we ook niets ophalen uit de database en initialiseren weg.usermetNone. Dit wordt later dan ook de aanduiding dat de gebruiker nog moet inloggen. - Als er wél een
user_idis gebruiken we deze om de volledige row van die user op te zoeken en bewaren het resultaat ing.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 | |
|---|---|
- We maken een eigen
login_requireddecorator. Die zullen we kunnen gebruiken met@login_requiredboven bepaalde view functies. - Als er geen
g.userdata is sturen we de request direct door naar de login pagina. Verder gebeurt er niets in deze decorator. - 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 | |
|---|---|
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.j2auth/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.
- De
title,headerencontentblocks blijven hier leeg en zullen worden ingevuld in de individuele templates. Het zijn hier dus louter placeholders. - De
url_forfunctie is automatisch beschikbaar in templates. - Bovenaan de pagina willen we navigatie-elementen
- Ook het
flask.gobject 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. - Met de
get_flashed_messagesfunctie krijgen we een lijst met alle strings die in the view functies met deflashfunctie zijn aangemaakt.
De Register Template¶
- Alle individuele templates zullen de base template uitbreiden
- Op deze manier overschrijven we de (initieel lege)
headerentemplateblokken uit de base template. The inhoud zal komen te staan op de plaats van deze blokken in de base template. - 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.
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.
- Ga naar http://127.0.0.1:5000/auth/register en geef een username and password in.
- Je wordt automatisch doorgestuurd naar http://127.0.0.1:5000/auth/login.
- Log in met dezelfde gegevens.
- Je krijgt een fout omdat de
indexview nog niet bestaat
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.
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:
- Index, om alle blogposts van alle gebruikers te laten zien.
- Create, om een ingelogde gebruiker een nieuwe post te laten schrijven.
- 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 | |
|---|---|
- Onze eigen
login_requireddecorator zullen we nodig hebben voor de views waarmee een blogpost bewerkt wordt. - Ook
get_dbhebben we opnieuw nodig, ditmaal om blogpost naar de database weg te schrijven en terug op te halen. - In tegenstelling tot de auth
Blueprintgeven we hier geenurl_prefixargument mee. Hierdoor komen alle routes voor dezeBlueprintin 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 | |
|---|---|
De Index View¶
De index pagina zal alle blogpost laten zien, van nieuw naar oud.
| flaskr/blog.py | |
|---|---|
- Door enkel
/in het pad van de route te gebruiken wordt deze toegankelijke als http://127.0.0.1:5000. - Met een
JOINtussen beide tables halen we de informatie op van elke post maar ook van de auteur van de post. - Met de
fetchallfunctie krijgen we een lijst van alle rows. Elk item in die lijst stelt 1 row voor, maar krijgen we opnieuw als een dictionary. - Door die lijst (
posts) mee te geven in derender_templatefunctie zullen we deze kunnen gebruiken in de template.
- Voor ingelogde gebruikers (m.a.w. als
g.usernietNoneis) voegen we een extra link toe naar de (toekomstige) pagina om een nieuwe post te maken. - Voor de inhoud van de pagina itereren we over de lijst van
postsdie wordt meegegeven aan de template. Depostvariable zal dus telkens een dictionary zijn met alle velden uit de database query. - Zo is
post['title']bijvoorbeeld de titel van één post en gebruiken we dit voor de H1 header. - 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_forfunctie kunnen extra argumenten toegevoegd worden die als parameters zullen worden meegestuurd. Hier geven we de ID van de post mee zodat deblog.updateview 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_idgebruiken we de user_id, die al beschikbaar is in hetgobject. - Start eenvoudig en voeg stap voor stap functionaliteit toe.
Oplossing
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
Newlink. - 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.updateview 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:
- We gebruiken de gegeven
idom een post uit deposttable op te halen. - De
idmoet natuurlijk bestaan. Deauthor_idvan 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 deflask.abortfunctie. 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.
- De ID van de post maakt deel uit van het pad van de route (bvb.
/123/update). - I.p.v.
INSERTgebruiken weUPDATEom een bestaande database row aan te passen - 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.
- Voor de
titleenbodyinput 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 | |
|---|---|
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.
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 | |
|---|---|
$ 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:
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 | |
|---|---|
create_appheeft nu een optioneledatabaseparameter. Als default behouden we gewoon"flaskr.sqlite".- De waarde van die parameter slaan we op onder de zelfgekozen
"DATABASE"key in deconfigdictionary van hetFlaskapp 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 | |
|---|---|
current_appgeeft altijd het huidigeFlaskapp object terug.- 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 | |
|---|---|
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 | |
|---|---|
| tests/test_auth.py | |
|---|---|
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.
We passen onze test opnieuw aan. We controleren ook direct of we wel worden doorgestuurd (redirect) naar de login pagina.
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 | |
|---|---|
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:
Onze test wordt dus:
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.
- We moeten
pytestimporteren om fixtures te gebruiken. Uitflaskimporteren we ook een paar classes om als type hints te gebruiken. - Een eerste fixture maakt de test Flask applicatie aan en geeft het
appobject terug. We maken van de gelegenheid gebruik om alvast de test database te initialiseren. - Fixtures kunnen zelf ook andere fixtures gebruiken! In dit geval gebruiken we de
appfixture om daarmee eenFlaskClientobject terug te geven. - Die
clientfixture kan dan gebruikt worden in de twee bestaande, en toekomstige testen. - Voor
test_register_posthebben we deappfixture ook nog even nodig, om toegang te krijgen tot deapp_contextmethode.
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 | |
|---|---|
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.
Extra: yield vs return
Een functie kan zowel met return als met yield een waarde teruggeven. Maar wat is dan het verschil?
returngeeft een waarde terug en beëindigt meteen de functie.yieldgeeft 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 | |
|---|---|
Of als enkel het password leeg is ...
| tests/test_auth.py | |
|---|---|
Of als de username reeds bestaat...
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
usernameenpassword - Het verwachten bericht in de
response.data
Met de pytest.mark.parametrize decorator kunnen we de drie testen herleiden tot een enkele:
- Het eerste argument van de decorator is een tuple met de namen (keys) van de verschillende parameters.
- Het tweede argument is een tuple van tuples, met de waarden (values) die voor de parameters gebruikt zullen worden.
- 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?
- Een bestaande gebruiker kan inloggen
- GET + POST
- Doorgestuurd naar de index (
/) - Bij een volgende
GET /bevat de session cookie deuser_id
- Een bestaande gebruiker kan niet inloggen, en krijgt de juiste foutmelding, wanneer de username of password foutief zijn.
Antwoord
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!
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.Warning
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.
NOTES
Extra: VS Code plugin: https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters
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.
Een eenvoudige test voor de / route kunnen we dan als volgt schrijven:
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
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
Blogpost maken en updaten¶
- Een
GETrequest 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
POSTnaar de create view wordt een nieuwe post in de database weggeschreven. - Wanneer een ingelogde user geldige data
POSTnaar de update view wordt de bestaande post in de database geupdate. - Wanneer een ingelogde user ongeldige data (lege
title)POSTnaar beide views wordt een foutmelding teruggestuurd en is er geen update in de database
Oplossing
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 | |
|---|---|
Extras¶
Lees/kijk-voer en referenties¶
- Harvard CS50x 2025 - Lecture 9 - Flask (YouTube)
- Getting started with pytest (YouTube)
- Flask User Guide
- Officiële Flask tutorial
- How to work with SQLite URIs
Referentie Implementatie¶
Een volledig werkende implementatie gebaseerd op deze cursus vind je op https://github.com/dvx76/flask-tutorial.
