From 4a90f9face5627f2b39b3755bcc67eaa25ed2a72 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 07:46:14 -0400 Subject: [PATCH] [FIX] tests : balayer test/test_*.py et refuser un fichier muet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- script/test/run_unit_test.sh | 26 +++--- test/test_analyse_lib.py | 16 +++- test/test_analyse_view_custom.py | 8 +- test/test_anonymize.py | 8 +- test/test_check_addons_exist.py | 4 + test/test_code_generator_tools.py | 4 + test/test_database_tools.py | 4 + test/test_docker_update_version.py | 4 + test/test_format_file_to_commit.py | 4 + test/test_iscompatible.py | 4 + test/test_mail_imap_transport.py | 4 + test/test_monitoring.py | 8 +- test/test_suite_reaches_every_file.py | 113 ++++++++++++++++++++++++++ test/test_version.py | 4 + 14 files changed, 184 insertions(+), 27 deletions(-) create mode 100644 test/test_suite_reaches_every_file.py diff --git a/script/test/run_unit_test.sh b/script/test/run_unit_test.sh index 9352e64..57772ab 100755 --- a/script/test/run_unit_test.sh +++ b/script/test/run_unit_test.sh @@ -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 diff --git a/test/test_analyse_lib.py b/test/test_analyse_lib.py index c41894f..6517ec3 100644 --- a/test/test_analyse_lib.py +++ b/test/test_analyse_lib.py @@ -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() diff --git a/test/test_analyse_view_custom.py b/test/test_analyse_view_custom.py index a85a275..274f978 100644 --- a/test/test_analyse_view_custom.py +++ b/test/test_analyse_view_custom.py @@ -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() diff --git a/test/test_anonymize.py b/test/test_anonymize.py index 7c15ac2..28a3210 100644 --- a/test/test_anonymize.py +++ b/test/test_anonymize.py @@ -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() diff --git a/test/test_check_addons_exist.py b/test/test_check_addons_exist.py index d0711f1..374d31d 100644 --- a/test/test_check_addons_exist.py +++ b/test/test_check_addons_exist.py @@ -177,3 +177,7 @@ class TestMainBadConfig(unittest.TestCase): ): result = main() self.assertEqual(result, -1) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_code_generator_tools.py b/test/test_code_generator_tools.py index 4549d0d..7adff06 100644 --- a/test/test_code_generator_tools.py +++ b/test/test_code_generator_tools.py @@ -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() diff --git a/test/test_database_tools.py b/test/test_database_tools.py index f85675c..9797e01 100644 --- a/test/test_database_tools.py +++ b/test/test_database_tools.py @@ -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() diff --git a/test/test_docker_update_version.py b/test/test_docker_update_version.py index 2df796b..fe47931 100644 --- a/test/test_docker_update_version.py +++ b/test/test_docker_update_version.py @@ -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() diff --git a/test/test_format_file_to_commit.py b/test/test_format_file_to_commit.py index fae5593..db5f51c 100644 --- a/test/test_format_file_to_commit.py +++ b/test/test_format_file_to_commit.py @@ -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() diff --git a/test/test_iscompatible.py b/test/test_iscompatible.py index cf605cb..6061287 100644 --- a/test/test_iscompatible.py +++ b/test/test_iscompatible.py @@ -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() diff --git a/test/test_mail_imap_transport.py b/test/test_mail_imap_transport.py index 603c5ba..1633a53 100644 --- a/test/test_mail_imap_transport.py +++ b/test/test_mail_imap_transport.py @@ -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() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index 3f0a880..fbba336 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -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() diff --git a/test/test_suite_reaches_every_file.py b/test/test_suite_reaches_every_file.py new file mode 100644 index 0000000..a1d6d56 --- /dev/null +++ b/test/test_suite_reaches_every_file.py @@ -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() diff --git a/test/test_version.py b/test/test_version.py index 2d48072..dffbb19 100644 --- a/test/test_version.py +++ b/test/test_version.py @@ -241,3 +241,7 @@ class TestUpdatePrintLog(unittest.TestCase): update = Update() update.execute_log = ["entry1", "entry2"] update.print_log() + + +if __name__ == "__main__": + unittest.main()