From 320e54285a05a142415400212955152fd03536d1 Mon Sep 17 00:00:00 2001 From: Calvin Pieters Date: Sat, 22 Aug 2026 11:20:18 +0300 Subject: [PATCH] Isolate TestScheduler fixtures so tests cannot leak state into each other TestScheduler built its schedulers, species and jobs once in setUpClass and shared them across every test in the class, while eleven of those tests mutate what they are handed: they replace scheduler.output, inject species into species_dict and job_dict, flip job_types['rotors'], overwrite job_status and output paths, and append to unique_species_labels. All of them also shared a single fixed project directory that tearDownClass deleted. Under pytest-xdist the class is split across workers, each running its own class-level setup and teardown, so one worker's tearDownClass removed the project directory another worker was still writing into, and a test that had implicitly relied on an earlier test dirtying the shared scheduler saw a clean one instead. Roughly half of the runs failed, with a different set of tests each time. Build the fixtures per test in setUp instead, and give every test its own project directory removed via addCleanup, which also lets tearDownClass go. Constructing the fixtures costs about 60 ms, so the serial run of the file grows from ~5.0 s to ~6.7 s and the -n 4 run is unchanged. test_initialize_output_dict opened by asserting that the shared output dict already contained information, which only held because an earlier test had put it there; it failed when run on its own. It now writes that information itself before asserting. --- arc/scheduler_test.py | 216 ++++++++++++++++++++++-------------------- 1 file changed, 111 insertions(+), 105 deletions(-) diff --git a/arc/scheduler_test.py b/arc/scheduler_test.py index 62b03efc14..c78a248575 100644 --- a/arc/scheduler_test.py +++ b/arc/scheduler_test.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock, patch import os import shutil +import tempfile from types import SimpleNamespace @@ -75,14 +76,34 @@ class TestScheduler(unittest.TestCase): """ Contains unit tests for the Scheduler class """ - @classmethod - def setUpClass(cls): + def make_project_directory(self, name): """ - A method that is run before all unit tests in this class. + Create a unique project directory for the running test and schedule its removal. + + Args: + name (str): A descriptive name, used as the directory name prefix. + + Returns: + str: The path of the created directory. """ - cls.maxDiff = None - cls.ess_settings = {'gaussian': ['server1'], 'molpro': ['server2', 'server1'], 'qchem': ['server1']} - cls.project_directory = os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage3') + projects_path = os.path.join(ARC_PATH, 'Projects') + os.makedirs(projects_path, exist_ok=True) + project_directory = tempfile.mkdtemp(prefix=f'{name}_', dir=projects_path) + self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True) + return project_directory + + def setUp(self): + """ + A method that is run before each unit test in this class. + + The fixtures below are rebuilt per test: many of these tests mutate the schedulers, + the species and the jobs they are handed, so a fixture shared across tests (or across + xdist workers, each of which runs its own class-level setup) would make the outcome of + one test depend on which other tests ran before it. + """ + self.maxDiff = None + self.ess_settings = {'gaussian': ['server1'], 'molpro': ['server2', 'server1'], 'qchem': ['server1']} + self.project_directory = self.make_project_directory('arc_project_for_testing_delete_after_usage3') xyz1 = str_to_xyz("""C -0.57422867 -0.01669771 0.01229213 N 0.82084044 0.08279104 -0.37769346 H -1.05737005 -0.84067772 -0.52007494 @@ -90,90 +111,90 @@ def setUpClass(cls): H -0.66133128 -0.19490562 1.08785111 H 0.88047852 0.26966160 -1.37780789 H 1.27889520 -0.81548721 -0.22940984""") - cls.spc1 = ARCSpecies(label='methylamine', smiles='CN', xyz=xyz1) - cls.spc2 = ARCSpecies(label='C2H6', smiles='CC') + self.spc1 = ARCSpecies(label='methylamine', smiles='CN', xyz=xyz1) + self.spc2 = ARCSpecies(label='C2H6', smiles='CC') xyz3 = """C 1.11424367 -0.01231165 -0.11493630 C -0.07257945 -0.17830906 -0.16010022 O -1.38500471 -0.36381519 -0.20928090 H 2.16904830 0.12689206 -0.07152274 H -1.82570782 0.42754384 -0.56130718""" - cls.spc3 = ARCSpecies(label='CtripCO', smiles='C#CO', xyz=xyz3) - cls.job1 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=cls.ess_settings, - species=[cls.spc1], xyz=xyz1, job_type='conf_opt', - conformer=0, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}), - project_directory=cls.project_directory, job_num=101) - cls.job2 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=cls.ess_settings, - species=[cls.spc1], xyz=xyz1, job_type='conf_opt', - conformer=1, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}), - project_directory=cls.project_directory, job_num=102) - cls.job3 = job_factory(job_adapter='qchem', project='project_test', ess_settings=cls.ess_settings, - species=[cls.spc2], job_type='freq', - level=Level(repr={'method': 'wb97x-d3', 'basis': '6-311+g(d,p)'}), - project_directory=cls.project_directory, job_num=103) - cls.job4 = job_factory(job_adapter='gaussian', project='project_test_4', ess_settings=cls.ess_settings, - species=[cls.spc1], xyz=xyz1, job_type='scan', torsions=[[3, 1, 2, 6]], rotor_index=0, - level=Level(repr={'method': 'b3lyp', 'basis': 'cbsb7'}), - project_directory=cls.project_directory, job_num=104) - cls.job_types1 = {'conf_opt': True, - 'conf_sp': False, - 'opt': True, - 'fine': False, - 'freq': True, - 'sp': True, - 'rotors': False, - 'orbitals': False, - 'lennard_jones': False, - } - cls.job_types2 = {'conf_opt': True, - 'conf_sp': False, - 'opt': True, - 'fine': False, - 'freq': True, - 'sp': True, - 'rotors': True, - } - cls.sched1 = Scheduler(project='project_test_1', ess_settings=cls.ess_settings, - species_list=[cls.spc1, cls.spc2, cls.spc3], - composite_method=None, - conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), - opt_level=Level(repr=default_levels_of_theory['opt']), - freq_level=Level(repr=default_levels_of_theory['freq']), - sp_level=Level(repr=default_levels_of_theory['sp']), - scan_level=Level(repr=default_levels_of_theory['scan']), - ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), - project_directory=cls.project_directory, - testing=True, - job_types=cls.job_types1, - orbitals_level=default_levels_of_theory['orbitals'], - adaptive_levels=None, - ) - cls.sched2 = Scheduler(project='project_test_2', ess_settings=cls.ess_settings, - species_list=[cls.spc1, cls.spc2, cls.spc3], - composite_method=None, - conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), - opt_level=Level(repr=default_levels_of_theory['opt']), - freq_level=Level(repr=default_levels_of_theory['freq']), - sp_level=Level(repr=default_levels_of_theory['sp']), - scan_level=Level(repr=default_levels_of_theory['scan']), - ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), - project_directory=cls.project_directory, - testing=True, - job_types=cls.job_types1, - orbitals_level=default_levels_of_theory['orbitals'], - adaptive_levels=None, - ) - cls.sched3 = Scheduler(project='project_test_4', ess_settings=cls.ess_settings, - species_list=[cls.spc1], - composite_method=Level(repr='CBS-QB3'), - conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), - opt_level=Level(repr=default_levels_of_theory['freq_for_composite']), - freq_level=Level(repr=default_levels_of_theory['freq_for_composite']), - scan_level=Level(repr=default_levels_of_theory['scan_for_composite']), - ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), - project_directory=cls.project_directory, - testing=True, - job_types=cls.job_types2, - ) + self.spc3 = ARCSpecies(label='CtripCO', smiles='C#CO', xyz=xyz3) + self.job1 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=self.ess_settings, + species=[self.spc1], xyz=xyz1, job_type='conf_opt', + conformer=0, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}), + project_directory=self.project_directory, job_num=101) + self.job2 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=self.ess_settings, + species=[self.spc1], xyz=xyz1, job_type='conf_opt', + conformer=1, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}), + project_directory=self.project_directory, job_num=102) + self.job3 = job_factory(job_adapter='qchem', project='project_test', ess_settings=self.ess_settings, + species=[self.spc2], job_type='freq', + level=Level(repr={'method': 'wb97x-d3', 'basis': '6-311+g(d,p)'}), + project_directory=self.project_directory, job_num=103) + self.job4 = job_factory(job_adapter='gaussian', project='project_test_4', ess_settings=self.ess_settings, + species=[self.spc1], xyz=xyz1, job_type='scan', torsions=[[3, 1, 2, 6]], rotor_index=0, + level=Level(repr={'method': 'b3lyp', 'basis': 'cbsb7'}), + project_directory=self.project_directory, job_num=104) + self.job_types1 = {'conf_opt': True, + 'conf_sp': False, + 'opt': True, + 'fine': False, + 'freq': True, + 'sp': True, + 'rotors': False, + 'orbitals': False, + 'lennard_jones': False, + } + self.job_types2 = {'conf_opt': True, + 'conf_sp': False, + 'opt': True, + 'fine': False, + 'freq': True, + 'sp': True, + 'rotors': True, + } + self.sched1 = Scheduler(project='project_test_1', ess_settings=self.ess_settings, + species_list=[self.spc1, self.spc2, self.spc3], + composite_method=None, + conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), + opt_level=Level(repr=default_levels_of_theory['opt']), + freq_level=Level(repr=default_levels_of_theory['freq']), + sp_level=Level(repr=default_levels_of_theory['sp']), + scan_level=Level(repr=default_levels_of_theory['scan']), + ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), + project_directory=self.project_directory, + testing=True, + job_types=self.job_types1, + orbitals_level=default_levels_of_theory['orbitals'], + adaptive_levels=None, + ) + self.sched2 = Scheduler(project='project_test_2', ess_settings=self.ess_settings, + species_list=[self.spc1, self.spc2, self.spc3], + composite_method=None, + conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), + opt_level=Level(repr=default_levels_of_theory['opt']), + freq_level=Level(repr=default_levels_of_theory['freq']), + sp_level=Level(repr=default_levels_of_theory['sp']), + scan_level=Level(repr=default_levels_of_theory['scan']), + ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), + project_directory=self.project_directory, + testing=True, + job_types=self.job_types1, + orbitals_level=default_levels_of_theory['orbitals'], + adaptive_levels=None, + ) + self.sched3 = Scheduler(project='project_test_4', ess_settings=self.ess_settings, + species_list=[self.spc1], + composite_method=Level(repr='CBS-QB3'), + conformer_opt_level=Level(repr=default_levels_of_theory['conformer']), + opt_level=Level(repr=default_levels_of_theory['freq_for_composite']), + freq_level=Level(repr=default_levels_of_theory['freq_for_composite']), + scan_level=Level(repr=default_levels_of_theory['scan_for_composite']), + ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']), + project_directory=self.project_directory, + testing=True, + job_types=self.job_types2, + ) def test_conformers(self): """Test the parse_conformer_energy() and determine_most_stable_conformer() methods""" @@ -347,6 +368,7 @@ def test_determine_adaptive_level(self): def test_initialize_output_dict(self): """Test Scheduler.initialize_output_dict""" + self.sched1.output['C2H6']['info'] = 'some text' self.assertTrue(self.sched1._does_output_dict_contain_info()) self.sched1.output = dict() self.assertEqual(self.sched1.output, dict()) @@ -745,7 +767,7 @@ def test_check_rxn_e0_by_spc(self): 'job_types': {'conf_opt': True, 'conf_sp': False, 'opt': True, 'freq': True, 'sp': True, 'rotors': True, 'irc': True, 'fine': True}, }, } - project_directory = os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage6') + project_directory = self.make_project_directory('arc_project_for_testing_delete_after_usage6') os.makedirs(os.path.join(project_directory, 'output', 'Species', 'nC3H7', 'geometry'), exist_ok=True) os.makedirs(os.path.join(project_directory, 'output', 'Species', 'iC3H7', 'geometry'), exist_ok=True) os.makedirs(os.path.join(project_directory, 'output', 'rxns', 'TS0', 'geometry'), exist_ok=True) @@ -757,7 +779,7 @@ def test_check_rxn_e0_by_spc(self): dst=os.path.join(project_directory, 'output', 'rxns', 'TS0', 'geometry', 'freq.out')) sched = Scheduler(project='test_rxn_e0_check', ess_settings=self.ess_settings, - project_directory=os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage6'), + project_directory=project_directory, rxn_list=[rxn], species_list=rxn.r_species + rxn.p_species + [rxn.ts_species], kinetics_adapter='arkane', @@ -774,9 +796,7 @@ def test_check_rxn_e0_by_spc(self): job_type='freq', level=Level(repr='B3LYP/6-31G(d,p)'), project='test_project', - project_directory=os.path.join(ARC_PATH, - 'Projects', - 'arc_project_for_testing_delete_after_usage6'), + project_directory=project_directory, ) job_1.local_path_to_output_file = os.path.join(ARC_TESTING_PATH, 'freq', 'TS_nC3H7-iC3H7.out') check_ts(reaction=rxn, verbose=True, job=job_1, checks=['NMD']) @@ -784,10 +804,8 @@ def test_check_rxn_e0_by_spc(self): def test_save_e_elect(self): """Test the save_e_elect() method.""" - project_directory = os.path.join(ARC_PATH, 'Projects', 'save_e_elect') + project_directory = self.make_project_directory('save_e_elect') e_elect_summary_path = os.path.join(project_directory, 'output', 'e_elect_summary.yml') - if os.path.isfile(os.path.join(project_directory, 'output', 'e_elect_summary.yml')): - os.remove(os.path.join(project_directory, 'output', 'e_elect_summary.yml')) sched = Scheduler(project='test_save_e_elect', ess_settings=self.ess_settings, project_directory=project_directory, @@ -811,7 +829,6 @@ def test_save_e_elect(self): sp_path=os.path.join(ARC_TESTING_PATH, 'sp', 'mehylamine_CCSD(T).out')) content = read_yaml_file(e_elect_summary_path) self.assertEqual(content, {'formaldehyde': -300621.95378630824, 'mehylamine': -251360.00924747565}) - shutil.rmtree(project_directory, ignore_errors=True) def test_species_has_geo_sp_freq(self): """Test the species_has_geo() / species_has_sp() / species_has_freq() functions.""" @@ -1954,17 +1971,6 @@ def test_run_job_does_not_alias_level_args(self, mock_job_factory): args['keyword']['dft_grid'] = 'defgrid2' self.assertEqual(level.args, {'keyword': {'opt': 'opt=(verytight)'}, 'block': dict()}) - @classmethod - def tearDownClass(cls): - """ - A function that is run ONCE after all unit tests in this class. - Delete all project directories created during these unit tests - """ - projects = ['arc_project_for_testing_delete_after_usage3', 'arc_project_for_testing_delete_after_usage6'] - for project in projects: - project_directory = os.path.join(ARC_PATH, 'Projects', project) - shutil.rmtree(project_directory, ignore_errors=True) - class TestSpawnTsJobsAdmission(unittest.TestCase): """