# Tester le système : Swagger UI, curl et un script de test en Python<no value>

## Qu'allons-nous apprendre ?

Un serveur qui n'a pas été testé est un serveur dont on ne peut pas être sûr qu'il fonctionne. Nous allons apprendre trois façons, du manuel à l'automatique :

| Outil | Quand |
|---|---|
| **Swagger UI** (`/docs`) | Des tests manuels pratiques, pendant le développement |
| **curl** | Des tests rapides depuis le terminal, une habitude des gens du backend et du DevOps |
| **Un script Python** | Une série de tests qui s'exécute en un clic, et vérifie **tout**, à chaque fois |

Et un **Test Plan** : ce que l'on teste, et ce que l'on s'attend à obtenir.

## Pourquoi en avons-nous besoin ?

Chaque modification du code peut casser quelque chose qui fonctionnait. Si le test est « j'ai ouvert le navigateur et ça a l'air bien », des bugs atteindront les utilisateurs. Une liste fixe de tests, et surtout un script qui l'exécute, permet de modifier le code **en toute confiance**. C'est aussi un entraînement pour le projet d'intégration, où une journée entière est consacrée au débogage et aux tests manuels de toutes les actions CRUD.

## Explication à partir des bases

### Swagger UI

**Swagger UI** est une page que FastAPI **construit automatiquement** à partir du code : des routes, des Type Hints et des modèles Pydantic. Elle affiche chaque endpoint, et permet d'envoyer de vraies requêtes depuis le navigateur.

```text
http://127.0.0.1:8000/docs
```

**Le processus pour chaque endpoint :**

1. Cliquer sur l'endpoint (par exemple `POST /students`) l'ouvre.
2. **Try it out** → les champs s'ouvrent à la modification.
3. Remplissez les paramètres, et/ou modifiez le **Request body** (un exemple de JSON apparaît déjà, construit à partir du modèle !).
4. **Execute** → une vraie requête est envoyée au serveur.
5. En dessous : **Curl** (la commande équivalente !), la **Request URL**, le **Response body**, le **Response code** et les **Response headers**.

**Un bonus :** `http://127.0.0.1:8000/openapi.json` existe aussi. C'est **la description** de l'API dans un format standard (OpenAPI), à partir de laquelle Swagger construit la page. D'autres outils savent la lire.

### curl — un rappel

```bash
curl -i http://127.0.0.1:8000/students
curl -i -X POST http://127.0.0.1:8000/students -H "Content-Type: application/json" -d '{"name": "Tom", "age": 27, "email": "tom@mail.com"}'
curl -i -X DELETE http://127.0.0.1:8000/students/3
```

`-i` affiche aussi la ligne d'état, et c'est exactement ce que l'on teste.

{{< box "tip" "curl avec du JSON dans PowerShell" >}}
Dans PowerShell, les guillemets dans le JSON sont compliqués. Deux façons pratiques : (1) enregistrez le JSON dans un fichier (`new_student.json`) et envoyez-le avec `curl.exe -i -X POST URL -H "Content-Type: application/json" -d "@new_student.json"`. (2) Copiez la commande curl que **Swagger affiche** et exécutez-la dans Git Bash.
{{< /box >}}

### Un script de test en Python

Un script qui envoie des requêtes à un serveur **en cours d'exécution**, et vérifie pour chacune : **le code est-il celui attendu ?** À la fin, il affiche combien de tests ont réussi. Un test comme celui-ci, qui fait fonctionner le système de l'extérieur comme un vrai client, s'appelle un test **End-to-End**. (Plus tard dans le parcours, vous apprendrez des outils de test professionnels, comme pytest. L'idée est la même.)

### Test Plan — une liste de tests

Pour chaque endpoint : **le cas où tout va bien** + **chaque** cas d'échec du tableau de correspondance. Si chaque ligne du tableau a un test, vous savez que le contrat de l'API est respecté.

## Syntaxe

```python
import json
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    request = urllib.request.Request(BASE_URL + path, data=data, method=method,
                                     headers={"Content-Type": "application/json"})
    try:
        response = urllib.request.urlopen(request)
        status, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        status, raw = error.code, error.read()
    return status, (json.loads(raw) if raw else None)
```

`call` renvoie un **tuple** (le code, le corps de la réponse), en cas de succès comme d'erreur (`HTTPError`). Un corps vide (204) → `None`.

## Premier exemple de code

**Swagger — créer un étudiant étape par étape :**

1. Lancez le serveur (`uvicorn main:app --reload`) et ouvrez `/docs`.
2. **POST /students** → **Try it out**. Le Request body affiche :

```json
{
  "name": "string",
  "age": 0,
  "email": "string"
}
```

3. Remplacez-le par :

```json
{
  "name": "Tom",
  "age": 27,
  "email": "tom@mail.com"
}
```

4. **Execute**. Le résultat :

```text
Code: 201
Response body:
{
  "id": 3,
  "name": "Tom",
  "age": 27,
  "email": "tom@mail.com"
}
```

5. Cliquez de nouveau sur **Execute**, avec le même body. Maintenant : **409**, `"a student with email tom@mail.com already exists"`.

## Analyse du code ligne par ligne

| Ce que l'on voit dans Swagger | D'où cela vient dans le code |
|---|---|
| La liste de tous les endpoints | Les décorateurs `@app.get/post/put/delete` |
| Le nom de l'API en haut (« Students API ») | `FastAPI(title="Students API")` |
| L'exemple de JSON dans le body | Le modèle `StudentIn` (champs et types) |
| Le champ `student_id` avec `integer` | Le Type Hint `student_id: int` |
| Le code 201 comme « Successful Response » dans POST | `status_code=201` |
| L'id 3 dans la réponse | `db.create_student` → AUTO_INCREMENT |

**Tout est dérivé du code.** Quand vous modifiez une route, la documentation se met à jour toute seule. C'est pourquoi elle est toujours exacte.

## Un autre exemple

**Un script de test de bout en bout.** `smoke_test.py` (le serveur doit tourner dans un autre terminal, sur une table **vide** : dans Database Client, exécutez `DROP TABLE students;`, et redémarrez le serveur — `init_db` recrée la table) :

```python
import json
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"

def call(method, path, body=None):
    data = json.dumps(body).encode() if body is not None else None
    request = urllib.request.Request(BASE_URL + path, data=data, method=method,
                                     headers={"Content-Type": "application/json"})
    try:
        response = urllib.request.urlopen(request)
        status, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        status, raw = error.code, error.read()
    return status, (json.loads(raw) if raw else None)

CHECKS = [
    ("create Dana", "POST", "/students", {"name": "Dana", "age": 24, "email": "dana@mail.com"}, 201),
    ("create Avi", "POST", "/students", {"name": "Avi", "age": 31, "email": "avi@mail.com"}, 201),
    ("duplicate email", "POST", "/students", {"name": "Dana Copy", "age": 20, "email": "dana@mail.com"}, 409),
    ("too young", "POST", "/students", {"name": "Kid", "age": 7, "email": "kid@mail.com"}, 400),
    ("missing fields", "POST", "/students", {"name": "Noa"}, 422),
    ("list", "GET", "/students", None, 200),
    ("get existing", "GET", "/students/1", None, 200),
    ("get missing", "GET", "/students/99", None, 404),
    ("bad id", "GET", "/students/abc", None, 422),
    ("update", "PUT", "/students/2", {"name": "Avi Levi", "age": 32, "email": "avi@mail.com"}, 200),
    ("update missing", "PUT", "/students/99", {"name": "Ghost", "age": 30, "email": "g@mail.com"}, 404),
    ("update clash", "PUT", "/students/2", {"name": "Avi", "age": 32, "email": "dana@mail.com"}, 409),
    ("delete", "DELETE", "/students/1", None, 204),
    ("delete again", "DELETE", "/students/1", None, 404),
]

def main():
    passed = 0
    for name, method, path, body, expected in CHECKS:
        status, data = call(method, path, body)
        ok = status == expected
        passed = passed + (1 if ok else 0)
        mark = "PASS" if ok else "FAIL"
        print(mark, "|", name, "|", method, path, "| expected", expected, "got", status)
    print(passed, "/", len(CHECKS), "checks passed")

if __name__ == "__main__":
    main()
```

**Sortie :**

```text
PASS | create Dana | POST /students | expected 201 got 201
PASS | create Avi | POST /students | expected 201 got 201
PASS | duplicate email | POST /students | expected 409 got 409
PASS | too young | POST /students | expected 400 got 400
PASS | missing fields | POST /students | expected 422 got 422
PASS | list | GET /students | expected 200 got 200
PASS | get existing | GET /students/1 | expected 200 got 200
PASS | get missing | GET /students/99 | expected 404 got 404
PASS | bad id | GET /students/abc | expected 422 got 422
PASS | update | PUT /students/2 | expected 200 got 200
PASS | update missing | PUT /students/99 | expected 404 got 404
PASS | update clash | PUT /students/2 | expected 409 got 409
PASS | delete | DELETE /students/1 | expected 204 got 204
PASS | delete again | DELETE /students/1 | expected 404 got 404
14 / 14 checks passed
```

**Analyse :**

- **`CHECKS`** — une liste de tuples : le nom du test, la méthode, le chemin, le body et le code attendu. Les **données** sont séparées de la **logique** (la séparation des responsabilités !). Ajouter un test = ajouter une ligne.
- **L'ordre compte** : les tests dépendent les uns des autres (on crée d'abord, puis on supprime). C'est pourquoi on part d'une base vide.
- **La boucle** — envoie, compare avec `expected`, et affiche PASS/FAIL.
- **La magie :** vous changez quelque chose dans le code (un refactoring), vous lancez, et en une seconde vous voyez si quelque chose a cassé. **Un filet de sécurité.**

**Essayez de le casser :** dans `main.py`, changez `status_code=204` en `200` dans DELETE, et enregistrez (`--reload`). Supprimez la table (`DROP TABLE students;` dans Database Client), redémarrez, et lancez le script :

```text
FAIL | delete | DELETE /students/1 | expected 204 got 200
13 / 14 checks passed
```

Le script **a détecté** la modification. Remettez le code en place.

## Erreur courante

**Erreur 1 : ne tester que le cas où tout va bien.** « J'ai créé un étudiant, et ça a marché » ≠ « le serveur fonctionne ». La plupart des bugs du code serveur sont dans les cas d'échec : un 404 qui revient en 200, un 409 qui revient en 500. **Test Plan = le cas où tout va bien + chaque ligne d'échec du tableau de correspondance.**

**Erreur 2 : lancer le script de test sur une base contenant des données précédentes.** « create Dana » échouera avec 409, car Dana existe déjà depuis l'exécution précédente. **Une base vide avant chaque exécution** (ou, plus tard dans le parcours, une base de test séparée).

**Erreur 3 : oublier que le serveur doit tourner.**

```text
urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made because the target machine actively refused it>
```

« Connection refused » = aucun serveur n'écoute sur le port (souvenez-vous du thème des réseaux !). Lancez uvicorn dans un terminal séparé.

**Erreur 4 : ne vérifier que le code, et pas le corps.** Un code 200 avec un corps faux (un nom qui n'a pas été mis à jour) reste un bug. Dans les tests importants, vérifiez aussi les valeurs : `data["name"] == "Avi Levi"`. (Voir la pratique en autonomie.)

<!-- exercises:start — generated by scripts/intro6/sync_site_exercises.py; edit scripts/intro6/exercises/ -->
## Exercices

Les exercices n'utilisent que ce qui a été vu dans cette leçon et les précédentes. Essayez d'abord seul, et n'ouvrez la solution qu'ensuite.

### Exercice 1: Test Plan

Écrivez un Test Plan pour l'API des étudiants : au moins 8 tests, avec la méthode, le chemin, ce que vous envoyez, et le code que vous vous attendez à obtenir. Incluez le cas où tout va bien **et aussi** des cas d'échec.

{{< solution >}}
| # | Test | Requête | Code attendu |
|---|---|---|---|
| 1 | Créer Dana | `POST /students` avec un corps valide | 201 |
| 2 | Un e-mail en double | `POST /students` avec l'e-mail de Dana | 409 |
| 3 | Trop jeune | `POST /students` avec l'âge 7 | 400 |
| 4 | Des champs manquants | `POST /students` avec seulement un nom | 422 |
| 5 | Un étudiant existant | `GET /students/1` | 200 |
| 6 | Un étudiant qui n'existe pas | `GET /students/99` | 404 |
| 7 | Un id non numérique | `GET /students/abc` | 422 |
| 8 | Supprimer | `DELETE /students/1` | 204 |
| 9 | Supprimer à nouveau | `DELETE /students/1` | 404 |

Le premier test réussit toujours — **la plupart des bugs sont dans les cas d'échec** : un 404 qui revient en 200, ou un 409 qui revient en 500.
{{< /solution >}}

### Exercice 2: curl

Écrivez des commandes curl pour un serveur tournant sur `http://127.0.0.1:8000` :

1. Créer un étudiant (Dana, 24, dana@mail.com) — en affichant le code.
2. Lire l'étudiant 1.
3. Supprimer l'étudiant 1 — en affichant le code.

{{< solution >}}
```powershell
curl -i -X POST http://127.0.0.1:8000/students -H "Content-Type: application/json" -d '{"name": "Dana", "age": 24, "email": "dana@mail.com"}'
curl http://127.0.0.1:8000/students/1
curl -i -X DELETE http://127.0.0.1:8000/students/1
```

`-i` affiche le code d'état — dans les tests, c'est la chose la plus importante à voir. Dans Swagger (`/docs`), vous pouvez effectuer les mêmes requêtes depuis le navigateur.
{{< /solution >}}

### Exercice 3: La fonction call

Écrivez une fonction `call(method, path, body=None)` qui envoie une requête au serveur avec `urllib.request`, et renvoie un tuple (le code, le corps sous forme de dictionnaire ou `None`). Les erreurs HTTP (4xx, 5xx) ne doivent pas faire planter — elles renvoient aussi un code.

{{< solution >}}
```py
import json
import urllib.error
import urllib.request

BASE_URL = "http://127.0.0.1:8000"


def call(method, path, body=None):
    data = None
    if body is not None:
        data = json.dumps(body).encode()
    request = urllib.request.Request(BASE_URL + path, data=data, method=method,
                                     headers={"Content-Type": "application/json"})
    try:
        response = urllib.request.urlopen(request)
        status, raw = response.status, response.read()
    except urllib.error.HTTPError as error:
        status, raw = error.code, error.read()
    if raw:
        return status, json.loads(raw)
    return status, None
```

- `urlopen` lance une `HTTPError` sur les 4xx et 5xx — et dans les tests, ce sont justement les réponses qui nous intéressent, on les attrape donc et on renvoie le code.
- Une réponse 204 n'a pas de corps — donc `None`.
{{< /solution >}}

### Exercice 4: Le script qui affiche PASS / FAIL

Écrivez la partie qui parcourt la liste des tests et affiche un résultat. Pour vous entraîner sans serveur, les résultats sont déjà dans la liste : le nom du test, le code attendu et le code reçu.

```py
results = [
    ("create Dana", 201, 201),
    ("duplicate email", 409, 409),
    ("get missing", 404, 200),
    ("delete", 204, 204),
]
```

**Résultat attendu :**

```text
PASS | create Dana | expected 201 got 201
PASS | duplicate email | expected 409 got 409
FAIL | get missing | expected 404 got 200
PASS | delete | expected 204 got 204
3 / 4 checks passed
```

{{< solution >}}
```python
results = [
    ("create Dana", 201, 201),
    ("duplicate email", 409, 409),
    ("get missing", 404, 200),
    ("delete", 204, 204),
]

passed = 0
for name, expected, got in results:
    if expected == got:
        status = "PASS"
        passed = passed + 1
    else:
        status = "FAIL"
    print(status, "|", name, "| expected", expected, "got", got)
print(passed, "/", len(results), "checks passed")
```

```text
PASS | create Dana | expected 201 got 201
PASS | duplicate email | expected 409 got 409
FAIL | get missing | expected 404 got 200
PASS | delete | expected 204 got 204
3 / 4 checks passed
```

Avec un vrai serveur, `got` vient de `call(...)`. Le FAIL révèle ici un vrai bug : un étudiant qui n'existe pas revient en 200 au lieu de 404.
{{< /solution >}}

### Exercice 5: Le serveur ne tourne pas

Vous lancez le script de test, et vous obtenez :

```text
urllib.error.URLError: <urlopen error [WinError 10061] No connection could be made
because the target machine actively refused it>
```

1. Qu'est-ce que cela signifie ?
2. Comment lancer correctement les tests ?
3. Pourquoi vaut-il la peine de partir d'une base vide ?

{{< solution >}}
1. **Aucun serveur n'écoute** à l'adresse et au port — la connexion a été refusée. Le script est un **client**, et il a besoin d'un serveur en marche.
2. **Deux terminaux** : dans l'un, lancez `uvicorn main:app`, et dans l'autre le script de test.
3. Pour que les résultats soient **prévisibles** : l'id du premier étudiant vaut 1, et la liste commence vide. Sur une base avec d'anciennes données, « créer Dana » peut renvoyer 409 parce qu'elle existe déjà — et le test échouera sans qu'il y ait de bug.
{{< /solution >}}

<!-- exercises:end -->

## Résumé

- **Swagger UI** (`/docs`) : Try it out → body → Execute. Construit automatiquement à partir du code, et affiche aussi la commande curl équivalente.
- **curl -i** : un test rapide depuis le terminal. Dans PowerShell, du JSON depuis un fichier (`-d "@file.json"`) ou Git Bash.
- **Un script Python (End-to-End)** : une liste de tests (les données) + une boucle (la logique) → PASS/FAIL. Un filet de sécurité pour chaque modification.
- **Test Plan** = le cas où tout va bien + **chaque** cas d'échec du tableau de correspondance. Vérifiez les codes **et aussi** le corps.
- Lancez les tests sur une base vide, et avec un serveur en marche.

<!-- docs:start — generated by scripts/intro6/site_docs.py; edit the links there -->

## Documentation officielle

- [FastAPI — Interactive API docs](https://fastapi.tiangolo.com/tutorial/first-steps/#interactive-api-docs)
- [curl — manual](https://curl.se/docs/manpage.html)
- [urllib.request](https://docs.python.org/3/library/urllib.request.html)

<!-- docs:end -->
