In questo post vedremo come configurare moduli e strumenti per lanciare, documentare e testare una codebase.

Lanciare un codice è solo in apparenza la fine di un progetto. Documentare i comportamenti e le definizioni, e testare che tutto vada come previsto sono aspetti di grande importanza.

Sources: https://pxhere.com/en/photo/1188160, https://www.publicdomainpictures.net/en/view-image.php?image=424040&picture=rapid-test-corona-test


Nel nostro post precedente abbiamo visto come progettare un’API in Python usando il framework FastAPI. Oggi vedremo come lanciare un server Python, e documentare e testare una codebase.

Esecuzione e Gestione delle Dipendenze

L’entrypoint di sem è nel modulo modules/main.py:

# modules/main.py

26	import uvicorn
27	
28	
29	__version__ = "1.0.0"
30	
31	
32	def main():
33	    "Execute main entrypoint."
34	    uvicorn.run("modules.api:app", host="0.0.0.0", reload=True)
35	
36	
37	if __name__ == "__main__":
38	    main()

Il modulo uvicorn viene usato per avviare applicazioni che restano in attesa di richieste HTTP (come la mia app FastAPI, specificata come argomento).

  • L’applicazione utilizzerà l’indirizzo specificato come host (sulla porta di default, 8000, poiché non ne abbiamo fornita una come argomento).
  • L’indirizzo 0.0.0.0 viene utilizzato per futura compatibilità con la containerizzazione (il valore di default sarebbe 127.0.0.1 o localhost, che funzionano anche con questa scelta per host).
  • Impostando reload a True il server verrà rilanciato se uno dei file sorgente viene modificato.

Adesso il server può essere lanciato con il comando

$ python -m modules.main

nella riga di comando del sistema; molto probabilmente, però, questo risulterà in un fallimento, a meno che tutti i pacchetti necessari non siano installati sul sistema.

Per assicurarsi che tutte le dipendenze siano installate, sem è preconfigurato con poetry, un gestore di pacchetti Python. Nello specifico, il repository contiene un file pyproject.toml, che elenca le dipendenze richieste dal progetto e altre informazioni rilevanti.

# pyproject.toml

 1	[tool.poetry]
 2	name = "sem"
 3	version = "0.1.0"
 4	description = "Simple expense manager"
 5	authors = ["aangelone2 <adriano.angelone.work@gmail.com>"]
 6	license = "GPL v3"
 7	readme = "README.md"
 8	packages = [{include = "modules"}]
 9	
10	[tool.poetry.dependencies]
11	python = "^3.11"
12	SQLAlchemy = "^2.0.20"
13	SQLAlchemy-Utils = "^0.41.1"
14	psycopg = "^3.1.14"
15	fastapi = "^0.104.1"
16	uvicorn = "^0.24.0"
17	rich = "^13.7.0"
18	
19	[tool.poetry.group.dev.dependencies]
20	pytest = "^7.4.3"
21	httpx = "^0.25.1"
22	
23	[tool.poetry.group.docs.dependencies]
24	mkdocs = "^1.5.3"
25	mkdocstrings = {extras = ["python"], version = "^0.24"}
26	mkdocs-material = "^9.4.11"
27	
28	[tool.poetry.scripts]
29	sem = "modules.main:main"
30	
31	[build-system]
32	requires = ["poetry-core"]
33	build-backend = "poetry.core.masonry.api"

Dopo alcune informazioni sul progetto, le dipendenze vengono elencate in gruppi (alcune sono necessarie per il progetto stesso, alcune per lo sviluppo, altre per la documentazione). Di default, poetry installerà tutti i gruppi, se non diversamente indicato.

Il gruppo principale tool.poetry.dependencies contiene il pacchetto python: poetry configurerà un ambiente virtuale con questa versione di python come interprete, dove verranno installati tutti i pacchetti e verrà lanciato il programma (per evitare conflitti con pacchetti di sistema). La sezione tool.poetry.scripts consente di definire degli entrypoint (con la sintassi modulo:funzione) che possono essere lanciati con la sintassi

$ poetry run <entrypoint>

Il mettere ^ all’inizio di un numero di versione implica che dovrebbero essere installate versioni recenti almeno quanto quella specificata, dove il numero di versione maggiore è lo stesso (ad esempio, ^1.0 installerà 1.2 se disponibile, ma non 2.0).

Quando il repository viene clonato, è necessario eseguire (come menzionato nel README) il comando

$ poetry install

Questo installerà le versioni più recenti dei pacchetti specificati che non sono in conflitto con i requisiti degli altri, rispettando i vincoli impostati in pyproject.toml, e predisporrà gli entrypoint di poetry.

Quando poetry install viene lanciato per la prima volta (e ogni volta che l’ambiente viene modificato, a causa di nuove versioni dei pacchetti) poetry genera un file poetry.lock, che memorizza le versioni effettivamente installate dei pacchetti. Questo di solito è incluso nel repository (come in sem) e consente un processo di installazione molto più veloce, poiché poetry lo utilizzerà nelle successive chiamate a poetry install.

Una volta completata la configurazione di poetry, il server può essere lanciato nell’ambiente creato da poetry:

$ poetry run sem

Se lanciato da una directory diversa dalla root del repository, bisognerà lanciare il comando

$ poetry --directory <repository root> run sem

Vedremo in seguito come configurare strumenti di automazione (in pratica, un makefile) per semplificare l’avvio del server.

Una volta lanciato in questo modo, il server continuerà a girare finché arrestato manualmente, ricevendo e rispondendo a richieste HTTP. Accedere, ad esempio, a http://localhost:8000/query/ su uno web browser eseguirà una di queste richieste, che riceverà in risposta una lista di tutte le spese nel database.

Documentazione

Il nostro progetto offrirà due set di documentazione: uno per i moduli non API (usando le docstring aggiunte nei vari moduli) e uno per l’API (usando le descrizioni fornite nei decoratori FastAPI). La separazione tra questi due può sembrare poco intuitiva, ma alla fine non è troppo scomoda, poiché la documentazione interna è indirizzata a un pubblico diverso da quella dell’API (sviluppatori con focus verso server e client, rispettivamente).

Questi set di documentazione saranno offerti da server di documentazione, servizi a cui un client (come un browser web) può connettersi tramite richieste HTTP, ricevendo in cambio la documentazione da visualizzare.

Documentazione degli Internals

sem usa il modulo mkdocs per generare documentazione per gli internals del codice. Questa utility estrae la sua configurazione dal file mkdocs.yml nella directory root del progetto, che in sem ha la forma

# mkdocs.yml

 1	site_name: sem
 2	
 3	dev_addr: "0.0.0.0:8001"
 4	
 5	theme:
 6	  name: "material"
 7	
 8	plugins:
 9	  - mkdocstrings
10	
11	nav:
12	  - Homepage: index.md
13	  - Reference:
14	    - models: reference/models.md
15	    - schemas: reference/schemas.md
16	    - crud_handler: reference/crud_handler.md
17	    - session: reference/session.md
  • dev_addr imposta l’URL al quale sarà disponibile la documentazione. Il valore di default è 127.0.0.1 (localhost) sulla porta 8000, che ho cambiato in 8001 per poter accedere alla documentazione mentre si utilizza il server (che sarebbe sulla stessa porta).
  • theme imposta l’aspetto grafico delle pagine della documentazione. Qui abbiamo scelto il tema mkdocs-material, che abbiamo specificato come dipendenza nel file pyproject.toml.
  • mkdocstrings è un plugin per mkdocs, che consente di generare automaticamente pagine di documentazione da docstring di moduli, classi e funzioni.
  • nav è un elenco di pagine di documentazione (scritto in Markdown). La struttura di questo elenco (incluse le directory in cui sono organizzate le pagine) rispecchierà l’organizzazione del sito web della documentazione. Qui impostiamo una pagina principale e una pagina di riferimento per ciascun modulo nella directory reference/ (quest’ultima sarà generata a partire dalle docstring).

La pagina principale solitamente contiene informazioni generali sul progetto ed eventualmente collegamenti alle altre sezioni della documentazione. Nel nostro caso, si tratta semplicemente di una copia del file README, archiviato in docs/index.md. La pagina risultante dovrebbe apparire in questo modo:

    Screenshot della pagina principale della documentazione generata da mkdocs.

Il menu sul lato sinistro dà accesso alla pagina principale e alle pagine dei moduli, dove funzioni e classi mostrano le loro descrizioni. Queste sono generati dai file di documentazione di ciascun modulo, che ha la forma (qui, per il modulo crud_handler.py)

# docs/reference/crud_handler.md

::: modules.crud_handler
    options:
        docstring_style: numpy

che ordina al plugin mkdocstrings di usare le docstring nel file specificato (ho usato lo stile numpy per le docstrings, che non è l’impostazione predefinita). Le pagine di documentazione risultanti dovrebbero avere la forma

Screenshot della documentazione per il modulo `crud_handler` generata da mkdocs.

Una volta completata questa configurazione, la documentazione viene creata e servita utilizzando i comandi

$ poetry run mkdocs build
$ poetry run mkdocs serve

e può essere visualizzata all’indirizzo http://localhost:8001.

Documentazione per l’API

Il servizio di documentazione API, integrato in FastAPI, usa le docstring solo per i modelli pydantic presenti nelle path functions (da cui la loro assenza in modules/api.py). Raccoglie invece informazioni sui modelli pydantic e sulle funzioni API tramite le descrizioni negli specificatori Field() e nei decoratori FastAPI.

Questa documentazione è accessibile quando il server è in esecuzione all’indirizzo <URL>/docs o <URL>/redoc, dove <URL> è l’URL del server (qui, http://localhost:8000). La pagina esposta visualizza un elenco degli endpoint raggruppati per metodo e URL (di seguito è riportato uno screenshot di esempio della documentazione esposta a /docs/ per l’endpoint /add).

Screenshot della documentazione API per l'endpoint `/add` (parametri ed esempio di corpo della richiesta).

La documentazione mostrerà i parametri consentiti, esempi di tipo di risposta e altre informazioni, come qui sotto per l’endpoint /add.

Screenshot della documentazione API dell'endpoint `/add` (possibili codici e modelli di risposta).

Testing

Concetti e Strumenti Generali

Quando si scrivono applicazioni, è essenziale scrivere una qualche forma di test, che garantisca in modo automatico (o almeno semiautomatico) che il codice si comporti come previsto, coprendo un numero sufficientemente ampio di situazioni e casi limite.

Esistono diversi protocolli per testare un programma: qui eseguiremo principalmente unit test (che testano la funzionalità di singole parti del programma, come le funzioni) e test di integrazione (che testano l’interazione fra i componenti del programma).

Utilizzeremo il modulo pytest per eseguire test automatici, dato che gli strumenti che offre semplificano notevolmente la gestione dei test. Questo framework cerca file Python il cui nome inizia con test_; questi dovrebbero contenere funzioni il cui nome inizia con test_, che verranno eseguite e che conterranno controlli sulla funzionalità del programma. pytest riporterà informazioni dettagliate sull’esito di questi controlli, consentendo di diagnosticare eventuali problemi qualora si presentassero.

Il primo componente della suite di test è il file tests/common.py, che contiene strumenti generali utilizzate da tutti i test. TEST_DB_NAME sarà il nome di un database separato per i dati di test (per evitare di inquinare il database principale del programma), mentre expenses è una tupla di oggetti ExpenseRead che verranno utilizzati per riempire il database di test con dati di esempio.

# tests/common.py

 9	from contextlib import contextmanager
10	
11	from modules.schemas import ExpenseAdd
12	from modules.schemas import ExpenseRead
13	from modules.crud_handler import CRUDHandler
14	
15	
16	TEST_DB_NAME = "sem-test"
17	
18	
19	# Example expenses for testing
20	expenses = (
21	    ExpenseRead(
22	        id=1,
23	        date="2023-12-31",
24	        type="R",
25	        category="gen",
26	        amount=-12.0,
27	        description="test-1",
28	    ),
29	    ExpenseRead(
30	        id=2,
31	        date="2023-12-15",
32	        type="C",
33	        category="test",
34	        amount=-13.0,
35	        description="test-2",
36	    ),
        ...
61	)

Per eseguire test realistici del service layer, è utile riempire il database di test con alcuni dati di esempio, che verranno estratti, aggiornati e così via. Per semplificare il test, è opportuno svuotare il database di test prima che il test venga eseguito e dopo averlo completato, per avere un ambiente di test controllato. Questo potrebbe essere fatto manualmente, ma è molto più semplice definire un context manager di test per automatizzare la pulizia.

# tests/common.py

 80	@contextmanager
 81	def CRUDHandlerTestContext() -> CRUDHandler:
 82	    """Manage testing context for CRUDHandler.
 83	
 84	    Inits and inserts example data, removing all data
             from table at closure.
 85	
 86	    Yields
 87	    -----------------------
 88	    CRUDHandler
 89	        The context-managed and populated CRUDHandler.
 90	    """
 91	    # Easier to define a new context manager,
 92	    # should call erase() in __enter__() and __exit__().
 93	    ch = CRUDHandler(TEST_DB_NAME)
 94	    ch.erase()
 95	
 96	    for exp in expenses:
 97	        # ID field ignored by pydantic constructor
 98	        ch.add(ExpenseAdd(**exp.model_dump()))
 99	
100	    try:
101	        yield ch
102	    finally:
103	        ch.erase()
104	        ch.close()

Questo è molto simile a CRUDHandlerContext(), con la differenza che 1) i dati vengono inseriti nel database di test prima della chiamata a yield, in un database di test svuotato, e 2) il database viene ripulito prima di chiudere il contesto.

In teoria, questo potrebbe essere sostituito tramite mocking del database, cioè sostituendo l’accesso al database con una funzione che restituisca dati, bypassando il database. Ciò consentirebbe uno unit-testing più rigoroso del service layer, poiché non avrebbe luogo alcuna interazione con il data layer. Tuttavia, ho ritenuto che le funzioni di test riportate di seguito, che sono essenzialmente test di integrazione di data e service layer, siano sufficienti per diagnosticare problemi anche nel solo service layer.

Test per CRUDHandler

I nostri primi test riguarderanno la classe CRUDHandler, verificando che i suoi metodi funzionino come previsto in diversi casi. Questi vengono eseguiti nel file tests/test_crud_handler.py: non mostreremo qui l’intero file, ma solo alcuni test che usano strumenti utili.

Il primo test mostrato qui testa il metodo query(), eseguendo una ricerca senza filtri (abbiamo omesso per somiglianza i test seguenti, che applicano anche filtri e controllano che vengano restituite solo le spese corrispondenti).

# tests/test_crud_handler.py

 6	from fastapi.encoders import jsonable_encoder
 7	
 8	from pytest import raises
...
13	from modules.schemas import QueryParameters
14	from modules.crud_handler import CRUDHandlerError
15
16	from tests.common import expenses
...
18	from tests.common import CRUDHandlerTestContext
19
20
21	def test_global_query():
22	    """Tests no-parameter query."""
23	    with CRUDHandlerTestContext() as ch:
24	        # retrieve all expenses
25	        res = ch.query(QueryParameters())
26	        expected = [
27	            expenses[4],
28	            expenses[3],
29	            expenses[2],
30	            expenses[1],
31	            expenses[0],
32	        ]
33	
34	        assert jsonable_encoder(res) == jsonable_encoder(expected)
                ...

La funzione di test viene eseguita all’interno del contesto CRUDHandlerTestContext() e dovrebbe restituire i dati inseriti. L’output diretto della query viene quindi confrontato con un elenco delle voci inserite, ordinate per data (per corrispondere all’output del metodo query()).

Come in tutte le funzioni di test pytest, una direttiva assert consente di confrontare l’output previsto con quello effettivo. jsonable_encoder() serializza le espressioni confrontate ed è necessario per evitare problemi di precisione quando si confrontano i campi a virgola mobile degli oggetti ottenuti con quelli previsti.

Un test più complesso, mostrato di seguito, è quello per la funzione remove(). Vengono rimosse due spese esistenti e interrogato il database, per verificare che le spese rimosse siano effettivamente scomparse e che le altre siano rimaste intatte; quindi si tenta la cancellazione di una spesa inesistente.

352	def test_remove():
353	    """Tests removal function."""	
354	    with CRUDHandlerTestContext() as ch:
355	        # Selective removal
356	        ch.remove([3, 1])
357	
358	        res = ch.query(QueryParameters())
359	        expected = [
360	            expenses[4],
361	            expenses[3],
362	            expenses[1],
363	        ]
364	
365	        assert jsonable_encoder(res) == jsonable_encoder(expected)
366	
367	        # Nonexistent ID
368	        with raises(CRUDHandlerError) as err:
369	            res = ch.remove([19])
370	        assert str(err.value) == "ID 19 not found"
371	
372	        # Checking that no changes are committed in case of error
373	        res = ch.query(QueryParameters())
374	        assert jsonable_encoder(res) == jsonable_encoder(expected)
                ...

Alla riga 369, il tentativo di rimozione di un QueryParameters inesistente dovrebbe risultare in un’eccezione CRUDHandlerError. La funzione pytest.raises() crea un contesto in cui le istruzioni che generano eccezioni possono essere racchiuse senza causare un crash del programma (al contrario, un’eccezione di quel tipo diventa necessaria per far sì che il test abbia successo). Quindi, il messaggio di errore viene confrontato con quello previsto (accedendo all’attributo value dell’eccezione).

Test per l’API

Il file tests/test_api.py contiene test da eseguire tramite l’API. Questi a loro volta coinvolgeranno il service e il data layer, che vengono testati in modo indipendente nei file precedenti. A causa della natura più complessa dei test tramite API, sono richieste alcune dichiarazioni preliminari.

 6	from fastapi.testclient import TestClient
 7	from fastapi.encoders import jsonable_encoder
 8	
 9	import pytest
10	
11	from modules.schemas import ExpenseRead
12	from modules.crud_handler import CRUDHandler
13	from modules.crud_handler import CRUDHandlerContext
14	from modules.api import app
15	from modules.api import get_ch
16	
17	from tests.common import TEST_DB_NAME
        ...
19	from tests.common import expenses
20	from tests.common import CRUDHandlerTestContext
21	
22	
23	def get_test_ch() -> CRUDHandler:
24	    """Yield CRUDHandler object for testing purposes.
25	
26	    Pre-populated with some expenses, linked to "sem-test" DB.
27	
28	    Yields
29	    -----------------------
30	    CRUDHandler
31	        The CRUDHandler object.
32	    """
33	    with CRUDHandlerContext(TEST_DB_NAME) as ch:
34	        yield ch

get_test_ch() è una funzione che agisce come get_ch() nel modulo API, producendo un oggetto CRUDHandler connesso al database di testing. Il suo utilizzo risulterà evidente dalla funzione successiva:

37	@pytest.fixture
38	def test_client():
39	    """Construct FastAPI test client."""
40	    app.dependency_overrides[get_ch] = get_test_ch
41	    return TestClient(app)

C’è molto da discutere su questa funzione:

  • Le fixture, dichiarate tramite il decoratore @pytest.fixture, sono funzioni che restituiscono oggetti che possono essere utilizzati nelle funzioni di test, dove vengono passati come argomenti. I contesti (come CRUDHandlerTestContext() sopra) sono comunque preferibili se è necessario eseguire azioni di cleanup.
  • app_dependency_overrides è un attributo di tipo dict degli oggetti FastAPI. Le sue chiavi sono le dipendenze dell’oggetto, mentre i suoi valori sovrascriveranno le dipendenze, sostituendole nel resto del modulo.
    Di default, il dizionario è vuoto e non vengono eseguite sovrascritture; qui stiamo indicando a FastAPI di sostituire get_ch() con get_test_ch() in tutti gli endpoint. Quindi, l’API accederà al database di test anziché a quello principale.
  • TestClient è un oggetto FastAPI che funge da client HTTP, consentendo di inviare richieste al server e ricevere risposte. Questo oggetto verrà utilizzato nelle funzioni di test seguenti, come risultato del passaggio della fixture come parametro.

Il nostro primo esempio di test API sarà il test dell’API add(), dove una nuova spesa verrà aggiunta al database di test, che verrà poi interrogato per verificare che la procedura sia andata a buon fine.

152	def test_add_api(test_client):
153	    """Tests adding function."""
154	    with CRUDHandlerTestContext():
155	        # Skipping category
156	        new_exp = {
157	            "date": "2023-12-12",
158	            "type": "M",
159	            "amount": -1.44,
160	            "description": "added via API",
161	        }
162	        response = test_client.post("/add", json=new_exp)
163	
164	        assert response.status_code == 200
165	        assert response.json() == {"message": "expense added"}
166	
167	        response = test_client.get("/query?types=M")
168	
169	        expected = [
170	            expenses[2],
171	            ExpenseRead(id=6, **new_exp),
172	        ]
173	
174	        assert response.status_code == 200
175	        assert response.json() == jsonable_encoder(expected)

Qui una nuova spesa è creata (in formato JSON, poiché la richiesta richiederà un corpo JSON) e passata al metodo post() di TestClient (il metodo HTTP chiamato dovrebbe essere lo stesso dell’endpoint). La path viene fornita come argomento posizionale, mentre il corpo viene passato tramite l’argomento json.

Il codice di stato e il corpo della risposta (tramite i suoi attributi status_code e json) vengono confrontati con il valore previsto. Il metodo TestClient.get() viene quindi chiamato sull’URL /query (notare i query parameters passati) e la risposta viene confrontata con il valore previsto.

Il nostro ultimo esempio di test sarà quello per l’API di rimozione: qui dobbiamo gestire il caso in cui vengano restituiti errori.

432	def test_remove_api(test_client):
433	    """Tests removing request."""
434	    with CRUDHandlerTestContext():
435	        # Selective removal
436	        response = test_client.delete("/remove?ids=3&ids=1")
437	
438	        assert response.status_code == 200
439	        assert response.json() == {"message": "expense(s) removed"}
440	
441	        response = test_client.get("/query")
442	
443	        expected = [
444	            expenses[4],
445	            expenses[3],
446	            expenses[1],
447	        ]
448	
449	        assert response.json() == jsonable_encoder(expected)
450	
451	        # Inexistent ID
452	        response = test_client.delete("/remove/?ids=19")
453	        assert response.status_code == 404
454	        assert response.json() == {"detail": "ID 19 not found"}
...

Come accennato in precedenza, le HTTPException non sono vere e proprie eccezioni Python: di conseguenza, la richiesta restituisce comunque una risposta, il cui codice di stato e corpo della risposta vengono confrontati con il valore atteso.

La suite di test sopra descritta viene eseguita lanciando il comando

$ poetry run python3 -m pytest -x -s -v .

Le opzioni di riga di comando non sono strettamente necessarie, ma a mio avviso sono utili:

  • -v aggiunge un utile livello di verbosità;
  • -x interrompe l’esecuzione al primo test fallito;
  • -s stampa l’output del test (che altrimenti sarebbe nascosto).

pytest mostrerà in dettaglio quali test hanno fallito e quali hanno avuto successo, funzione per funzione, e offrirà informazioni aggiuntive in caso di fallimento.

Nelle Prossime Puntate

In questo post abbiamo capito come lanciare un programma server, gestirne le dipendenze, costruire e navigarne la documentazione ed eseguire test.

Nella prossima puntata di questa serie, impareremo come distribuire rapidamente il nostro server tramite containers con docker, e come scrivere un’interfaccia da riga di comando semplice ma efficace per usarlo.


Autore: Adriano Angelone

Dopo aver ottenuto la Laurea Magistrale in Fisica all’Università di Pisa nel 2013, ha ricevuto il Dottorato in Fisica all’Università di Strasburgo nel 2017. Ha lavorato come ricercatore post-dottorale all’Università di Strasburgo, alla SISSA (Trieste) e all’Università Sorbona (Parigi), prima di entrare in eXact-lab come Sviluppatore di Software Scientifico nel 2023.

In eXact-lab, lavora all’ottimizzazione di codici computazionali e allo sviluppo di software di gestione dati e data engineering.