edq.testing.unittest

  1import datetime
  2import difflib
  3import os
  4import typing
  5import unittest
  6
  7import edq.util.dirent
  8import edq.util.json
  9import edq.util.reflection
 10import edq.util.serial
 11import edq.util.time
 12
 13FORMAT_STR: str = "\n--- Expected ---\n%s\n--- Actual ---\n%s\n---\n"
 14
 15DirentSpec = typing.Tuple[str, typing.Union[str, typing.Dict[str, edq.util.serial.PODType], typing.List['DirentSpec']]]  # pylint: disable=invalid-name
 16"""
 17A simple spec for a directory entry.
 18See create_directory_structure().
 19"""
 20
 21class BaseTest(unittest.TestCase):
 22    """
 23    A base class for unit tests.
 24    """
 25
 26    maxDiff = None
 27    """ Don't limit the size of diffs. """
 28
 29    testing_timezone: typing.Union[datetime.timezone, None] = edq.util.time.UTC
 30    """ The timezone to use. """
 31
 32    use_diff_output: bool = True
 33    """ Use diff-like comparisons. """
 34
 35    @classmethod
 36    def setUpClass(cls) -> None:
 37        super().setUpClass()
 38
 39        edq.util.time.set_testing_local_timezone(cls.testing_timezone)
 40
 41    @classmethod
 42    def tearDownClass(cls) -> None:
 43        super().tearDownClass()
 44
 45        edq.util.time.set_testing_local_timezone(None)
 46
 47    def assertStringEqual(self, expected: str, actual: str, message: typing.Union[str, None] = None, strip: bool = False) -> None:  # pylint: disable=invalid-name
 48        """ Check that two strings are equal. """
 49
 50        expected = str(expected)
 51        actual = str(actual)
 52
 53        if (strip):
 54            expected = expected.strip()
 55            actual = actual.strip()
 56
 57        if (actual != expected):
 58            if (message is None):
 59                message = self._format_comparison_message(expected, actual)
 60
 61            self.fail(message)
 62
 63    def assertJSONEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
 64        """
 65        Like unittest.TestCase.assertEqual(),
 66        but uses expected default assertion message containing the full JSON representation of the arguments.
 67        """
 68
 69        expected_json = edq.util.json.dumps(expected, indent = 4)
 70        actual_json = edq.util.json.dumps(actual, indent = 4)
 71
 72        if (message is None):
 73            message = self._format_comparison_message(expected_json, actual_json)
 74
 75        super().assertEqual(expected, actual, msg = message)
 76
 77    def assertJSONDictEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
 78        """
 79        Like unittest.TestCase.assertDictEqual(),
 80        but will try to convert each comparison argument to a dict if it is not already,
 81        and uses a default assertion message containing the full JSON representation of the arguments.
 82        """
 83
 84        if (not isinstance(expected, dict)):
 85            if (isinstance(expected, edq.util.serial.DictConverter)):
 86                expected = expected.to_dict(edq.util.serial.SerializationContext())
 87            else:
 88                expected = vars(expected)
 89
 90        if (not isinstance(actual, dict)):
 91            if (isinstance(actual, edq.util.serial.DictConverter)):
 92                actual = actual.to_dict(edq.util.serial.SerializationContext())
 93            else:
 94                actual = vars(actual)
 95
 96        expected_json = edq.util.json.dumps(expected, indent = 4)
 97        actual_json = edq.util.json.dumps(actual, indent = 4)
 98
 99        if (message is None):
100            message = self._format_comparison_message(expected_json, actual_json)
101
102        super().assertDictEqual(expected, actual, msg = message)
103
104    def assertJSONListEqual(self,  # pylint: disable=invalid-name
105            expected: typing.List[typing.Any],
106            actual: typing.List[typing.Any],
107            message: typing.Union[str, None] = None,
108            ) -> None:
109        """
110        Call assertDictEqual(), but supply a default message containing the full JSON representation of the arguments.
111        """
112
113        expected_json = edq.util.json.dumps(expected, indent = 4)
114        actual_json = edq.util.json.dumps(actual, indent = 4)
115
116        if (message is None):
117            message = self._format_comparison_message(expected_json, actual_json)
118
119        super().assertListEqual(expected, actual, msg = message)
120
121    def assertFileHashEqual(self, expected: str, actual: str) -> None:  # pylint: disable=invalid-name
122        """
123        Assert that the hash of two files matches.
124        Will fail if either path does not exist.
125        """
126
127        if (not edq.util.dirent.exists(expected)):
128            self.fail(f"File does not exist: '{expected}'.")
129
130        if (not edq.util.dirent.exists(actual)):
131            self.fail(f"File does not exist: '{actual}'.")
132
133        expected_hash = edq.util.dirent.hash_file(expected)
134        actual_hash = edq.util.dirent.hash_file(actual)
135
136        self.assertEqual(expected_hash, actual_hash, msg = f"Hash mismatch: '{expected}' ({expected_hash}) vs '{actual}' ({actual_hash}).")
137
138    def format_error_string(self, ex: typing.Union[BaseException, None]) -> str:
139        """
140        Format an error string from an exception so it can be checked for testing.
141        The type of the error will be included,
142        and any nested errors will be joined together.
143        """
144
145        parts = []
146
147        while (ex is not None):
148            type_name = edq.util.reflection.get_qualified_name(ex)
149            message = str(ex)
150
151            parts.append(f"{type_name}: {message}")
152
153            ex = ex.__cause__
154
155        return "; ".join(parts)
156
157    def _format_comparison_message(self, expected: str, actual: str) -> str:
158        """ Create a message string comparing the two given strings. """
159
160        message = FORMAT_STR % (expected, actual)
161
162        if (not self.use_diff_output):
163            return message
164
165        expected_lines = expected.splitlines(keepends = True)
166        actual_lines = actual.splitlines(keepends = True)
167
168        lines = list(difflib.unified_diff(expected_lines, actual_lines, fromfile = 'expected', tofile = 'actual'))
169        message += "\n--- Diff ---\n" + ''.join(lines) + "\n------------"
170
171        return message
172
173def create_directory_structure(spec: typing.List[DirentSpec], base_dir: str) -> None:
174    """
175    Create a directory strucutre from a simple definition.
176    The definition represents a list of direents.
177    Each dirent is a tuple with the name of a dirent and the contents of the dirent.
178    The contents can be:
179        1) a list if dirents (indicating that this dirent is a directory),
180        2) a dict (indicating that this dirent is a JSON file),
181        or 3) a string (indicating that this dirent is a text file).
182    """
183
184    for (name, contents) in spec:
185        path = os.path.join(base_dir, name)
186
187        if (isinstance(contents, str)):
188            edq.util.dirent.write_file(path, contents)
189        elif (isinstance(contents, dict)):
190            edq.util.json.dump_path(contents, path, indent = 4)
191        elif (isinstance(contents, list)):
192            edq.util.dirent.mkdir(path)
193            create_directory_structure(contents, path)
194        else:
195            raise ValueError(f"Unknown dirent content type ('{type(contents)}') at path: '{path}'.")
FORMAT_STR: str = '\n--- Expected ---\n%s\n--- Actual ---\n%s\n---\n'
DirentSpec = typing.Tuple[str, typing.Union[str, typing.Dict[str, typing.Union[bool, float, int, str, typing.List[ForwardRef('PODType')], typing.Dict[str, ForwardRef('PODType')], NoneType]], typing.List[ForwardRef('DirentSpec')]]]

A simple spec for a directory entry. See create_directory_structure().

class BaseTest(unittest.case.TestCase):
 22class BaseTest(unittest.TestCase):
 23    """
 24    A base class for unit tests.
 25    """
 26
 27    maxDiff = None
 28    """ Don't limit the size of diffs. """
 29
 30    testing_timezone: typing.Union[datetime.timezone, None] = edq.util.time.UTC
 31    """ The timezone to use. """
 32
 33    use_diff_output: bool = True
 34    """ Use diff-like comparisons. """
 35
 36    @classmethod
 37    def setUpClass(cls) -> None:
 38        super().setUpClass()
 39
 40        edq.util.time.set_testing_local_timezone(cls.testing_timezone)
 41
 42    @classmethod
 43    def tearDownClass(cls) -> None:
 44        super().tearDownClass()
 45
 46        edq.util.time.set_testing_local_timezone(None)
 47
 48    def assertStringEqual(self, expected: str, actual: str, message: typing.Union[str, None] = None, strip: bool = False) -> None:  # pylint: disable=invalid-name
 49        """ Check that two strings are equal. """
 50
 51        expected = str(expected)
 52        actual = str(actual)
 53
 54        if (strip):
 55            expected = expected.strip()
 56            actual = actual.strip()
 57
 58        if (actual != expected):
 59            if (message is None):
 60                message = self._format_comparison_message(expected, actual)
 61
 62            self.fail(message)
 63
 64    def assertJSONEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
 65        """
 66        Like unittest.TestCase.assertEqual(),
 67        but uses expected default assertion message containing the full JSON representation of the arguments.
 68        """
 69
 70        expected_json = edq.util.json.dumps(expected, indent = 4)
 71        actual_json = edq.util.json.dumps(actual, indent = 4)
 72
 73        if (message is None):
 74            message = self._format_comparison_message(expected_json, actual_json)
 75
 76        super().assertEqual(expected, actual, msg = message)
 77
 78    def assertJSONDictEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
 79        """
 80        Like unittest.TestCase.assertDictEqual(),
 81        but will try to convert each comparison argument to a dict if it is not already,
 82        and uses a default assertion message containing the full JSON representation of the arguments.
 83        """
 84
 85        if (not isinstance(expected, dict)):
 86            if (isinstance(expected, edq.util.serial.DictConverter)):
 87                expected = expected.to_dict(edq.util.serial.SerializationContext())
 88            else:
 89                expected = vars(expected)
 90
 91        if (not isinstance(actual, dict)):
 92            if (isinstance(actual, edq.util.serial.DictConverter)):
 93                actual = actual.to_dict(edq.util.serial.SerializationContext())
 94            else:
 95                actual = vars(actual)
 96
 97        expected_json = edq.util.json.dumps(expected, indent = 4)
 98        actual_json = edq.util.json.dumps(actual, indent = 4)
 99
100        if (message is None):
101            message = self._format_comparison_message(expected_json, actual_json)
102
103        super().assertDictEqual(expected, actual, msg = message)
104
105    def assertJSONListEqual(self,  # pylint: disable=invalid-name
106            expected: typing.List[typing.Any],
107            actual: typing.List[typing.Any],
108            message: typing.Union[str, None] = None,
109            ) -> None:
110        """
111        Call assertDictEqual(), but supply a default message containing the full JSON representation of the arguments.
112        """
113
114        expected_json = edq.util.json.dumps(expected, indent = 4)
115        actual_json = edq.util.json.dumps(actual, indent = 4)
116
117        if (message is None):
118            message = self._format_comparison_message(expected_json, actual_json)
119
120        super().assertListEqual(expected, actual, msg = message)
121
122    def assertFileHashEqual(self, expected: str, actual: str) -> None:  # pylint: disable=invalid-name
123        """
124        Assert that the hash of two files matches.
125        Will fail if either path does not exist.
126        """
127
128        if (not edq.util.dirent.exists(expected)):
129            self.fail(f"File does not exist: '{expected}'.")
130
131        if (not edq.util.dirent.exists(actual)):
132            self.fail(f"File does not exist: '{actual}'.")
133
134        expected_hash = edq.util.dirent.hash_file(expected)
135        actual_hash = edq.util.dirent.hash_file(actual)
136
137        self.assertEqual(expected_hash, actual_hash, msg = f"Hash mismatch: '{expected}' ({expected_hash}) vs '{actual}' ({actual_hash}).")
138
139    def format_error_string(self, ex: typing.Union[BaseException, None]) -> str:
140        """
141        Format an error string from an exception so it can be checked for testing.
142        The type of the error will be included,
143        and any nested errors will be joined together.
144        """
145
146        parts = []
147
148        while (ex is not None):
149            type_name = edq.util.reflection.get_qualified_name(ex)
150            message = str(ex)
151
152            parts.append(f"{type_name}: {message}")
153
154            ex = ex.__cause__
155
156        return "; ".join(parts)
157
158    def _format_comparison_message(self, expected: str, actual: str) -> str:
159        """ Create a message string comparing the two given strings. """
160
161        message = FORMAT_STR % (expected, actual)
162
163        if (not self.use_diff_output):
164            return message
165
166        expected_lines = expected.splitlines(keepends = True)
167        actual_lines = actual.splitlines(keepends = True)
168
169        lines = list(difflib.unified_diff(expected_lines, actual_lines, fromfile = 'expected', tofile = 'actual'))
170        message += "\n--- Diff ---\n" + ''.join(lines) + "\n------------"
171
172        return message

A base class for unit tests.

maxDiff = None

Don't limit the size of diffs.

testing_timezone: Optional[datetime.timezone] = datetime.timezone.utc

The timezone to use.

use_diff_output: bool = True

Use diff-like comparisons.

@classmethod
def setUpClass(cls) -> None:
36    @classmethod
37    def setUpClass(cls) -> None:
38        super().setUpClass()
39
40        edq.util.time.set_testing_local_timezone(cls.testing_timezone)

Hook method for setting up class fixture before running tests in the class.

@classmethod
def tearDownClass(cls) -> None:
42    @classmethod
43    def tearDownClass(cls) -> None:
44        super().tearDownClass()
45
46        edq.util.time.set_testing_local_timezone(None)

Hook method for deconstructing the class fixture after running all tests in the class.

def assertStringEqual( self, expected: str, actual: str, message: Optional[str] = None, strip: bool = False) -> None:
48    def assertStringEqual(self, expected: str, actual: str, message: typing.Union[str, None] = None, strip: bool = False) -> None:  # pylint: disable=invalid-name
49        """ Check that two strings are equal. """
50
51        expected = str(expected)
52        actual = str(actual)
53
54        if (strip):
55            expected = expected.strip()
56            actual = actual.strip()
57
58        if (actual != expected):
59            if (message is None):
60                message = self._format_comparison_message(expected, actual)
61
62            self.fail(message)

Check that two strings are equal.

def assertJSONEqual(self, expected: Any, actual: Any, message: Optional[str] = None) -> None:
64    def assertJSONEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
65        """
66        Like unittest.TestCase.assertEqual(),
67        but uses expected default assertion message containing the full JSON representation of the arguments.
68        """
69
70        expected_json = edq.util.json.dumps(expected, indent = 4)
71        actual_json = edq.util.json.dumps(actual, indent = 4)
72
73        if (message is None):
74            message = self._format_comparison_message(expected_json, actual_json)
75
76        super().assertEqual(expected, actual, msg = message)

Like unittest.TestCase.assertEqual(), but uses expected default assertion message containing the full JSON representation of the arguments.

def assertJSONDictEqual(self, expected: Any, actual: Any, message: Optional[str] = None) -> None:
 78    def assertJSONDictEqual(self, expected: typing.Any, actual: typing.Any, message: typing.Union[str, None] = None) -> None:  # pylint: disable=invalid-name
 79        """
 80        Like unittest.TestCase.assertDictEqual(),
 81        but will try to convert each comparison argument to a dict if it is not already,
 82        and uses a default assertion message containing the full JSON representation of the arguments.
 83        """
 84
 85        if (not isinstance(expected, dict)):
 86            if (isinstance(expected, edq.util.serial.DictConverter)):
 87                expected = expected.to_dict(edq.util.serial.SerializationContext())
 88            else:
 89                expected = vars(expected)
 90
 91        if (not isinstance(actual, dict)):
 92            if (isinstance(actual, edq.util.serial.DictConverter)):
 93                actual = actual.to_dict(edq.util.serial.SerializationContext())
 94            else:
 95                actual = vars(actual)
 96
 97        expected_json = edq.util.json.dumps(expected, indent = 4)
 98        actual_json = edq.util.json.dumps(actual, indent = 4)
 99
100        if (message is None):
101            message = self._format_comparison_message(expected_json, actual_json)
102
103        super().assertDictEqual(expected, actual, msg = message)

Like unittest.TestCase.assertDictEqual(), but will try to convert each comparison argument to a dict if it is not already, and uses a default assertion message containing the full JSON representation of the arguments.

def assertJSONListEqual( self, expected: List[Any], actual: List[Any], message: Optional[str] = None) -> None:
105    def assertJSONListEqual(self,  # pylint: disable=invalid-name
106            expected: typing.List[typing.Any],
107            actual: typing.List[typing.Any],
108            message: typing.Union[str, None] = None,
109            ) -> None:
110        """
111        Call assertDictEqual(), but supply a default message containing the full JSON representation of the arguments.
112        """
113
114        expected_json = edq.util.json.dumps(expected, indent = 4)
115        actual_json = edq.util.json.dumps(actual, indent = 4)
116
117        if (message is None):
118            message = self._format_comparison_message(expected_json, actual_json)
119
120        super().assertListEqual(expected, actual, msg = message)

Call assertDictEqual(), but supply a default message containing the full JSON representation of the arguments.

def assertFileHashEqual(self, expected: str, actual: str) -> None:
122    def assertFileHashEqual(self, expected: str, actual: str) -> None:  # pylint: disable=invalid-name
123        """
124        Assert that the hash of two files matches.
125        Will fail if either path does not exist.
126        """
127
128        if (not edq.util.dirent.exists(expected)):
129            self.fail(f"File does not exist: '{expected}'.")
130
131        if (not edq.util.dirent.exists(actual)):
132            self.fail(f"File does not exist: '{actual}'.")
133
134        expected_hash = edq.util.dirent.hash_file(expected)
135        actual_hash = edq.util.dirent.hash_file(actual)
136
137        self.assertEqual(expected_hash, actual_hash, msg = f"Hash mismatch: '{expected}' ({expected_hash}) vs '{actual}' ({actual_hash}).")

Assert that the hash of two files matches. Will fail if either path does not exist.

def format_error_string(self, ex: Optional[BaseException]) -> str:
139    def format_error_string(self, ex: typing.Union[BaseException, None]) -> str:
140        """
141        Format an error string from an exception so it can be checked for testing.
142        The type of the error will be included,
143        and any nested errors will be joined together.
144        """
145
146        parts = []
147
148        while (ex is not None):
149            type_name = edq.util.reflection.get_qualified_name(ex)
150            message = str(ex)
151
152            parts.append(f"{type_name}: {message}")
153
154            ex = ex.__cause__
155
156        return "; ".join(parts)

Format an error string from an exception so it can be checked for testing. The type of the error will be included, and any nested errors will be joined together.

def create_directory_structure( spec: List[Tuple[str, Union[str, Dict[str, Union[bool, float, int, str, List[ForwardRef('PODType')], Dict[str, ForwardRef('PODType')], NoneType]], List[ForwardRef('DirentSpec')]]]], base_dir: str) -> None:
174def create_directory_structure(spec: typing.List[DirentSpec], base_dir: str) -> None:
175    """
176    Create a directory strucutre from a simple definition.
177    The definition represents a list of direents.
178    Each dirent is a tuple with the name of a dirent and the contents of the dirent.
179    The contents can be:
180        1) a list if dirents (indicating that this dirent is a directory),
181        2) a dict (indicating that this dirent is a JSON file),
182        or 3) a string (indicating that this dirent is a text file).
183    """
184
185    for (name, contents) in spec:
186        path = os.path.join(base_dir, name)
187
188        if (isinstance(contents, str)):
189            edq.util.dirent.write_file(path, contents)
190        elif (isinstance(contents, dict)):
191            edq.util.json.dump_path(contents, path, indent = 4)
192        elif (isinstance(contents, list)):
193            edq.util.dirent.mkdir(path)
194            create_directory_structure(contents, path)
195        else:
196            raise ValueError(f"Unknown dirent content type ('{type(contents)}') at path: '{path}'.")

Create a directory strucutre from a simple definition. The definition represents a list of direents. Each dirent is a tuple with the name of a dirent and the contents of the dirent. The contents can be: 1) a list if dirents (indicating that this dirent is a directory), 2) a dict (indicating that this dirent is a JSON file), or 3) a string (indicating that this dirent is a text file).