Skip to content

Commit 6d7741d

Browse files
committed
Provide infrastructure for assertion of related values as a single case.
In out desired use of the test suite is a requirement to allow writing a test that is the same assertion agianst a series of related values. As an example, imagine an operation on chunks and wishing to assert multiple sizes. This is obviously something to be extremely careful in promoting given it is in a fair amount of conflict with the notion of separate test cases, which experience says more "generally" leads to better outcomes. However, used judiciously this can be useful, and this is far better supported by a mechanism designed to support it: namely, that _all_ failures are reported up front rather than the first failing assertion cancelling the test block and the ending up in a fix-and-rerun cycle. Add a facility and associated assertion to the test support library that allows executing an operation and its assertion against a series of values, buffers any exceptions that occurs and enforce that either a no exception or all exception outcome is asserted by end of test. Include a mechanism to the support library so arbitrary objects can do checks and use this in multi-value assertions to ensure a result is validated by its containing case.
1 parent 489c12f commit 6d7741d

File tree

5 files changed

+210
-1
lines changed

5 files changed

+210
-1
lines changed

tests/_support/__init__.py

Whitespace-only changes.

tests/_support/assertover.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# --- BEGIN_HEADER ---
4+
#
5+
# support - helper functions for unit testing
6+
# Copyright (C) 2003-2024 The MiG Project by the Science HPC Center at UCPH
7+
#
8+
# This file is part of MiG.
9+
#
10+
# MiG is free software: you can redistribute it and/or modify
11+
# it under the terms of the GNU General Public License as published by
12+
# the Free Software Foundation; either version 2 of the License, or
13+
# (at your option) any later version.
14+
#
15+
# MiG is distributed in the hope that it will be useful,
16+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
# GNU General Public License for more details.
19+
#
20+
# You should have received a copy of the GNU General Public License
21+
# along with this program; if not, write to the Free Software
22+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23+
#
24+
# -- END_HEADER ---
25+
#
26+
27+
"""Infrastruture to support assertion over a range of values.
28+
"""
29+
30+
class NoBlockError(AssertionError):
31+
pass
32+
33+
class NoCasesError(AssertionError):
34+
pass
35+
36+
class AssertOver:
37+
def __init__(self, values=None, testcase=None):
38+
self._attempts = None
39+
self._consulted = False
40+
self._ended = False
41+
self._started = False
42+
self._testcase = testcase
43+
self._values = iter(values)
44+
45+
def __call__(self, block):
46+
self._attempts = []
47+
48+
try:
49+
while True:
50+
block_value = next(self._values)
51+
attempt_info = self._execute_block(block, block_value)
52+
self.record_attempt(attempt_info)
53+
except StopIteration:
54+
pass
55+
56+
self._ended = True
57+
58+
def __enter__(self):
59+
return self
60+
61+
def __exit__(self, exc_type, exc_value, traceback):
62+
if self._attempts is None:
63+
raise NoBlockError()
64+
65+
if len(self._attempts) == 0:
66+
raise NoCasesError()
67+
68+
if not any(self._attempts):
69+
return True
70+
71+
value_lines = ["- <%r> : %s" % (attempt[0], str(attempt[1])) for attempt in self._attempts if attempt]
72+
raise AssertionError("assertions raised for the following values:\n%s" % '\n'.join(value_lines))
73+
74+
def record_attempt(self, attempt_info):
75+
return self._attempts.append(attempt_info)
76+
77+
def to_check_callable(self):
78+
def raise_unless_consuted():
79+
if not self._consulted:
80+
raise AssertionError("no examiniation made of assertion of multiple values")
81+
return raise_unless_consuted
82+
83+
def assert_success(self):
84+
self._consulted = True
85+
assert not any(self._attempts)
86+
87+
@classmethod
88+
def _execute_block(cls, block, block_value):
89+
try:
90+
block.__call__(block_value)
91+
return None
92+
except Exception as blockexc:
93+
return (block_value, blockexc,)

tests/support.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@
6363
shutil.rmtree(TEST_OUTPUT_DIR)
6464
os.mkdir(TEST_OUTPUT_DIR)
6565

66+
67+
from tests._support.assertover import AssertOver
68+
69+
6670
# Basic global logging configuration for testing
6771

6872

@@ -184,6 +188,7 @@ class MigTestCase(TestCase):
184188

185189
def __init__(self, *args):
186190
super(MigTestCase, self).__init__(*args)
191+
self._cleanup_checks = list()
187192
self._cleanup_paths = set()
188193
self._logger = None
189194
self._skip_logging = False
@@ -196,6 +201,11 @@ def setUp(self):
196201
def tearDown(self):
197202
if not self._skip_logging:
198203
self._logger.check_empty_and_reset()
204+
205+
for check_callable in self._cleanup_checks:
206+
check_callable.__call__()
207+
self._cleanup_checks = list()
208+
199209
if self._logger is not None:
200210
self._reset_logging(stream=BLACKHOLE_STREAM)
201211

@@ -213,6 +223,9 @@ def tearDown(self):
213223
def before_each(self):
214224
pass
215225

226+
def _register_check(self, check_callable):
227+
self._cleanup_checks.append(check_callable)
228+
216229
def _reset_logging(self, stream):
217230
root_logger = logging.getLogger()
218231
root_handler = root_logger.handlers[0]
@@ -224,6 +237,12 @@ def logger(self):
224237
self._logger = FakeLogger()
225238
return self._logger
226239

240+
def assert_over(self, values=None, _AssertOver=AssertOver):
241+
assert_over = _AssertOver(values=values, testcase=self)
242+
check_callable = assert_over.to_check_callable()
243+
self._register_check(check_callable)
244+
return assert_over
245+
227246
# custom assertions available for common use
228247

229248
def assertFileContentIdentical(self, file_actual, file_expected):

tests/test_support.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,42 @@
33
import sys
44
import unittest
55

6-
from support import MigTestCase, PY2, testmain, temppath
6+
from support import MigTestCase, PY2, testmain, temppath, \
7+
AssertOver
8+
9+
10+
class InstrumentedAssertOver(AssertOver):
11+
def __init__(self, *args, **kwargs):
12+
AssertOver.__init__(self, *args, **kwargs)
13+
self._check_callable = None
14+
self._check_callable_called = False
15+
16+
def get_check_callable(self):
17+
return self._check_callable
18+
19+
def has_check_callable(self):
20+
return self._check_callable is not None
21+
22+
def was_check_callable_called(self):
23+
return self._check_callable_called
24+
25+
def to_check_callable(self):
26+
_check_callable = AssertOver.to_check_callable(self)
27+
def _wrapped_check_callable():
28+
self._check_callable_called = True
29+
_check_callable()
30+
self._check_callable = _wrapped_check_callable
31+
return _wrapped_check_callable
732

833

934
class SupportTestCase(MigTestCase):
35+
def _class_attribute(self, name, **kwargs):
36+
cls = type(self)
37+
if 'value' in kwargs:
38+
setattr(cls, name, kwargs['value'])
39+
else:
40+
return getattr(cls, name, None)
41+
1042
@unittest.skipIf(PY2, "Python 3 only")
1143
def test_unclosed_files_are_recorded(self):
1244
tmp_path = temppath("support-unclosed", self)
@@ -31,6 +63,30 @@ def test_unclosed_files_are_reset(self):
3163
except:
3264
self.assertTrue(False, "should not be reachable")
3365

66+
def test_when_asserting_over_multiple_values(self):
67+
def assert_is_int(value):
68+
assert isinstance(value, int)
69+
70+
attempt_wrapper = self.assert_over(values=(1, 2, 3), _AssertOver=InstrumentedAssertOver)
71+
72+
# record the wrapper on the test case so the subsequent test can assert against it
73+
self._class_attribute('surviving_attempt_wrapper', value=attempt_wrapper)
74+
75+
with attempt_wrapper as attempt:
76+
attempt(assert_is_int)
77+
attempt_wrapper.assert_success()
78+
79+
self.assertTrue(attempt_wrapper.has_check_callable())
80+
# cleanup was recorded
81+
self.assertIn(attempt_wrapper.get_check_callable(), self._cleanup_checks)
82+
83+
def test_when_asserting_over_multiple_values_after(self):
84+
# test name is purposefully after ..._recorded in sort order
85+
# such that we can check the check function was called correctly
86+
87+
attempt_wrapper = self._class_attribute('surviving_attempt_wrapper')
88+
self.assertTrue(attempt_wrapper.was_check_callable_called())
89+
3490

3591
if __name__ == '__main__':
3692
testmain()
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import unittest
2+
3+
from tests.support import AssertOver
4+
from tests._support.assertover import NoBlockError, NoCasesError
5+
6+
7+
def assert_a_thing(value):
8+
assert value.endswith(' thing'), "must end with a thing"
9+
10+
11+
class TestsSupportAssertOver(unittest.TestCase):
12+
def test_none_failing(self):
13+
saw_raise = False
14+
try:
15+
with AssertOver(values=('some thing', 'other thing')) as value_block:
16+
value_block(lambda _: assert_a_thing(_))
17+
except Exception as exc:
18+
saw_raise = True
19+
self.assertFalse(saw_raise)
20+
21+
def test_three_total_two_failing(self):
22+
with self.assertRaises(AssertionError) as raised:
23+
with AssertOver(values=('some thing', 'other stuff', 'foobar')) as value_block:
24+
value_block(lambda _: assert_a_thing(_))
25+
26+
theexception = raised.exception
27+
self.assertEqual(str(theexception), """assertions raised for the following values:
28+
- <'other stuff'> : must end with a thing
29+
- <'foobar'> : must end with a thing""")
30+
31+
def test_no_cases(self):
32+
with self.assertRaises(AssertionError) as raised:
33+
with AssertOver(values=()) as value_block:
34+
value_block(lambda _: assert_a_thing(_))
35+
36+
theexception = raised.exception
37+
self.assertIsInstance(theexception, NoCasesError)
38+
39+
40+
if __name__ == '__main__':
41+
unittest.main()

0 commit comments

Comments
 (0)