Les requêtes SQL se cassent en silence. Découvrez comment les tester, les versionner et les automatiser avec l’intégration continue pour garantir leur fiabilité dans le temps.

Tout le monde se concentre sur l’écriture de SQL qui « fonctionne », mais très peu vérifient s’il continuera à fonctionner demain. Une seule nouvelle ligne, une hypothèse modifiée ou un refactoring peut casser une requête silencieusement. Cet article présente un flux de travail complet, montrant comment traiter le SQL comme un logiciel : versionné, testé et automatisé. Nous partirons d’une véritable question d’entretien d’Amazon sur l’identification des clients ayant les dépenses quotidiennes les plus élevées, puis nous convertirons la requête en un composant testable, définirons les résultats attendus et automatiserons les tests avec l’intégration continue et le déploiement continu (CI/CD).

LA REQUÊTE SQL D’AMAZON

Un entretien chez Amazon demande souvent de trouver les clients avec la dépense quotidienne la plus élevée sur une période donnée, à partir de deux tables : customers et orders. Ce problème illustre parfaitement comment le SQL peut être traité comme un logiciel : la requête doit être correcte, stable et résister aux régressions. Voici la solution finale en PostgreSQL :

WITH customerdailytotals AS (
  SELECT
    o.cust_id,
    o.order_date,
    SUM(o.totalordercost) AS totaldailycost
  FROM orders o
  WHERE o.order_date BETWEEN '2019-02-01' AND '2019-05-01'
  GROUP BY o.custid, o.orderdate
),
rankeddailytotals AS (
  SELECT
    cust_id,
    order_date,
    totaldailycost,
    RANK() OVER (
      PARTITION BY order_date
      ORDER BY totaldailycost DESC
    ) AS rnk
  FROM customerdailytotals
)
SELECT
  c.first_name,
  rdt.order_date,
  rdt.totaldailycost AS max_cost
FROM rankeddailytotals rdt
JOIN customers c ON rdt.cust_id = c.id
WHERE rdt.rnk = 1
ORDER BY rdt.order_date;

POURQUOI TESTER SON SQL ?

Le SQL casse plus facilement qu’on ne le croit. Une valeur par défaut modifiée, une colonne renommée ou une nouvelle source de données peut introduire des erreurs silencieuses. Les tests vous protègent de ces problèmes. Trois étapes de test seront couvertes : convertir la logique en une fonction, définir la sortie attendue et écrire une suite de tests unitaires.

PRÉPARER LES TESTS : DES DONNÉES CONTRÔLÉES

Pour tester le code SQL, on commence par l’envelopper dans une fonction Python à l’aide d’un framework léger comme unittest. On définit d’abord la requête à tester :

query = """
        WITH customerdailytotals AS (
          SELECT
            o.cust_id,
            o.order_date,
            SUM(o.totalordercost) AS totaldailycost
          FROM orders o
          WHERE o.order_date BETWEEN '2019-02-01' AND '2019-05-01'
          GROUP BY o.custid, o.orderdate
        ),
        rankeddailytotals AS (
          SELECT
            cust_id,
            order_date,
            totaldailycost,
            RANK() OVER (
              PARTITION BY order_date
              ORDER BY totaldailycost DESC
            ) AS rnk
          FROM customerdailytotals
        )
        SELECT
          c.first_name,
          rdt.order_date,
          rdt.totaldailycost AS max_cost
        FROM rankeddailytotals rdt
        JOIN customers c ON rdt.cust_id = c.id
        WHERE rdt.rnk = 1
        ORDER BY rdt.order_date;
    """

Ensuite, on crée un échantillon de données contrôlé pour les tests :

test_customers = [
    (15, "Mia"),
    (7, "Jill"),
    (3, "Farida")
]

test_orders = [
    (1, 3, "2019-03-04", 100),
    (2, 3, "2019-03-01", 80),
    (4, 7, "2019-02-01", 25),
    (6, 15, "2019-02-01", 100)
]

Et on définit la sortie attendue, qui sert de référence de comparaison :

expected = [
    ("Mia", "2019-02-01", 100),
    ("Farida", "2019-03-01", 80),
    ("Farida", "2019-03-04", 100)
]

ÉCRIRE LE TEST UNITAIRE

Maintenant que la requête, les entrées et les sorties attendues sont définies, on peut écrire un test unitaire. Le framework unittest de Python garde les dépendances minimales tout en offrant structure et répétabilité. On commence par créer une base de données SQLite en mémoire :

conn = sqlite3.connect(":memory:")
cursor = conn.cursor()

On recrée uniquement les tables nécessaires à la requête, mais avec un schéma réaliste (même si seules quelques colonnes sont utilisées) pour éviter une fausse confiance liée à une simplification excessive :

CREATE TABLE customers (.)
CREATE TABLE orders (.)

On insère ensuite les données de test contrôlées :

cursor.executemany("INSERT INTO customers VALUES (?, ?, ?, ?, ?, ?)", test_customers)
cursor.executemany("INSERT INTO orders VALUES (?, ?, ?, ?, ?)", test_orders)
conn.commit()

Avant d’exécuter la requête, on peut charger et afficher les tables de test avec Pandas (étape optionnelle mais utile pour le débogage) :

customersdf = pd.readsql("SELECT id, firstname, lastname, city FROM customers", conn)
ordersdf = pd.readsql("SELECT * FROM orders", conn)

On lance la requête à tester :

result = pd.read_sql(query, conn)

La vérification se fait ligne par ligne : on compare d’abord la taille du résultat avec celle attendue :

all_correct = True

if len(result) .= len(expected):
    all_correct = False

Puis on itère sur chaque élément attendu pour vérifier les valeurs :

for i, (fname, lname, date, cost) in enumerate(expected):
    if i < len(result):
        actual = result.iloc[i]
        if not (
            actual["first_name"] == fname
            and actual["last_name"] == lname
            and actual["order_date"] == date
            and actual["max_cost"] == cost
        ):
            all_correct = False

Enfin, un message clair résume le résultat :

if all_correct and len(result) == len(expected):
    print("ALL TESTS PASSED")
else:
    print("SOME TESTS FAILED")

AUTOMATISATION AVEC CI/CD

Une suite de tests n’est utile que si elle s’exécute de manière cohérente à chaque modification du code. On utilise GitHub Actions pour définir un workflow d’intégration continue qui lance les tests SQL à chaque push ou pull request sur la branche principale.

On crée le fichier .github/workflows/test_sql.yml avec le contenu suivant :

name: Run SQL Tests

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

Cela permet de détecter les régressions SQL avant qu’elles ne soient fusionnées.

On définit ensuite un job test qui s’exécute sur un environnement Ubuntu propre :

jobs:
  test:
    runs-on: ubuntu-latest

Les étapes du job :

- name: Checkout repository
  uses: actions/checkout@v4

Cette étape télécharge le code du dépôt pour que le runner puisse accéder aux fichiers SQL et aux tests.

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

Elle installe Python 3.10 pour garantir un runtime cohérent.

- name: Install dependencies
  run: |
    python -m pip install --upgrade pip
    pip install -r requirements.txt

Cette commande installe toutes les bibliothèques Python nécessaires (comme Pandas) listées dans requirements.txt.

- name: Run unit tests
  run: python -m unittest discover

Enfin, on lance les tests unitaires. Le workflow s’exécute automatiquement à chaque commit ou pull request, et les résultats sont visibles dans l’onglet Actions du dépôt GitHub.

LA QUALITÉ DES DONNÉES, MAILLON FAIBLE DES PIPELINES

Les tests unitaires confirment que la logique produit toujours la sortie attendue, et la CI garantit leur exécution automatique. Mais dans les environnements de données réels, les données elles-mêmes peuvent causer des échecs : lignes arrivant en retard, dates mal formées, clés manquantes, doublons inattendus. C’est là qu’intervient l’automatisation de la qualité des données. Sans contrôles automatisés, ces problèmes faussent silencieusement les résultats car SQL ne lève pas d’exceptions évidentes.

LES RÈGLES DE QUALITÉ EN SQL

Chaque requête SQL repose sur des hypothèses implicites. Pour notre requête sur les meilleurs dépensiers quotidiens, plusieurs hypothèses critiques doivent être vérifiées.

Les prénoms doivent être uniques : la jointure se fait par ID, mais l’affichage utilise le prénom. Si les prénoms ne sont plus uniques, le résultat devient ambigu :

SELECT first_name, COUNT(*)
FROM customers
GROUP BY first_name
HAVING COUNT(*) > 1;

Les coûts des commandes ne doivent pas être négatifs : des valeurs négatives indiquent souvent des erreurs d’ingestion :

SELECT *
FROM orders
WHERE totalordercost < 0;

Les dates de commande doivent être valides et dans une plage attendue : des dates nulles ou aberrantes trahissent des problèmes de synchronisation :

SELECT *
FROM orders
WHERE order_date IS NULL
   OR orderdate > CURRENTDATE;

Chaque commande doit référencer un client valide : une commande orpheline serait silencieusement exclue des jointures :

SELECT o.*
FROM orders o
LEFT JOIN customers c ON c.id = o.cust_id
WHERE c.id IS NULL;

AUTOMATISER LES CONTRÔLES QUALITÉ

Plutôt que d’exécuter ces vérifications manuellement, on les encapsule dans une fonction Python qui lève une exception si une règle est violée :

import pandas as pd

def rundataquality_checks(conn):
    checks = {
        "Duplicate first names": """
            SELECT first_name
            FROM customers
            GROUP BY first_name
            HAVING COUNT(*) > 1;
        """,
        "Negative order costs": """
            SELECT *
            FROM orders
            WHERE totalordercost < 0;
        """,
        "Invalid order dates": """
            SELECT *
            FROM orders
            WHERE order_date IS NULL
               OR order_date > CURRENT_DATE;
        """,
        "Orders without customers": """
            SELECT o.*
            FROM orders o
            LEFT JOIN customers c ON c.id = o.cust_id
            WHERE c.id IS NULL;
        """
    }

    for rule_name, query in checks.items():
        result = pd.read_sql(query, conn)
        if not result.empty:
            raise ValueError(f"Data quality check failed: {rule_name}")

    print("All data quality checks passed.")

Ces contrôles qualité se comportent comme des tests unitaires : succès ou échec. Comme ils sont en Python, ils sont automatiquement exécutés par le workflow GitHub Actions existant, via la commande python -m unittest discover.

CONCLUSION : VERS UN SQL FIABLE

La plupart des gens s’arrêtent une fois que la requête produit une réponse correcte. Mais les environnements de données réels récompensent ceux qui rendent leurs requêtes stables, testables et versionnées. Combiner les tests unitaires, l’intégration continue et les contrôles de qualité des données garantit que la requête continuera de fournir des résultats fiables, même lorsque les données évoluent.

La correction c’est bien, la fiabilité c’est essentiel.
Sources :
  • KDnuggets

L'indépendance de CLODCO est votre garantie.

Pour que l'actualité de l'IA reste sans filtre et sans concession, votre soutien est indispensable. Votre contribution est le seul moteur de notre liberté éditoriale.

Soutenir CLODCO