Skip to content

Commit 3f7f6f8

Browse files
committed
[#28] Added test_manager and test_runner
Adds two classes which manage context for running tests in containers. The test_runner is an abstract base class that requires a single ethod to be implemented by "concrete" test_runners which executes the test. In this commit, the only test_runner implemented is the `irods_python_suite`, which executes the tests from the iRODS python test suite (i.e. /var/lib/irods/scripts/run_tests.py). The test_manager manages a number of test_runners so that tests can be distributed to the test_runners and then executed in parallel on the available pool of zones.
1 parent 9e34b6c commit 3f7f6f8

File tree

5 files changed

+277
-118
lines changed

5 files changed

+277
-118
lines changed

irods_testing_environment/context.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,17 @@ def service_account_irods_env():
119119
return os.path.join(irods_home(), '.irods', 'irods_environment.json')
120120

121121

122+
def run_tests_script():
123+
"""Return the path to the script which runs the python tests."""
124+
import os
125+
return os.path.join(irods_home(), 'scripts', 'run_tests.py')
126+
127+
128+
def python():
129+
"""Return the appropriate python interpreter."""
130+
return 'python3'
131+
132+
122133
def sanitize(repo_or_tag):
123134
"""Sanitize the input from special characters rejected by docker-compose.
124135
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# grown-up modules
2+
import logging
3+
4+
# local modules
5+
from . import test_runner
6+
7+
class test_manager:
8+
"""A class that manages a list of tests and `test_runners` for executing tests."""
9+
10+
def __init__(self, containers, tests, test_type='irods_python_suite'):
11+
"""Constructor for `test_manager`.
12+
13+
Arguments:
14+
containers -- list of containers which will be used to construct `test_runner`s
15+
tests -- list of tests which will run on the `test_runners`
16+
test_type -- a string representing the name of the class implementing the test_runner
17+
"""
18+
tr_name = '_'.join(['test_runner', test_type])
19+
tr = eval('.'.join(['test_runner', tr_name]))
20+
logging.info('[{}]'.format(tr))
21+
self.test_runners = [tr(c) for c in containers]
22+
23+
logging.info('[{}]'.format(tests))
24+
logging.info('[{}]'.format(str(self)))
25+
logging.info('[{}]'.format(self.test_runners))
26+
27+
for i, t in enumerate(tests):
28+
index = i % len(self.test_runners)
29+
30+
logging.info('index [{}], test [{}]'.format(index, t))
31+
self.test_runners[index].add_test(t)
32+
33+
logging.info('[{}]'.format(str(self)))
34+
35+
36+
def __str__(self):
37+
"""Return a string representation of the list of `test_runners`."""
38+
return str([str(tr) for tr in self.test_runners])
39+
40+
41+
def failed_tests(self):
42+
"""Return a list of failed tests across the managed `test_runners`."""
43+
return [t for tr in self.test_runners for t in tr.failed_tests()]
44+
45+
46+
def return_code(self):
47+
"""Return int representing the 'overall' return code from a test run.
48+
49+
Return 0 if all `test_runners` have a return code of 0. Otherwise, 1.
50+
"""
51+
logging.info('[{}]'.format([tr.rc for tr in self.test_runners]))
52+
return 0 if [tr.rc for tr in self.test_runners].count(0) == len(self.test_runners) else 1
53+
54+
55+
def result_string(self):
56+
"""Return string showing tests that passed and failed from each `test_runner.`"""
57+
r = '==== begin test run results ====\n'
58+
for tr in self.test_runners:
59+
r = r + tr.result_string()
60+
61+
if self.return_code() is not 0:
62+
r = r + 'List of failed tests:\n\t{}\n'.format(' '.join(self.failed_tests()))
63+
r = r + 'Return code:[{}]\n'.format(self.return_code())
64+
65+
else:
66+
r = r + 'All tests passed! :)\n'
67+
68+
r = r + '==== end of test run results ====\n'
69+
70+
return r
71+
72+
73+
def run(self, fail_fast=True, *args):
74+
"""Run managed `test_runners` in parallel.
75+
76+
Arguments:
77+
fail_fast -- if True, the first test to fail ends the run
78+
*args -- arguments to be passed along to the `test_runner`'s specific `run` method
79+
"""
80+
import concurrent.futures
81+
82+
with concurrent.futures.ThreadPoolExecutor() as executor:
83+
futures_to_test_runners = {
84+
executor.submit(tr.run, fail_fast, *args): tr for tr in self.test_runners
85+
}
86+
87+
for f in concurrent.futures.as_completed(futures_to_test_runners):
88+
tr = futures_to_test_runners[f]
89+
90+
try:
91+
f.result()
92+
93+
if tr.rc is 0 and len(tr.failed_tests()) is 0:
94+
logging.warning('tests completed successfully [{}]'.format(tr.name()))
95+
else:
96+
logging.warning('some tests failed [{}]'.format(tr.name()))
97+
98+
except Exception as e:
99+
logging.warning('[{}]: exception raised while running test'.format(tr.name()))
100+
logging.warning(e)
101+
102+
tr.rc = 1
103+
104+
if fail_fast: raise
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# grown-up modules
2+
import logging
3+
4+
# local modules
5+
from . import context
6+
from . import execute
7+
8+
class test_runner:
9+
"""A class that manages a list of tests and can execute them on a managed container."""
10+
11+
def __init__(self, executing_container, tests=None):
12+
"""Constructor for `test_runner`.
13+
14+
The list of tests is not required to be initialized here. `add_test` will append a
15+
test to the end of the test list.
16+
17+
Arguments:
18+
executing_container -- the container on which tests will be executed
19+
tests -- optional list of tests to use with this runner
20+
"""
21+
# TODO: each test runner is tied to a single container... might need to abstract this out later
22+
self.executor = executing_container
23+
self.tests = tests or list()
24+
self.passed = list()
25+
self.failed = list()
26+
self.rc = 0
27+
28+
29+
def __str__(self):
30+
"""Return a string representation of a map representing the data members."""
31+
return str({
32+
'executing_container': self.name(),
33+
'return_code': self.rc,
34+
'test_list': self.tests,
35+
'passed_tests': self.passed_tests(),
36+
'failed_tests': self.failed_tests()
37+
})
38+
39+
40+
def add_test(self, test):
41+
"""Append `test` to the test list and return self."""
42+
logging.info('before [{}]'.format(self))
43+
self.tests.append(test)
44+
logging.info('after [{}]'.format(self))
45+
return self
46+
47+
48+
def name(self):
49+
"""Return the name of the executing container."""
50+
return self.executor.name
51+
52+
53+
def test_list(self):
54+
"""Return the list of tests for which this runner is responsible."""
55+
return self.tests
56+
57+
58+
def passed_tests(self):
59+
"""Return the list of tests which have been executed and passed."""
60+
return self.passed
61+
62+
63+
def failed_tests(self):
64+
"""Return the list of tests which have been executed and failed."""
65+
return self.failed
66+
67+
68+
def skipped_tests(self):
69+
"""Return the list of tests which have not been executed."""
70+
executed_tests = self.passed_tests() + self.failed_tests()
71+
return list(filter(lambda t: t not in executed_tests, self.test_list()))
72+
73+
74+
def result_string(self):
75+
"""Return a string representing the results of running the test list."""
76+
r = '-----\nresults for [{}]\n'.format(self.name())
77+
78+
r = r + '\tpassed tests:\n'
79+
for t in self.passed_tests():
80+
r = r + '\t\t[{}]\n'.format(t)
81+
82+
r = r + '\tskipped tests:\n'
83+
for t in self.skipped_tests():
84+
r = r + '\t\t[{}]\n'.format(t)
85+
86+
r = r + '\tfailed tests:\n'
87+
for t in self.failed_tests():
88+
r = r + '\t\t[{}]\n'.format(t)
89+
90+
r = r + '\treturn code:[{}]\n-----\n'.format(self.rc)
91+
92+
return r
93+
94+
95+
def run(self, fail_fast=True, *args):
96+
"""Execute test list sequentially on executing container.
97+
98+
Arguments:
99+
fail_fast -- if True, the first test to fail ends the run
100+
*args -- any additional arguments that the test execution can take
101+
"""
102+
for t in self.tests:
103+
cmd, ec = self.execute_test(t, *args)
104+
105+
if ec is 0:
106+
self.passed_tests().append(t)
107+
logging.info('[{}]: cmd succeeded [{}]'.format(self.name(), cmd))
108+
109+
else:
110+
self.rc = ec
111+
self.failed_tests().append(t)
112+
logging.warning('[{}]: cmd failed [{}] [{}]'.format(self.name(), ec, cmd))
113+
114+
if fail_fast:
115+
raise RuntimeError('[{}]: command failed [{}]'.format(self.name(), cmd))
116+
117+
if self.rc is not 0:
118+
logging.error('[{}]: tests that failed [{}]'.format(self.name(), self.failed_tests()))
119+
120+
121+
def execute_test(self, test, *args):
122+
"""Execute `test` with return the command run and the return code."""
123+
raise NotImplementedError('test_runner is a base class and should not be used directly')
124+
125+
126+
class test_runner_irods_python_suite(test_runner):
127+
def __init__(self, executing_container, tests=None):
128+
super(test_runner_irods_python_suite, self).__init__(executing_container, tests)
129+
130+
131+
@staticmethod
132+
def run_tests_command():
133+
"""Return a list of strings used as a space-delimited invocation of the test runner."""
134+
return [context.python(), context.run_tests_script()]
135+
136+
137+
def execute_test(self, test, options=None):
138+
"""Execute `test` with `options` and return the command run and the return code."""
139+
cmd = self.run_tests_command() + ['--run_specific_test', test]
140+
if options: cmd.append(options)
141+
return cmd, execute.execute_command(self.executor,
142+
' '.join(cmd),
143+
user='irods',
144+
workdir=context.irods_home(),
145+
stream_output=True)

0 commit comments

Comments
 (0)