Comment j'ai testé FLOZz Daily Mix avec Nextcloud Music (Docker), Pytest, Nox et GitHub Actions

Je vous avais parlé il y a quelque temps de FLOZz Daily Mix, mon projet de générateur de playlist. Quand je me suis lancé dans cette aventure, je suis allé directement à l'essentiel : je voulais arriver le plus rapidement possible à un truc fonctionnel, avec les fonctionnalités minimales nécessaires pour générer des mixes. En entreprise on parlerait de MVP (Minimum Viable Product) mais comme il ne s'agit que d'un projet perso sans prétention, on parlera plutôt de SDQM (Script Dégueu Qui Marche). Dit autrement : j'ai pris quelques raccourcis pour arriver à un résultat 😛️.

Il y avait deux bonnes raisons à ça : la première c'était que j'étais impatient de retrouver une fonctionnalité « Daily Mix » à la Spotify sur mon cloud, et la seconde c'était pour augmenter mes chances d'arriver à un résultat, avant que je ne perde ma motivation à travailler sur le projet... J'ai en effet un très long historique de projets, certainement trop ambitieux ou pas assez intéressants au final, qui ne sont jamais arrivés à maturité et qui pourrissent au fond d'un dossier... 😅️

Quoi qu'il en soit, j'ai développé tout ça rapidement, en m'autorisant à ce que le code ne soit pas super nickel au début. Une fois le résultat obtenu, j'ai fait un peu de ménage (a.k.a. j'ai amélioré un peu le code, planqué quelques trucs sous le tapis et rajouté un peu de doc) et j'ai présenté le projet sur les zinternets pour voir ce que les gens en pensaient, et si ça intéressait du monde en dehors de moi-même.

Scène du Roi Lion dans laquelle Rafiki présente Simba à l'assemblée

J'ai eu la chance d'avoir quelques retours, des gens intéressés, d'autres qui l'ont testé... Et il n'a pas fallu attendre bien longtemps pour que ça ne fonctionne pas chez quelqu'un. Rien de bien surprenant, c'était même plutôt attendu, le logiciel étant encore en plein développement et on est encore loin de la v1.0. 😁️

Jusqu'à présent, j'avais tout implémenté assez rapidement en me basant uniquement sur ce que je pouvais tester sur l'API Subsonic que j'avais sous la main, celle de Nextcloud Music, et sur ma propre collection de musique, qui est toute bien étiquetée avec le plus grand soin (merci à MusicBrainz et à leur logiciel, Picard ; il faudra que je vous en reparle de tout ça d'ailleurs ✨️).

Quoi qu'il en soit, maintenant que le socle minimal est là et que je commence à vouloir rajouter des fonctionnalités (comme la gestion des genres), le besoin d'augmenter la fiabilité de la base de code se fait sentir. J'ai besoin également d'avoir confiance dans le fait que les ajouts que je fais au code ne cassent pas l'existant et fonctionneront correctement chez tout le monde. C'est pourquoi j'ai commencé à introduire des tests unitaires sur le projet. [ayé on en vient enfin au sujet de l'article... 😅️]

Je ne vais bien sûr pas écrire d'un coup des tests pour l'ensemble du logiciel et je ne vise pas un coverage de 100%, mais il y a des points précis qui méritent qu'on s'y attarde. Le plus important d'entre eux, c'est le client Subsonic.

Une grosse partie du projet consiste en effet à extraire les données d'un cloud musical en passant par des APIs Subsonic. C'est par là qu'entrent les données, et c'est à cet endroit précis que tout peut foirer : variations dans les réponses des différentes implémentations des APIs, métadonnées manquantes dans une musique, diverses méthodes d'authentification suivant les serveurs, etc.

Il me faut donc pouvoir vérifier rapidement que tout fonctionne et continue de fonctionner au fil des évolutions à la fois de mon logiciel mais aussi au fil des évolutions des divers serveurs de musique.

Je ne vais pas faire durer le « suspense » plus longtemps, de toute façon vous avez lu le titre de l'article : aujourd'hui je vais vous faire un petit retour d'expérience sur la manière dont j'ai implémenté mes tests. On va voir comment monter une instance de Nextcloud dans Docker, avec Nextcloud Music d'installé et des données de test. On va voir également comment écrire quelques tests avec Pytest et comment lancer tout ça à l'aide de Nox. Rien de bien compliqué au final, mais ça me servira de mémo. 😛️

Construction du container Docker

Pour pouvoir tester le client Subsonic de FLOZz Daily Mix, j'ai besoin d'avoir une instance Nextcloud avec Nextcloud Music (et plus tard peut être d'autres implémentations d'APIs Subsonic pour plus de robustesses) avec un jeu de donnée de test. La solution la plus simple pour ça, c'est de construire une image Docker avec tout ce qu'il faut dedans afin de pouvoir la lancer au moment où on en a besoin pour faire tourner les tests.

Note

PRÉREQUIS : Si vous souhaitez reproduire ce que je fais dans cette partie, vous aurez besoin d'avoir Docker d'installé sur votre machine. Sous Debian / Ubuntu vous pouvez l'installer avec la commande suivante :

sudo apt install docker.io

Pour commencer, je vais créer un dossier "tests/" qui contiendra à terme les tests unitaires (on y reviendra un peu plus tard), et dans ce dossier je vais créer un sous-dossier "nextcloud/" dans lequel on va mettre tout ce qu'il faut pour construire notre image Docker. Voici un rapide coup d'œil de ce à quoi ça ressemble :

📂️ FZZDM-PROJECT
├─ 📂️ tests/
│  ├─ 📂️ nextcloud/
│  │  ├─ 📂️ musics/
│  │  │  ├─ 🎵️ track01.opus
│  │  │  ├─ 🎵️ track02.opus
│  │  │  └─ 🎵️ ...
│  │  ├─ 📄️ Dockerfile
│  │  └─ 📄️ setup-nextcloud-music.sh

Dans notre dossier "nextcloud/" on retrouve un dossier "musics/" qui contient nos données de test. Les musiques qui s'y trouvent sont en réalité des fichiers contenant 1 seconde de silence, mais avec des métadonnées réalistes qui seront utiles à nos tests par la suite.

Ensuite on a un "Dockerfile" avec le contenu suivant :

FROM nextcloud:30-apache

ENV NEXTCLOUD_ADMIN_USER admin
ENV NEXTCLOUD_ADMIN_PASSWORD password
ENV SQLITE_DATABASE nextcloud

RUN apt-get update -y && apt-get install -y sqlite3
COPY ./setup-nextcloud-music.sh /docker-entrypoint-hooks.d/post-installation/
COPY ./musics /var/sample-musics

On part de l'image "nextcloud" en version 30 (la dernière dispo au moment où j'écris ces lignes). On y rajoute quelques variables d'environnement pour donner le nom et le mot de passe de l'utilisateur administrateur de l'instance... Comme je suis original ça sera "admin"/"password"... Et on indique également qu'on va utiliser une base de données SQLite qui s'appellera "nextcloud" (pas besoin de s'embêter avec du MariaDB ou du PostgreSQL ici, ça fera très bien l'affaire).

Ensuite on installe les outils CLI de SQLite dont on va avoir besoin par la suite, puis on copie notre script d'installation pour Nextcloud. L'image Nextcloud étant bien foutue, elle fournit des dossiers dans lesquels on peut placer des scripts qui seront exécutés à des moments précis. Dans notre cas, on va utiliser le dossier "post-installation/", dont les scripts seront exécutés une fois l'installation de base de Nextcloud effectuée (base de données initialisée et utilisateur admin créé).

Enfin on copie nos musiques de test dans un dossier quelconque de l'image pour pouvoir les récupérer par la suite. On ne peut malheureusement pas les placer directement au bon endroit car cela ferait échouer la création de l'utilisateur lors de l'installation initiale de Nextcloud... 😅️

Et pour terminer ce petit tour des fichiers, voyons à présent ce qu'on a dans "setup-nextcloud-music.sh", le script qui va nous permettre de finaliser l'installation de notre instance Nextcloud :

#!/bin/bash

# Install Nextcloud Music app
php /var/www/html/occ app:install music

# Add a test user for Nextcloud Music Ampache & Subsonic APIs (admin:password)
sqlite3 "/var/www/html/data/nextcloud.db" "                                \
    INSERT INTO oc_music_ampache_users (                                   \
        user_id,                                                           \
        description,                                                       \
        hash                                                               \
    )                                                                      \
    VALUES (                                                               \
        'admin',                                                           \
        'APIs Test (admin:password)',                                      \
        '5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8' \
    );"

# Copy musics to the Nextcloud files of the "admin" user and fix permissions
cp -r "/var/sample-musics" "/var/www/html/data/admin/files/musics"
chown -R www-data:www-data "/var/www/html/data/admin/files/musics"

# Trigger file scan (allow Nextcloud to find the new files)
php /var/www/html/occ files:scan --verbose --path "/admin/files/musics"

# Trigger music scan (allow Nextcloud Music to index new musics)
php /var/www/html/occ music:scan --verbose admin

Je pense qu'avec les commentaires c'est assez clair mais on peut noter qu'on utilise les outils CLI de SQLite pour injecter un utilisateur pour l'API Subsonic de Nextcloud Music directement dans la DB (on ne va pas s'amuser à passer par l'interface Web pour ça 😅️). On peut également remarquer qu'on recopie les musiques dans le dossier contenant les fichiers Nextcloud de l'utilisateur "admin". Et on termine avec quelques commandes Nextcloud pour forcer l'indexation des fichiers. Cette dernière étape est nécessaire car on a placé les fichiers là sans passer par les APIs de Nextcloud ; il n'a donc pas été informé de leur existence.

Voilà, avec tout ça on devrait être bons ; on va pouvoir vérifier que tout fonctionne bien. 😁️

On va donc commencer par construire notre image Docker, que je vais nommer "fzzdm-nextcloud". Pour ce faire, on va utiliser la commande suivante, à lancer depuis la racine du projet :

docker build --tag "fzzdm-nextcloud" "./tests/nextcloud/"

Une fois l'image construite, on peut la lancer avec cette autre commande :

docker run --rm --detach --publish 8090:80 --name "fzzdm-test-nextcloud" "fzzdm-nextcloud"

Si tout s'est bien passé, vous devriez pouvoir accéder à l'instance Nextcloud depuis votre navigateur Web favori à l'adresse suivante :

Vous devriez alors arriver sur l'écran de connexion Nextcloud :

Une fois connecté avec notre utilisateur "admin" (et son mot de passe super sécurisé "password"... 😅️) on peut voir que notre instance Nextcloud est toute bien configurée, qu'on a bien Nextcloud Music d'installé et qu'il a bien indexé nos musiques de test :

Comme tout est nickel, on peut à présent couper le container Docker ; on le relancera au moment de faire tourner nos tests :

docker stop "fzzdm-test-nextcloud"

Pour aller plus loin, je vous mets quelques liens utiles :

Tester avec Pytest

Maintenant qu'on a notre Nextcloud, passons à l'écriture des tests unitaires eux-mêmes. Je vais utiliser pour cela le framework Pytest, par ce que c'est celui que je préfère tout simplement. 😄️

Voici un aperçu des fichiers présents dans mon projet qui vont avoir un intérêt pour nos tests :

📂️ FZZDM-PROJECT
│
├─ 📂️ flozz_daily_mix/
│  ├─ 📄️ subsonic.py
│  └─ 📄️ ...
│
├─ 📂️ tests/
│  ├─ 📂️ nextcloud/
│  └─ 📄️ test_subsonic.py    <- Nos tests Pytest
│
├─ 📄️ noxfile.py
├─ 📄️ pyproject.toml
└─ 📄️ ...

Étant donné que je veux tester mon client Subsonic, dont le code se trouve dans le fichier "flozz_daily_mix/subsonic.py", je vais créer le fichier correspondant "test_subsonic.py" dans mon dossier "tests/".

Voici un extrait du contenu de ce fichier :

import pytest

from flozz_daily_mix.subsonic import SubsonicClient

class TestSubsonic:

    @pytest.fixture
    def subsonic(self):
        subsonic = SubsonicClient("http://localhost:8090/", "admin", "password")
        return subsonic

    def test_getAlbumList(self, subsonic):
        albums = subsonic.getAlbumList()
        for album in albums:
            assert "id" in album
            assert "parent" in album
            assert "artist" in album
            assert "coverArt" in album
            # ...

La première chose à savoir avec Pytest, c'est que tout ce qui sert à tester doit commencer par "test" (nom de fichier, nom de classe, nom de fonction ou de méthode). C'est pourquoi le fichier se nomme "test_subsonic.py", et que la classe permettant de tester mon client Subsonic se nomme "TestSubsonic".

Dans l'extrait ci-dessus je vous ai mis deux méthodes :

  • subsonic() qui est surplombée par le décorateur "@pytest.fixture". Cette méthode sert à nous retourner une instance de notre client Subsonic que l'on utilisera dans les tests. L'objet retourné par cette méthode sera automatiquement transmis aux méthodes test_XXX() qui contiennent un argument du même nom, comme c'est le cas de la méthode juste en dessous.
  • test_getAlbumList() : contient le code pour tester la méthode getAlbumList() de notre client. Ici rien de bien compliqué, le client Subsonic nous est fournit en paramètre grâce à la fixture juste au-dessus, et on va pouvoir vérifier que la méthode nous retourne bien le résultat attendu (une liste d'albums qui contiennent un certain nombre de propriétés).

Si on veut tester tout ça, on peut créer un virtualenv dans lequel on va installer pytest et notre projet :

# Création du virtualenv
python3 -m venv __env__

# Installation de Pytest
__env__/bin/pip install pytest

# Installation de notre projet en mode éditable
# NOTE : le mode éditable permet de ne pas avoir à réinstaller le projet
#        après chaque modification du code.
__env__/bin/pip -e .

Et on peut ensuite lancer les tests avec la commande suivante :

__env__/bin/pytest -v tests

Il faudra bien sûr que le container Docker de Nextcloud soit démarré pour que les tests puissent fonctionner !

Pour des raisons de praticité j'ai aussi rajouté des markers sur les tests liés à l'API Subsonic pour pouvoir les skipper dans le cas où je voudrais lancer uniquement les tests pour lesquels Docker n'est pas nécessaire.

Pour ce faire il suffit de rajouter un petit décorateur sur la classe (ou sur les méthodes) que l'on veut marquer :

@pytest.mark.subsonic
class TestSubsonic:
    # ...

Il faut également déclarer ce marker dans le fichier "pyproject.toml" sinon Pytest va se plaindre. Si vous avez déjà ce fichier dans votre projet, ajoutez simplement la config à la suite, sinon créez-le :

[tool.pytest.ini_options]
markers = [
    "subsonic: marks tests requiring a Subsonic API",
]

Maintenant on peut soit lancer UNIQUEMENT les tests qui sont marqués avec le marker "subsonic" :

__env__/bin/pytest -v -m "subsonic" tests/

Soit lancer tous les tests SAUF ceux marqués "subsonic" :

__env__/bin/pytest -v -m "not subsonic" tests/

Voilà dans les grandes lignes comment j'ai ajouté les tests de l'API. Pour aller plus loin et pour voir une version plus complète des tests, je vous mets quelques liens :

Lancer les tests avec Nox

Plutôt que de lancer nos tests à la main, ce qui implique de créer un virtualenv, de tout installer dedans et de taper une commande Pytest un peu à rallonge, on va utiliser Nox pour chapeauter tout ça. Si vous ne connaissez pas, Nox c'est un genre de Makefile mais pour Python. C'est assez pratique et je l'utilise sur tous mes projets !

Pour commencer, on va installer Nox. Vous avez deux choix : soit l'installer depuis les dépôts Debian / Ubuntu (si vous utilisez ces systèmes) à l'aide de la commande suivante :

sudo apt install python3-nox

Soit l'installer dans un virtualenv avec pip :

# Création du virtualenv
python3 -m venv __env__

# Installation de Pytest
__env__/bin/pip install nox

Suivant votre méthode d'installation, vous pourrez le lancer soit à l'aide de la commande "nox" soit avec son chemin dans le virtualenv : "__env__/bin/nox".

Une fois Nox installé, on va pouvoir créer le fichier "noxfile.py" a la racine du projet, avec le contenu suivant :

import nox

@nox.session(reuse_venv=True)
def test(session):
    session.install("pytest")
    session.install("-e", ".")
    session.run( "pytest", "tests/")

Dans la terminologie de Nox, une « tâche » s'appelle une « session ». Pour créer une session, il suffit de déclarer une fonction qui prendra l'objet "session" en paramètre, et de lui ajouter le décorateur "@nox.session()".

Ici j'ai rajouté en paramètre du décorateur un petit "reuse_venv=True". Nox va en effet créer un virtualenv lorsqu'on lance une session ; ce paramètre lui permet de réutiliser le même d'un lancement sur l'autre plutôt que de tout réinstaller à chaque fois.

Ensuite, le code de la fonction est assez simple :

  • On appelle "session.install()" pour installer des trucs. Ça prend les mêmes paramètres que ceux qu'on aurait donnés à la commande "pip".
  • On appelle "@nox.run()" pour lancer des commandes.

Note

NOTE : Si jamais vous cherchez où Nox cache ses virtualenv, ils se trouvent dans le dossier ".nox" situé dans le même dossier que celui dans lequel se trouve le "noxfile.py". Je vous recommande d'ailleurs d'ajouter ce dossier à votre ".gitignore"... 🙂️

Une fois la session créée, on peut la lancer à l'aide de la commande suivante :

nox --session test   # Version longue
nox -s test          # Version courte

Ici l'option "--session" ou sa forme raccourcie "-s" indique qu'on veut lancer une session. Il suffit ensuite de lui donner le nom de la session, qui est tout simplement le nom de la fonction.

Bon maintenant on peut lancer nos tests, mais on doit toujours lancer/arrêter notre container Docker à la main... Si on automatisait ça ?

Pour ce faire, on va rajouter les deux sessions suivantes dans le fichier :

@nox.session()
def start_nextcloud_docker(session):
    session.run(
        "docker", "build", "--tag", "fzzdm-nextcloud", "./tests/nextcloud/",
        external=True,
    )
    session.run(
        "docker", "run", "--rm", "--detach", "--publish", "8090:80",
        "--name", "fzzdm-test-nextcloud", "fzzdm-nextcloud",
        external=True,
    )


@nox.session()
def stop_nextcloud_docker(session):
    session.run(
        "docker", "stop", "fzzdm-test-nextcloud",
        external=True,
    )

Rien de bien compliqué ici : on a juste repris les commandes Dockers qu'on avait vues précédemment. Le seul détail à noter, c'est le paramètre "external=True" que l'on passe à la fonction "session.run()". Il indique à Nox qu'on appelle une commande qui est extérieure au virtualenv.

Maintenant on devrait pouvoir lancer nos tests avec la série de commandes suivantes :

nox -s start_nextcloud_docker
nox -s test
nox -s stop_nextcloud_docker

Mais si on fait ça... PATATRAS, les tests ne passent pas ! 😖️

Le problème est en fait assez simple. Il faut un certain temps pour que le container Nextcloud soit « up » (que tout soit initialisé et démarré). Il faut donc attendre qu'il ait fini de démarrer avant de lancer les tests.

Pour ce faire je vais rajouter une petite fonction Python qui va essayer de se connecter en boucle au Nextcloud, jusqu'à ce qu'il réponde enfin. Pour ce faire on va avoir besoin du module "urllib" de la bibliothèque standard de Python :

import time
import urllib.request

def _wait_for_http_backend(url, max_retry=120, verbose=True):
    """Wait for an HTTP backend getting ready.

    :param str url: The URL to test the backend readyness.
    :param int max_retry: The max retry time (in seconds).
    :param bool verbose: If True, print a message at retry (every seconds).
    """
    while max_retry:
        max_retry -= 1
        if verbose:
            print("Waiting for HTTP backend '%s' to be up..." % url)
        try:
            urllib.request.urlopen(url)
        except urllib.request.URLError as error:
            if "Connection refused" in error.reason:
                time.sleep(1)
            else:
                raise error  # Not the expected error...
        except ConnectionResetError:
            time.sleep(1)
        else:
            print("HTTP backend '%s' is up!" % url)
            break
    if not max_retry:
        raise Exception("HTTP backend '%s' never got up!" % url)

Je ne vous détaille pas tout le fonctionnement de la fonction, mais en gros elle va essayer de se connecter à l'adresse qu'on lui donne toutes les secondes, jusqu'à ce que ça réponde. Et afin de ne pas rester bloqué indéfiniment si jamais un problème empêchait le container de se lancer, on rajoute une petite limite : au bout de 2 minutes (120 tentatives), on abandonne.

Il nous reste plus qu'à appeler cette fonction dans la session qui lance le container Docker :

@nox.session()
def start_nextcloud_docker(session):
    session.run(...)
    session.run(...)
    _wait_for_http_backend("http://localhost:8090/")

On peut à présent relancer nos commandes et tout devrait bien se passer ! 😁️

Comme pour les autres parties de cet article, voici une petite sélection de liens pour en apprendre davantage :

Faire tourner tout ça dans GitHub Actions

La dernière étape qu'il nous reste, c'est de faire tourner nos tests de manière automatisée dans GitHub Actions afin d'être rapidement au courant quand on casse un truc. Pour ce faire il nous suffit de rajouter un fichier ".github/workflows/python-ci.yml" avec le contenu suivant :

name: "Lint and Tests"
on: [push, pull_request]

jobs:

  test:

    runs-on: ubuntu-latest

    steps:

      - name: "Pull the repository"
        uses: actions/checkout@v4

      - name: "Set up Python"
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: "Install Nox"
        run: |
          pip3 install setuptools
          pip3 install nox

      - name: "Test with pytest"
        run: |
          python3 -m nox --session start_nextcloud_docker
          python3 -m nox --session test
          python3 -m nox --session stop_nextcloud_docker

Je ne vais pas rentrer dans le détail car il n'y a pas de surprises ici : on récupère le code, on installe Python et Nox, puis on lance notre container et nos tests avec Nox comme on l'a vu précédemment.

Vous pourrez retrouver une version un poil plus complète de cet workflow sur GitHub :

Et voilà !

J'ai déjà rajouté pas mal de tests, surtout en ce qui concerne la partie client Subsonic et base de données du projet. J'en rajouterais davantage au fur et à mesure des futurs développements et des inévitables corrections de bug. Il est en effet important de rajouter un test face à chaque bug corrigé afin de s'assurer que celui-ci ne réapparaisse pas.

En tout cas avec tout ça j'ai une bonne base pour les tests et pour améliorer la fiabilité du logiciel. Je peux donc poursuivre sereinement mon travail vers la v1.0. 🙂️

J'espère que cet article à mi-chemin entre un retour d'expérience et de la technique aura pu vous intéresser. J'essaye de revenir dans pas trop longtemps avec un nouvel article sur un tout autre sujet ! 😄️