Skip to content

Commit a4f2fe7

Browse files
committed
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.
1 parent 6571653 commit a4f2fe7

1 file changed

Lines changed: 111 additions & 105 deletions

File tree

arc/scheduler_test.py

Lines changed: 111 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from unittest.mock import MagicMock, patch
1010
import os
1111
import shutil
12+
import tempfile
1213
from types import SimpleNamespace
1314

1415

@@ -75,105 +76,125 @@ class TestScheduler(unittest.TestCase):
7576
"""
7677
Contains unit tests for the Scheduler class
7778
"""
78-
@classmethod
79-
def setUpClass(cls):
79+
def make_project_directory(self, name):
8080
"""
81-
A method that is run before all unit tests in this class.
81+
Create a unique project directory for the running test and schedule its removal.
82+
83+
Args:
84+
name (str): A descriptive name, used as the directory name prefix.
85+
86+
Returns:
87+
str: The path of the created directory.
8288
"""
83-
cls.maxDiff = None
84-
cls.ess_settings = {'gaussian': ['server1'], 'molpro': ['server2', 'server1'], 'qchem': ['server1']}
85-
cls.project_directory = os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage3')
89+
projects_path = os.path.join(ARC_PATH, 'Projects')
90+
os.makedirs(projects_path, exist_ok=True)
91+
project_directory = tempfile.mkdtemp(prefix=f'{name}_', dir=projects_path)
92+
self.addCleanup(shutil.rmtree, project_directory, ignore_errors=True)
93+
return project_directory
94+
95+
def setUp(self):
96+
"""
97+
A method that is run before each unit test in this class.
98+
99+
The fixtures below are rebuilt per test: many of these tests mutate the schedulers,
100+
the species and the jobs they are handed, so a fixture shared across tests (or across
101+
xdist workers, each of which runs its own class-level setup) would make the outcome of
102+
one test depend on which other tests ran before it.
103+
"""
104+
self.maxDiff = None
105+
self.ess_settings = {'gaussian': ['server1'], 'molpro': ['server2', 'server1'], 'qchem': ['server1']}
106+
self.project_directory = self.make_project_directory('arc_project_for_testing_delete_after_usage3')
86107
xyz1 = str_to_xyz("""C -0.57422867 -0.01669771 0.01229213
87108
N 0.82084044 0.08279104 -0.37769346
88109
H -1.05737005 -0.84067772 -0.52007494
89110
H -1.10211468 0.90879867 -0.23383011
90111
H -0.66133128 -0.19490562 1.08785111
91112
H 0.88047852 0.26966160 -1.37780789
92113
H 1.27889520 -0.81548721 -0.22940984""")
93-
cls.spc1 = ARCSpecies(label='methylamine', smiles='CN', xyz=xyz1)
94-
cls.spc2 = ARCSpecies(label='C2H6', smiles='CC')
114+
self.spc1 = ARCSpecies(label='methylamine', smiles='CN', xyz=xyz1)
115+
self.spc2 = ARCSpecies(label='C2H6', smiles='CC')
95116
xyz3 = """C 1.11424367 -0.01231165 -0.11493630
96117
C -0.07257945 -0.17830906 -0.16010022
97118
O -1.38500471 -0.36381519 -0.20928090
98119
H 2.16904830 0.12689206 -0.07152274
99120
H -1.82570782 0.42754384 -0.56130718"""
100-
cls.spc3 = ARCSpecies(label='CtripCO', smiles='C#CO', xyz=xyz3)
101-
cls.job1 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=cls.ess_settings,
102-
species=[cls.spc1], xyz=xyz1, job_type='conf_opt',
103-
conformer=0, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}),
104-
project_directory=cls.project_directory, job_num=101)
105-
cls.job2 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=cls.ess_settings,
106-
species=[cls.spc1], xyz=xyz1, job_type='conf_opt',
107-
conformer=1, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}),
108-
project_directory=cls.project_directory, job_num=102)
109-
cls.job3 = job_factory(job_adapter='qchem', project='project_test', ess_settings=cls.ess_settings,
110-
species=[cls.spc2], job_type='freq',
111-
level=Level(repr={'method': 'wb97x-d3', 'basis': '6-311+g(d,p)'}),
112-
project_directory=cls.project_directory, job_num=103)
113-
cls.job4 = job_factory(job_adapter='gaussian', project='project_test_4', ess_settings=cls.ess_settings,
114-
species=[cls.spc1], xyz=xyz1, job_type='scan', torsions=[[3, 1, 2, 6]], rotor_index=0,
115-
level=Level(repr={'method': 'b3lyp', 'basis': 'cbsb7'}),
116-
project_directory=cls.project_directory, job_num=104)
117-
cls.job_types1 = {'conf_opt': True,
118-
'conf_sp': False,
119-
'opt': True,
120-
'fine': False,
121-
'freq': True,
122-
'sp': True,
123-
'rotors': False,
124-
'orbitals': False,
125-
'lennard_jones': False,
126-
}
127-
cls.job_types2 = {'conf_opt': True,
128-
'conf_sp': False,
129-
'opt': True,
130-
'fine': False,
131-
'freq': True,
132-
'sp': True,
133-
'rotors': True,
134-
}
135-
cls.sched1 = Scheduler(project='project_test_1', ess_settings=cls.ess_settings,
136-
species_list=[cls.spc1, cls.spc2, cls.spc3],
137-
composite_method=None,
138-
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
139-
opt_level=Level(repr=default_levels_of_theory['opt']),
140-
freq_level=Level(repr=default_levels_of_theory['freq']),
141-
sp_level=Level(repr=default_levels_of_theory['sp']),
142-
scan_level=Level(repr=default_levels_of_theory['scan']),
143-
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
144-
project_directory=cls.project_directory,
145-
testing=True,
146-
job_types=cls.job_types1,
147-
orbitals_level=default_levels_of_theory['orbitals'],
148-
adaptive_levels=None,
149-
)
150-
cls.sched2 = Scheduler(project='project_test_2', ess_settings=cls.ess_settings,
151-
species_list=[cls.spc1, cls.spc2, cls.spc3],
152-
composite_method=None,
153-
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
154-
opt_level=Level(repr=default_levels_of_theory['opt']),
155-
freq_level=Level(repr=default_levels_of_theory['freq']),
156-
sp_level=Level(repr=default_levels_of_theory['sp']),
157-
scan_level=Level(repr=default_levels_of_theory['scan']),
158-
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
159-
project_directory=cls.project_directory,
160-
testing=True,
161-
job_types=cls.job_types1,
162-
orbitals_level=default_levels_of_theory['orbitals'],
163-
adaptive_levels=None,
164-
)
165-
cls.sched3 = Scheduler(project='project_test_4', ess_settings=cls.ess_settings,
166-
species_list=[cls.spc1],
167-
composite_method=Level(repr='CBS-QB3'),
168-
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
169-
opt_level=Level(repr=default_levels_of_theory['freq_for_composite']),
170-
freq_level=Level(repr=default_levels_of_theory['freq_for_composite']),
171-
scan_level=Level(repr=default_levels_of_theory['scan_for_composite']),
172-
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
173-
project_directory=cls.project_directory,
174-
testing=True,
175-
job_types=cls.job_types2,
176-
)
121+
self.spc3 = ARCSpecies(label='CtripCO', smiles='C#CO', xyz=xyz3)
122+
self.job1 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=self.ess_settings,
123+
species=[self.spc1], xyz=xyz1, job_type='conf_opt',
124+
conformer=0, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}),
125+
project_directory=self.project_directory, job_num=101)
126+
self.job2 = job_factory(job_adapter='gaussian', project='project_test', ess_settings=self.ess_settings,
127+
species=[self.spc1], xyz=xyz1, job_type='conf_opt',
128+
conformer=1, level=Level(repr={'method': 'b97-d3', 'basis': '6-311+g(d,p)'}),
129+
project_directory=self.project_directory, job_num=102)
130+
self.job3 = job_factory(job_adapter='qchem', project='project_test', ess_settings=self.ess_settings,
131+
species=[self.spc2], job_type='freq',
132+
level=Level(repr={'method': 'wb97x-d3', 'basis': '6-311+g(d,p)'}),
133+
project_directory=self.project_directory, job_num=103)
134+
self.job4 = job_factory(job_adapter='gaussian', project='project_test_4', ess_settings=self.ess_settings,
135+
species=[self.spc1], xyz=xyz1, job_type='scan', torsions=[[3, 1, 2, 6]], rotor_index=0,
136+
level=Level(repr={'method': 'b3lyp', 'basis': 'cbsb7'}),
137+
project_directory=self.project_directory, job_num=104)
138+
self.job_types1 = {'conf_opt': True,
139+
'conf_sp': False,
140+
'opt': True,
141+
'fine': False,
142+
'freq': True,
143+
'sp': True,
144+
'rotors': False,
145+
'orbitals': False,
146+
'lennard_jones': False,
147+
}
148+
self.job_types2 = {'conf_opt': True,
149+
'conf_sp': False,
150+
'opt': True,
151+
'fine': False,
152+
'freq': True,
153+
'sp': True,
154+
'rotors': True,
155+
}
156+
self.sched1 = Scheduler(project='project_test_1', ess_settings=self.ess_settings,
157+
species_list=[self.spc1, self.spc2, self.spc3],
158+
composite_method=None,
159+
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
160+
opt_level=Level(repr=default_levels_of_theory['opt']),
161+
freq_level=Level(repr=default_levels_of_theory['freq']),
162+
sp_level=Level(repr=default_levels_of_theory['sp']),
163+
scan_level=Level(repr=default_levels_of_theory['scan']),
164+
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
165+
project_directory=self.project_directory,
166+
testing=True,
167+
job_types=self.job_types1,
168+
orbitals_level=default_levels_of_theory['orbitals'],
169+
adaptive_levels=None,
170+
)
171+
self.sched2 = Scheduler(project='project_test_2', ess_settings=self.ess_settings,
172+
species_list=[self.spc1, self.spc2, self.spc3],
173+
composite_method=None,
174+
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
175+
opt_level=Level(repr=default_levels_of_theory['opt']),
176+
freq_level=Level(repr=default_levels_of_theory['freq']),
177+
sp_level=Level(repr=default_levels_of_theory['sp']),
178+
scan_level=Level(repr=default_levels_of_theory['scan']),
179+
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
180+
project_directory=self.project_directory,
181+
testing=True,
182+
job_types=self.job_types1,
183+
orbitals_level=default_levels_of_theory['orbitals'],
184+
adaptive_levels=None,
185+
)
186+
self.sched3 = Scheduler(project='project_test_4', ess_settings=self.ess_settings,
187+
species_list=[self.spc1],
188+
composite_method=Level(repr='CBS-QB3'),
189+
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
190+
opt_level=Level(repr=default_levels_of_theory['freq_for_composite']),
191+
freq_level=Level(repr=default_levels_of_theory['freq_for_composite']),
192+
scan_level=Level(repr=default_levels_of_theory['scan_for_composite']),
193+
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
194+
project_directory=self.project_directory,
195+
testing=True,
196+
job_types=self.job_types2,
197+
)
177198

178199
def test_conformers(self):
179200
"""Test the parse_conformer_energy() and determine_most_stable_conformer() methods"""
@@ -410,6 +431,7 @@ def test_determine_adaptive_level(self):
410431

411432
def test_initialize_output_dict(self):
412433
"""Test Scheduler.initialize_output_dict"""
434+
self.sched1.output['C2H6']['info'] = 'some text'
413435
self.assertTrue(self.sched1._does_output_dict_contain_info())
414436
self.sched1.output = dict()
415437
self.assertEqual(self.sched1.output, dict())
@@ -851,7 +873,7 @@ def test_check_rxn_e0_by_spc(self):
851873
'job_types': {'conf_opt': True, 'conf_sp': False, 'opt': True, 'freq': True, 'sp': True, 'rotors': True, 'irc': True, 'fine': True},
852874
},
853875
}
854-
project_directory = os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage6')
876+
project_directory = self.make_project_directory('arc_project_for_testing_delete_after_usage6')
855877
os.makedirs(os.path.join(project_directory, 'output', 'Species', 'nC3H7', 'geometry'), exist_ok=True)
856878
os.makedirs(os.path.join(project_directory, 'output', 'Species', 'iC3H7', 'geometry'), exist_ok=True)
857879
os.makedirs(os.path.join(project_directory, 'output', 'rxns', 'TS0', 'geometry'), exist_ok=True)
@@ -863,7 +885,7 @@ def test_check_rxn_e0_by_spc(self):
863885
dst=os.path.join(project_directory, 'output', 'rxns', 'TS0', 'geometry', 'freq.out'))
864886
sched = Scheduler(project='test_rxn_e0_check',
865887
ess_settings=self.ess_settings,
866-
project_directory=os.path.join(ARC_PATH, 'Projects', 'arc_project_for_testing_delete_after_usage6'),
888+
project_directory=project_directory,
867889
rxn_list=[rxn],
868890
species_list=rxn.r_species + rxn.p_species + [rxn.ts_species],
869891
kinetics_adapter='arkane',
@@ -880,20 +902,16 @@ def test_check_rxn_e0_by_spc(self):
880902
job_type='freq',
881903
level=Level(repr='B3LYP/6-31G(d,p)'),
882904
project='test_project',
883-
project_directory=os.path.join(ARC_PATH,
884-
'Projects',
885-
'arc_project_for_testing_delete_after_usage6'),
905+
project_directory=project_directory,
886906
)
887907
job_1.local_path_to_output_file = os.path.join(ARC_TESTING_PATH, 'freq', 'TS_nC3H7-iC3H7.out')
888908
check_ts(reaction=rxn, verbose=True, job=job_1, checks=['NMD'])
889909
self.assertEqual(rxn.ts_species.ts_checks, {'E0': None, 'e_elect': True, 'IRC': None, 'freq': True, 'NMD': True, 'warnings': ''})
890910

891911
def test_save_e_elect(self):
892912
"""Test the save_e_elect() method."""
893-
project_directory = os.path.join(ARC_PATH, 'Projects', 'save_e_elect')
913+
project_directory = self.make_project_directory('save_e_elect')
894914
e_elect_summary_path = os.path.join(project_directory, 'output', 'e_elect_summary.yml')
895-
if os.path.isfile(os.path.join(project_directory, 'output', 'e_elect_summary.yml')):
896-
os.remove(os.path.join(project_directory, 'output', 'e_elect_summary.yml'))
897915
sched = Scheduler(project='test_save_e_elect',
898916
ess_settings=self.ess_settings,
899917
project_directory=project_directory,
@@ -917,7 +935,6 @@ def test_save_e_elect(self):
917935
sp_path=os.path.join(ARC_TESTING_PATH, 'sp', 'mehylamine_CCSD(T).out'))
918936
content = read_yaml_file(e_elect_summary_path)
919937
self.assertEqual(content, {'formaldehyde': -300621.95378630824, 'mehylamine': -251360.00924747565})
920-
shutil.rmtree(project_directory, ignore_errors=True)
921938

922939
def test_species_has_geo_sp_freq(self):
923940
"""Test the species_has_geo() / species_has_sp() / species_has_freq() functions."""
@@ -2060,17 +2077,6 @@ def test_run_job_does_not_alias_level_args(self, mock_job_factory):
20602077
args['keyword']['dft_grid'] = 'defgrid2'
20612078
self.assertEqual(level.args, {'keyword': {'opt': 'opt=(verytight)'}, 'block': dict()})
20622079

2063-
@classmethod
2064-
def tearDownClass(cls):
2065-
"""
2066-
A function that is run ONCE after all unit tests in this class.
2067-
Delete all project directories created during these unit tests
2068-
"""
2069-
projects = ['arc_project_for_testing_delete_after_usage3', 'arc_project_for_testing_delete_after_usage6']
2070-
for project in projects:
2071-
project_directory = os.path.join(ARC_PATH, 'Projects', project)
2072-
shutil.rmtree(project_directory, ignore_errors=True)
2073-
20742080

20752081
class TestSpawnTsJobsAdmission(unittest.TestCase):
20762082
"""

0 commit comments

Comments
 (0)