[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:
parent
ccbb2a8597
commit
4a90f9face
14 changed files with 184 additions and 27 deletions
|
|
@ -16,12 +16,19 @@
|
||||||
#
|
#
|
||||||
# ./script/test/run_unit_test.sh [fichiers...]
|
# ./script/test/run_unit_test.sh [fichiers...]
|
||||||
#
|
#
|
||||||
# NOMMER UN NOUVEAU FICHIER : sans argument, ce script ne prend que les
|
# TOUT test/test_*.py, et non une liste de préfixes. La liste disait prendre
|
||||||
# préfixes listés plus bas (test_qemu_, test_todo_, test_proxmox_…) — le reste
|
# test_qemu_, test_todo_, test_proxmox_… « le reste demandant une base de
|
||||||
# de test/ demande une base de données. Un fichier hors préfixe tombe donc dans
|
# données ». Mesuré : les 3670 tests du répertoire passent avec PostgreSQL
|
||||||
# le même silence qu'un fichier absent : douze tests écrits, jamais lancés, et
|
# injoignable, et la liste laissait 2400 d'entre eux hors de la suite —
|
||||||
# un total qui n'a pas bougé. Choisir le préfixe de sa famille, ou l'ajouter
|
# écrits, verts, jamais lancés. Un glob n'oublie personne ; une liste, si.
|
||||||
# ici.
|
#
|
||||||
|
# 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
|
set -uo pipefail
|
||||||
|
|
||||||
Red='\033[0;31m'
|
Red='\033[0;31m'
|
||||||
|
|
@ -47,11 +54,8 @@ fi
|
||||||
|
|
||||||
FILES=("$@")
|
FILES=("$@")
|
||||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||||
# Aucun argument : tout ce que le dépôt sait tester sans base de données.
|
# Aucun argument : tout le répertoire. Mesuré sans PostgreSQL.
|
||||||
mapfile -t FILES < <(ls test/test_qemu_*.py test/test_mobile_*.py \
|
mapfile -t FILES < <(ls test/test_*.py 2>/dev/null)
|
||||||
test/test_todo_*.py test/test_install_*.py test/test_run_*.py \
|
|
||||||
test/test_proxmox_*.py test/test_git_*.py \
|
|
||||||
2>/dev/null)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
fail=0
|
fail=0
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ class TestPgEnv(unittest.TestCase):
|
||||||
def test_literal_false_means_unset(self):
|
def test_literal_false_means_unset(self):
|
||||||
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
|
# Odoo écrit « False » dans config.conf pour dire « pas de valeur ».
|
||||||
# L'exporter tel quel ferait chercher un hôte nommé « False ».
|
# 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(
|
path = self._write(
|
||||||
"[options]\ndb_host = False\ndb_port = False\n"
|
"[options]\ndb_host = False\ndb_port = False\n"
|
||||||
"db_password = False\ndb_user = erplibre\n"
|
"db_password = False\ndb_user = erplibre\n"
|
||||||
|
|
@ -306,10 +314,6 @@ class TestRunPsqlGuards(unittest.TestCase):
|
||||||
self.assertIn("Invalid database name", str(caught.exception))
|
self.assertIn("Invalid database name", str(caught.exception))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCanonical(unittest.TestCase):
|
class TestCanonical(unittest.TestCase):
|
||||||
"""Ce qui décide s'il y a un écart. Le bruit se joue ici."""
|
"""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):
|
def test_manifest_is_the_fallback_when_the_dump_says_nothing(self):
|
||||||
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
|
self.assertEqual(L.backup_version({}, {"version": "16.0"}), "16.0")
|
||||||
self.assertIsNone(L.backup_version({}, {}))
|
self.assertIsNone(L.backup_version({}, {}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -266,10 +266,6 @@ class TestRender(unittest.TestCase):
|
||||||
self.assertNotEqual(english, french)
|
self.assertNotEqual(english, french)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCowTwinDiff(unittest.TestCase):
|
class TestCowTwinDiff(unittest.TestCase):
|
||||||
"""Comparer une copie de site web à la vue de module qu'elle masque.
|
"""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.assertEqual(n, 0)
|
||||||
self.assertNotIn("differs", row)
|
self.assertNotIn("differs", row)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -364,10 +364,6 @@ class TestTheRefusalToWrite(unittest.TestCase):
|
||||||
self.assertEqual(appels, [])
|
self.assertEqual(appels, [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase):
|
class TestTheSqlNeverTravelsThroughArgv(unittest.TestCase):
|
||||||
"""La panne signalée : « OSError: [Errno 7] Argument list too long ».
|
"""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")]
|
champs = [self._champ("a"), self._champ("b")]
|
||||||
_, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs)
|
_, bornes = self._sonder("8.0:13.0\x1f1.0:2.0", champs)
|
||||||
self.assertEqual(len(bornes["m"]), 2)
|
self.assertEqual(len(bornes["m"]), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase):
|
||||||
):
|
):
|
||||||
result = main()
|
result = main()
|
||||||
self.assertEqual(result, -1)
|
self.assertEqual(result, -1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -197,3 +197,7 @@ class TestArgsTypeParam(unittest.TestCase):
|
||||||
|
|
||||||
def test_selection_has_selection(self):
|
def test_selection_has_selection(self):
|
||||||
self.assertIn("selection", ARGS_TYPE_PARAM["Selection"])
|
self.assertIn("selection", ARGS_TYPE_PARAM["Selection"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -144,3 +144,7 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase):
|
||||||
s2 = {a["name"] for a in r2}
|
s2 = {a["name"] for a in r2}
|
||||||
self.assertEqual(s1.difference(s2), {"mod1"})
|
self.assertEqual(s1.difference(s2), {"mod1"})
|
||||||
self.assertEqual(len(s2.difference(s1)), 0)
|
self.assertEqual(len(s2.difference(s1)), 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -119,3 +119,7 @@ class TestEditDockerProd(unittest.TestCase):
|
||||||
for line in from_lines:
|
for line in from_lines:
|
||||||
self.assertIn("base:v3", line)
|
self.assertIn("base:v3", line)
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -138,3 +138,7 @@ class TestGetModifiedFiles(unittest.TestCase):
|
||||||
mock_run.side_effect = subprocess.CalledProcessError(1, "git")
|
mock_run.side_effect = subprocess.CalledProcessError(1, "git")
|
||||||
files = get_modified_files()
|
files = get_modified_files()
|
||||||
self.assertIsNone(files)
|
self.assertIsNone(files)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -92,3 +92,7 @@ class TestIsCompatible(unittest.TestCase):
|
||||||
|
|
||||||
def test_not_equal_false(self):
|
def test_not_equal_false(self):
|
||||||
self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0")))
|
self.assertFalse(iscompatible("foo!=1.0.0", Version("1.0.0")))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -434,3 +434,7 @@ class TestConnect(unittest.TestCase):
|
||||||
with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")):
|
with patch("imaplib.IMAP4_SSL", side_effect=OSError("injoignable")):
|
||||||
with self.assertRaises(ImapError):
|
with self.assertRaises(ImapError):
|
||||||
connect(self._account("ssl"), "hunter2")
|
connect(self._account("ssl"), "hunter2")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -250,10 +250,6 @@ class TestWhatAMigrationLeftBehind(unittest.TestCase):
|
||||||
self.assertTrue(residue.judge(plein)[0])
|
self.assertTrue(residue.judge(plein)[0])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTheScreenAnswersKeys(unittest.TestCase):
|
class TestTheScreenAnswersKeys(unittest.TestCase):
|
||||||
"""Presser les touches, et pas seulement lire les fonctions pures.
|
"""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"',
|
'env.user.has_group(\n "product.group_product_pricelist"',
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
113
test/test_suite_reaches_every_file.py
Normal file
113
test/test_suite_reaches_every_file.py
Normal 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()
|
||||||
|
|
@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase):
|
||||||
update = Update()
|
update = Update()
|
||||||
update.execute_log = ["entry1", "entry2"]
|
update.execute_log = ["entry1", "entry2"]
|
||||||
update.print_log()
|
update.print_log()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue