[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...]
|
||||
#
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase):
|
|||
):
|
||||
result = main()
|
||||
self.assertEqual(result, -1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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.execute_log = ["entry1", "entry2"]
|
||||
update.print_log()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue