[FIX] tests : balayer test/test_*.py et refuser un fichier muet

Le lanceur ne prenait que sept préfixes de noms, « le reste demandant une
base de données » : les 3703 tests du répertoire passent avec PostgreSQL
injoignable, et la suite n'en exécutait que 1131. Il balaie test/test_*.py.

Deux formes rendent un fichier muet sans erreur : unittest.main() posé en
plein milieu, qui sort avant que la seconde moitié soit définie (quatre
fichiers, 87 tests), et l'absence de bloc __main__, qui compte zéro (huit
fichiers, 174 tests). Une garde refuse ces deux formes et le retour d'une
liste de préfixes.

--- EN ---

The runner took seven filename prefixes only, "the rest needing a
database": all 3703 tests in the directory pass with PostgreSQL
unreachable, and the suite ran 1131 of them. It now globs test/test_*.py.

Two shapes make a file silent without error: unittest.main() placed
mid-file, which exits before the second half is defined (four files, 87
tests), and no __main__ block at all, which counts zero (eight files, 174
tests). A guard now refuses both shapes and the return of a prefix list.

Assisted-by: Claude Opus 5
(cherry picked from commit db71b333cc08ae0fce4ddaaf037357648b10236b)
This commit is contained in:
Mathieu Benoit 2026-08-26 07:46:14 -04:00
parent ccbb2a8597
commit 4a90f9face
14 changed files with 184 additions and 27 deletions

View file

@ -16,12 +16,19 @@
#
# ./script/test/run_unit_test.sh [fichiers...]
#
# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les
# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste
# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans
# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et
# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter
# ici.
# TOUT test/test_*.py, et non une liste de préfixes. La liste disait prendre
# test_qemu_, test_todo_, test_proxmox_… « le reste demandant une base de
# données ». Mesuré : les 3670 tests du répertoire passent avec PostgreSQL
# injoignable, et la liste laissait 2400 d'entre eux hors de la suite —
# écrits, verts, jamais lancés. Un glob n'oublie personne ; une liste, si.
#
# Un fichier n'est vu que s'il finit par le bloc habituel :
#
# if __name__ == "__main__":
# unittest.main()
#
# EN DERNIER, sinon tout ce qui suit est défini après l'appel et ne tourne
# jamais. C'est arrivé quatre fois ici, pour 87 tests.
set -uo pipefail
Red='\033[0;31m'
@ -47,11 +54,8 @@ fi
FILES=("$@")
if [[ ${#FILES[@]} -eq 0 ]]; then
# Aucun argument : tout ce que le dépôt sait tester sans base de données.
mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \
test/test_todo_*.py test/test_install_*.py test/test_run_*.py \
test/test_proxmox_*.py test/test_git_*.py \
2>/dev/null)
# Aucun argument : tout le répertoire. Mesuré sans PostgreSQL.
mapfile -t FILES < <(ls test/test_*.py 2>/dev/null)
fi
fail=0

View file

@ -132,6 +132,14 @@ class TestPgEnv(unittest.TestCase):
def test_literal_false_means_unset(self):
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
# L'exporter tel quel ferait chercher un hôte nommé « False ».
#
# L'environnement est VIDÉ de ses PG* : `pg_env` part de
# `os.environ`, et un PGHOST exporté par le shell de qui lance les
# tests les ferait échouer sans que rien du code n'ait changé.
for cle in ("PGHOST", "PGPORT", "PGPASSWORD"):
if cle in os.environ:
ancien = os.environ.pop(cle)
self.addCleanup(os.environ.__setitem__, cle, ancien)
path = self._write(
"[options]\ndb_host = False\ndb_port = False\n"
"db_password = False\ndb_user = erplibre\n"
@ -306,10 +314,6 @@ class TestRunPsqlGuards(unittest.TestCase):
self.assertIn("Invalid database name", str(caught.exception))
if __name__ == "__main__":
unittest.main()
class TestCanonical(unittest.TestCase):
"""Ce qui décide s'il y a un écart. Le bruit se joue ici."""
@ -520,3 +524,7 @@ class TestBackupWithoutManifest(unittest.TestCase):
def test_manifest_is_the_fallback_when_the_dump_says_nothing(self):
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
self.assertIsNone(L.backup_version({}, {}))
if __name__ == "__main__":
unittest.main()

View file

@ -266,10 +266,6 @@ class TestRender(unittest.TestCase):
self.assertNotEqual(english, french)
if __name__ == "__main__":
unittest.main()
class TestCowTwinDiff(unittest.TestCase):
"""Comparer une copie de site web à la vue de module qu'elle masque.
@ -358,3 +354,7 @@ class TestCowTwinDiff(unittest.TestCase):
)
self.assertEqual(n, 0)
self.assertNotIn("differs", row)
if __name__ == "__main__":
unittest.main()

View file

@ -364,10 +364,6 @@ class TestTheRefusalToWrite(unittest.TestCase):
self.assertEqual(appels, [])
if __name__ == "__main__":
unittest.main()
class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase):
"""La panne signalée : « OSError: [Errno 7] Argument list too long ».
@ -895,3 +891,7 @@ class TestTheProbeDistrustsWhatItReads(unittest.TestCase):
champs = [self._champ("a"), self._champ("b")]
_, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs)
self.assertEqual(len(bornes["m"]), 2)
if __name__ == "__main__":
unittest.main()

View file

@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase):
):
result = main()
self.assertEqual(result, -1)
if __name__ == "__main__":
unittest.main()

View file

@ -197,3 +197,7 @@ class TestArgsTypeParam(unittest.TestCase):
def test_selection_has_selection(self):
self.assertIn("selection", ARGS_TYPE_PARAM["Selection"])
if __name__ == "__main__":
unittest.main()

View file

@ -144,3 +144,7 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase):
s2 = {a["name"] for a in r2}
self.assertEqual(s1.difference(s2), {"mod1"})
self.assertEqual(len(s2.difference(s1)), 0)
if __name__ == "__main__":
unittest.main()

View file

@ -119,3 +119,7 @@ class TestEditDockerProd(unittest.TestCase):
for line in from_lines:
self.assertIn("base:v3", line)
os.unlink(path)
if __name__ == "__main__":
unittest.main()

View file

@ -138,3 +138,7 @@ class TestGetModifiedFiles(unittest.TestCase):
mock_run.side_effect = subprocess.CalledProcessError(1, "git")
files = get_modified_files()
self.assertIsNone(files)
if __name__ == "__main__":
unittest.main()

View file

@ -92,3 +92,7 @@ class TestIsCompatible(unittest.TestCase):
def test_not_equal_false(self):
self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0")))
if __name__ == "__main__":
unittest.main()

View file

@ -434,3 +434,7 @@ class TestConnect(unittest.TestCase):
with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")):
with self.assertRaises(ImapError):
connect(self._account("ssl"), "hunter2")
if __name__ == "__main__":
unittest.main()

View file

@ -250,10 +250,6 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase):
self.assertTrue(residue.judge(plein)[0])
if __name__ == "__main__":
unittest.main()
class TestTheScreenAnswersKeys(unittest.TestCase):
"""Presser les touches, et pas seulement lire les fonctions pures.
@ -436,3 +432,7 @@ class TestTheRepairAsksItsOwnDetector(unittest.TestCase):
'env.user.has_group(\n "product.group_product_pricelist"',
source,
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un test qui ne tourne pas est pire qu'un test absent.
Absent, on le sait. Vert sans avoir tourné, il rassure. Le dépôt a
rencontré les deux formes de ce silence le même jour :
· `unittest.main()` posé au MILIEU d'un fichier. Python exécute de haut
en bas : l'appel part, découvre ce qui est défini jusque-là, et sort.
Tout ce qui suit n'existe pas encore. Quatre fichiers, 87 tests.
· Aucun bloc `__main__` du tout. Le fichier ne fait rien quand on le
lance, le lanceur compte zéro test, et le total ne bouge pas. Huit
fichiers, 174 tests.
À quoi s'ajoutait une liste de préfixes dans le lanceur, qui laissait 2400
tests hors de la suite. Trois façons différentes d'obtenir le même
résultat : du vert qui ne prouve rien.
Ce fichier est la garde. Il ne lit pas ce que les tests vérifient — il
vérifie qu'ils PEUVENT être vérifiés.
"""
import ast
import glob
import io
import os
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LANCEUR = os.path.join(REPO, "script", "test", "run_unit_test.sh")
def fichiers_de_test():
return sorted(glob.glob(os.path.join(REPO, "test", "test_*.py")))
def bloc_main(arbre):
"""L'indice du `if __name__ == "__main__":` du module, ou None."""
for rang, noeud in enumerate(arbre.body):
if (
isinstance(noeud, ast.If)
and isinstance(noeud.test, ast.Compare)
and getattr(noeud.test.left, "id", "") == "__name__"
):
return rang
return None
class TestEveryFileCanRun(unittest.TestCase):
def setUp(self):
self.fichiers = fichiers_de_test()
self.assertTrue(self.fichiers, "aucun fichier de test trouvé")
def test_every_file_has_a_way_to_run_itself(self):
# Le lanceur exécute chaque fichier comme un programme. Sans ce
# bloc, il ne fait rien et le lanceur compte zéro — sans erreur.
for chemin in self.fichiers:
arbre = ast.parse(io.open(chemin, encoding="utf-8").read())
self.assertIsNotNone(
bloc_main(arbre),
f"{os.path.basename(chemin)} : pas de bloc __main__",
)
def test_the_run_call_is_the_last_thing_in_the_file(self):
# Au milieu, il coupe le fichier en deux et la seconde moitié
# n'est jamais définie au moment où les tests sont découverts.
for chemin in self.fichiers:
arbre = ast.parse(io.open(chemin, encoding="utf-8").read())
rang = bloc_main(arbre)
if rang is None:
continue
suivants = arbre.body[rang + 1 :]
self.assertEqual(
[],
[type(n).__name__ for n in suivants],
f"{os.path.basename(chemin)} : du code après unittest.main()",
)
def test_every_file_actually_declares_a_test(self):
for chemin in self.fichiers:
arbre = ast.parse(io.open(chemin, encoding="utf-8").read())
combien = sum(
1
for noeud in ast.walk(arbre)
if isinstance(noeud, (ast.FunctionDef, ast.AsyncFunctionDef))
and noeud.name.startswith("test_")
)
self.assertTrue(
combien, f"{os.path.basename(chemin)} : aucun test dedans"
)
class TestTheRunnerLeavesNobodyOut(unittest.TestCase):
"""Une liste de préfixes oublie ; un glob, non."""
def setUp(self):
self.source = io.open(LANCEUR, encoding="utf-8").read()
def test_it_takes_the_whole_directory(self):
self.assertIn("ls test/test_*.py", self.source)
def test_it_does_not_pick_families_by_name(self):
# Le défaut d'origine : un fichier hors préfixe tombait dans le
# même silence qu'un fichier absent.
for prefixe in ("test/test_qemu_*.py", "test/test_todo_*.py"):
self.assertNotIn(prefixe, self.source, prefixe)
if __name__ == "__main__":
unittest.main()

View file

@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase):
update = Update()
update.execute_log = ["entry1", "entry2"]
update.print_log()
if __name__ == "__main__":
unittest.main()