<!--
  canonical-url: https://www.avonture.be/fr/blog/bruno
  published:     2025-08-07
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Bruno - Un outil à la Postman - GUI et CLI

> Découvrez Bruno, un outil de test d'API puissant à la Postman, avec support GUI et CLI.

---
![Bruno - A postman-like tool - GUI and CLI](/img/v2/api.webp)

**TL;DR**

Cet article présente Bruno, une alternative libre et gratuite à Postman pour tester vos API. Il vous guide dans la mise en place d'une API Python FastAPI d'exemple, puis dans son test via l'interface graphique et le CLI de Bruno. Vous apprendrez à créer des requêtes, gérer des environnements et lancer des tests depuis la ligne de commande avec Docker, y compris comment gérer les problèmes réseau classiques. L'article aborde aussi l'ajout d'assertions pour valider vos tests.

[Bruno](https://www.usebruno.com/) est un outil comme Postman, utilisable gratuitement. Tout est stocké sur votre machine : vous pouvez donc conserver les fichiers dans votre codebase et les envoyer dans votre outil de versioning, par exemple.

*Comme ces fichiers vivent dans votre repository, la partie CLI de Bruno se transforme naturellement en job de CI — voir [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker). Et pour vérifier le contrat de l'API plutôt que ses réponses, il y a [Validate your OpenAPI schema against the Belgif REST standards](/blog/belgif-api-linter).*

## Lancer Bruno {#run-bruno}

Une fois installé, démarrez simplement `bruno` depuis la ligne de commande pour lancer l'interface :

![Bruno homepage](./images/homepage.webp)

Commençons par le commencement : créons une collection.

![Creating a collection](./images/create_collection.webp)

Et, comme on est malins, créons aussi un environnement (pour définir l'URL racine de notre site une seule fois) :

![Create the root environment variable](./images/environment.webp)

Nous voilà prêts, créons une nouvelle requête :

![Create a request](./images/new_request.webp)

Récupérer une blague au hasard :

![Getting a random joke](./images/random_joke.webp)

Une fois la requête créée, appuyez sur `CTRL`+`ENTER` ou cliquez sur la flèche de droite :

![Running a request](./images/run_request.webp)

## Pourquoi ça fonctionne {#why-it-works}

* Tout — collection, environnements, requêtes — est stocké sous forme de fichiers texte `.bru` dans votre repo : une requête se diffe et se relit comme du code.
* La même collection s'exécute de deux façons : en mode exploration dans la GUI, ou en vérifications scriptées depuis le CLI — pas d'outil séparé à garder synchronisé.
* Une collection correspond à un dossier unique et propre (voir la section *Ouvrir le projet avec VSCode* ci-dessous) ; rien ne vit dans une base de données ou un compte cloud que vous ne contrôlez pas.

## Installation {#installation}

Pour installer la GUI de Bruno sur ma distribution Ubuntu, je lance ces commandes :

```bash
$ sudo mkdir -p /root/.gnupg

$ sudo chmod 700 /root/.gnupg

$ sudo mkdir -p /etc/apt/keyrings

$ sudo gpg --no-default-keyring --keyring /etc/apt/keyrings/bruno.gpg --keyserver keyserver.ubuntu.com --recv-keys 9FA6017ECABE0266

$ echo "deb [signed-by=/etc/apt/keyrings/bruno.gpg] http://debian.usebruno.com/ bruno stable" | sudo tee /etc/apt/sources.list.d/bruno.list

$ sudo apt update

$ sudo apt install bruno
```

Consultez la documentation officielle [Download & Install](https://docs.usebruno.com/get-started/bruno-basics/download) pour plus d'infos.

**Optionnel - Démarrer une API de test pour essayer Bruno (cliquez pour les détails)**

Bruno a besoin de quelque chose à interroger. Si vous n'avez pas d'API sous la main, voici une API de test FastAPI en une minute, empruntée à [« Python - Fast API - Créez votre API JSON en Python en une minute »](/blog/python-fastapi) :

1. Lancez `mkdir /tmp/fastapi && cd $_` pour créer un dossier temporaire et vous y placer

2. Créez un `Dockerfile` avec le contenu ci-dessous

3. Créez un `main.py` avec le contenu ci-dessous

4. Lancez la commande `docker build -t python-fastapi . && docker run --detach -v .:/app -p 82:82 python-fastapi` pour démarrer le serveur

5. Ouvrez un navigateur sur `http://127.0.0.1:82/jokes` pour voir une première blague (F5 pour en obtenir une nouvelle ; au hasard)

**Dockerfile**

```docker
# We'll use the latest version of Python and the smaller image in size (i.e. `slim`)
FROM python:slim

# We'll define the default folder in the image to /app
WORKDIR /app

# The only dependency we need is fastapi
RUN pip install --no-cache-dir 'fastapi[standard]'

# We need to copy our Python script in the image
COPY main.py main.py

# Run Uvicorn with hot reload so any changes to our main.py script will force
# the server to invalidate the cache and refresh the page.
CMD ["uvicorn", "main:app", "--reload", "--host", "0.0.0.0", "--port", "82"]
```

**main.py**

```python
from random import choice
from fastapi import FastAPI

app = FastAPI()

jokes = [
    "Why do programmers always mix up Halloween and Christmas? Because Oct 31 == Dec 25",
    "Why don't programmers like nature? Because it's full of bugs!",
    "How many programmers does it take to change a light bulb? None. That's a hardware problem.",
    "What do you call 8 hobbits? A hobbyte",
    "What is this [“hip”, ”hip”]? hip hip array!"
]

@app.get("/jokes")
async def get_jokes():
    """
    Returns a random joke from the list.
    """
    return {"joke": choice(jokes)}

@app.get("/jokes/{joke_id}")
def read_item(joke_id: int):
    """
    Returns a specific joke (between 0 and 4).
    """
    try:
        return {"joke": jokes[joke_id]}
    except IndexError:
        return {"error": f"Joke with ID {joke_id} not found."}, 404
```

## Ouvrir le projet avec VSCode {#opening-the-project-with-vscode}

En ouvrant le projet dans VSCode, on voit un nouveau dossier appelé `Jokes` (notre collection) avec très peu de fichiers : `environments/dev.bru` où se trouvent les variables de notre environnement, un fichier générique `bruno.json`, puis notre requête dans `Get a random joke.bru`.

Structure très propre, non ?

![The project in VSCode](./images/vscode.webp)

## Exécuter des requêtes depuis la ligne de commande {#running-requests-from-the-command-line}

Bruno propose une [image Docker](https://hub.docker.com/r/alpine/bruno) : elle va nous aider à automatiser l'exécution de nos requêtes depuis la ligne de commande.

> **Caution:**
>
> À la mi-juillet 2025, je n'ai pas réussi à faire fonctionner cette image comme prévu. Je tombais sur des erreurs *Cannot read properties of undefined (reading 'headers')* alors que, à mon avis, tout était correctement configuré.
>
> J'ai donc cherché une autre image et j'ai trouvé celle-ci : [davidkarlsen/bruno-image](https://github.com/davidkarlsen/bruno-image). Pas de chance non plus, même la dernière version de l'époque (2.7.0) posait problème.
>
> En regardant le [Dockerfile](https://github.com/davidkarlsen/bruno-image/blob/main/Dockerfile), j'ai vu que le fichier était vraiment simple et qu'une version plus récente de [Bruno était sortie](https://github.com/usebruno/bruno/tags) : la 2.8.
>
> Bref, je vais créer ma propre image Docker et voir si ça va mieux.

### Créer notre propre image Bruno CLI {#create-our-own-bruno-cli-image}

Créons un fichier `bruno.Dockerfile` avec le contenu suivant :

**bruno.Dockerfile**

```docker
FROM node:lts-bookworm-slim
// highlight-next-line
RUN npm install -g @usebruno/cli@2.8.0
ENTRYPOINT ["bru"]
CMD ["run"]
```

On crée notre image comme ceci : `docker build --file bruno.Dockerfile  -t bruno-image .` (on peut la vérifier en lançant `docker run -it --rm bruno-image --version` ; on devrait voir `2.8.0`).

Comme on vient de le voir, notre collection est stockée dans le dossier `Jokes` et notre environnement dans `environments/dev.bru`. Mais un container ne peut pas joindre `127.0.0.1` sur votre host : créez donc un second fichier d'environnement, `environments/dev-docker.bru`, qui pointe vers `host.docker.internal` :

```none
vars {
  host: http://host.docker.internal:82
}
```

Lancez maintenant la collection avec cet environnement :

```bash
$ docker run -it --rm -v "./Jokes":/apps -w /apps 
--add-host host.docker.internal:host-gateway 
bruno-image run --env=dev-docker
```

![Bruno CLI is working](./images/bruno_cli_is_working.webp)

### Sous le capot (à sauter si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}

Pourquoi ne pas simplement réutiliser `environments/dev.bru`, celui que la GUI utilise déjà ? Parce que l'exécuter tel quel depuis le CLI échoue :

![Running for the first time the collection from the CLI](./images/connection_refused.webp)

Regardez le message d'erreur `connect ECONNREFUSED 127.0.0.1:82` : le container Bruno CLI essaie de joindre le serveur web sur `127.0.0.1`, mais cette adresse, c'est le container lui-même, pas votre host. `environments/dev.bru` ressemble à ceci :

```none
vars {
  root: http://127.0.0.1:82
}
```

Ça fonctionne depuis la GUI, parce que la GUI tourne directement sur votre host — là, `127.0.0.1` *est* votre machine. Un `curl -v http://127.0.0.1:82/jokes` depuis la ligne de commande de votre host fonctionne aussi :

```bash
$ curl -v http://127.0.0.1:82/jokes
{`
* Uses proxy env variable no_proxy == 'localhost,127.0.0.1,*.local'
*   Trying 127.0.0.1:82...
* TCP_NODELAY set
* Connected to 127.0.0.1 (127.0.0.1) port 82 (#0)
> GET /jokes HTTP/1.1
> Host: 127.0.0.1:82
> User-Agent: curl/7.68.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< date: Wed, 23 Jul 2025 08:36:40 GMT
< server: uvicorn
< content-length: 72
< content-type: application/json
<
* Connection #0 to host 127.0.0.1 left intact
\{"joke":"Why don't programmers like nature? Because it's full of bugs!"}
`}
```

Un container a son propre namespace réseau : `127.0.0.1` à l'intérieur n'atteint jamais l'host. C'est exactement ce que corrigent `environments/dev-docker.bru` et `host.docker.internal` ci-dessus. Effet de bord : ce nouveau fichier d'environnement ne fonctionnera pas depuis la GUI de Bruno — on peut sélectionner `dev` et tout marche, mais pas `dev-docker` :

![Having a second configuration file](./images/second_environment.webp)

## Ajouter quelques assertions {#adding-some-assertions}

L'intérêt de l'outil CLI, bien sûr, c'est de pouvoir lancer des assertions depuis la ligne de commande et s'assurer que l'API fonctionne toujours.

Mettons à jour le fichier `Get a random joke.bru` comme ceci :

**Get a random joke.bru**

```bru
meta {
  name: Get a random joke
  type: http
  seq: 1
}

get {
  url: {{host}}/jokes
  body: none
  auth: none
}

<!-- highlight-start -->
assert {
  res.status: eq 200
  res.headers["content-type"]: isDefined
  res.headers["content-type"]: isString
  res.headers["content-type"]: contains "application/json"
  res.body.joke.length: gt 0
  res.body.joke: isString
}
<!-- highlight-end -->

settings {
  encodeUrl: true
}
```

![Bruno CLI is running assertions](./images/bruno_cli_assertions.webp)

## Conclusion {#conclusion}

Bruno vous offre un workflow à la Postman — collections, environnements, assertions — sans quitter votre repository : chaque requête est un fichier `.bru` que vous pouvez differ, relire et versionner comme le reste de votre code. La GUI sert à explorer et à construire une collection ; le CLI, emballé dans votre propre image Docker, transforme cette même collection en vérification reproductible.

Cette reproductibilité, c'est exactement ce qu'attend un pipeline de CI. Voir [GitLab - Running Docker-out-of-Docker in your CI](/blog/gitlab-docker-out-of-docker) pour lancer cette image CLI comme job, et [Validate your OpenAPI schema against the Belgif REST standards](/blog/belgif-api-linter) pour vérifier le contrat de l'API, pas seulement ses réponses.
