Added replacement for the current regression test suite
parent
00f32316b2
commit
2e745b0b26
@ -0,0 +1 @@
|
||||
# -*- coding: utf-8 -*-
|
@ -0,0 +1,426 @@
|
||||
# TL;DR
|
||||
|
||||
If you just want to write a simple test case, check out the file
|
||||
`writing_tests.md`.
|
||||
|
||||
# Introduction
|
||||
|
||||
This test suite is intended for system tests, i.e. for running a binary with
|
||||
certain parameters and comparing the output against an expected value. This is
|
||||
especially useful for a regression test suite, but can be also used for testing
|
||||
of new features where unit testing is not feasible, e.g. to test new command
|
||||
line parameters.
|
||||
|
||||
The test suite is configured via `INI` style files using Python's builtin
|
||||
[ConfigParser](https://docs.python.org/3/library/configparser.html)
|
||||
module. Such a configuration file looks roughly like this:
|
||||
``` ini
|
||||
[DEFAULT]
|
||||
some_var: some_val
|
||||
|
||||
[section 1]
|
||||
empty_var:
|
||||
multiline_var: this is a multiline string
|
||||
as long as the indentation
|
||||
is present
|
||||
# comments can be inserted
|
||||
# some_var is implicitly present in this section by the DEFAULT section
|
||||
|
||||
[section 2]
|
||||
# set some_var for this section to something else than the default
|
||||
some_var: some_other_val
|
||||
# values from other sections can be inserted
|
||||
vars can have whitespaces: ${some_var} ${section 1: multiline var}
|
||||
multiline var: multiline variables can have
|
||||
|
||||
empty lines too
|
||||
```
|
||||
|
||||
For further details concerning the syntax, please consult the official
|
||||
documentation. The `ConfigParser` module is used with the following defaults:
|
||||
- Comments are started by `#` only
|
||||
- The separator between a variable and the value is `:`
|
||||
- Multiline comments can have empty lines
|
||||
- Extended Interpolation is used (this allows to refer to other sections when
|
||||
inserting values using the `${section:variable}` syntax)
|
||||
|
||||
Please keep in mind that leading and trailing whitespaces are **stripped** from
|
||||
strings when extracting variable values. So this:
|
||||
|
||||
``` ini
|
||||
some_var: some value with whitespaces before and after
|
||||
```
|
||||
is equivalent to this:
|
||||
``` ini
|
||||
some_var:some value with whitespaces before and after
|
||||
```
|
||||
|
||||
The test suite itself uses the builtin `unittest` module of Python to discover
|
||||
and run the individual test cases. The test cases themselves are implemented in
|
||||
Python source files, but the required Python knowledge is minimal.
|
||||
|
||||
## Test suite
|
||||
|
||||
The test suite is configured via one configuration file whose location
|
||||
automatically sets the root directory of the test suite. The `unittest` module
|
||||
then recursively searches all sub-directories with a `__init__.py` file for
|
||||
files of the form `test_*.py`, which it automatically interprets as test cases
|
||||
(more about these in the next section). Python will automatically interpret each
|
||||
directory as a module and use this to format the output, e.g. the test case
|
||||
`regression/crashes/test_bug_15.py` will be interpreted as the module
|
||||
`regression.crashes.test_bug_15`. Thus one can use the directory structure to
|
||||
group test cases.
|
||||
|
||||
The test suite's configuration file should have the following form:
|
||||
|
||||
``` ini
|
||||
[General]
|
||||
timeout: 0.1
|
||||
|
||||
[paths]
|
||||
binary: ../build/bin/binary
|
||||
important_file: ../conf/main.cfg
|
||||
|
||||
[variables]
|
||||
abort_error: ERROR
|
||||
abort_exit value: 1
|
||||
```
|
||||
|
||||
The General section only contains the `timeout` parameter, which is actually
|
||||
optional (when left out 1.0 is assumed). The timeout sets the maximum time in
|
||||
seconds for each command that is run before it is aborted. This allows for test
|
||||
driven development with tests that cause infinite loops or similar hangs in the
|
||||
test suite.
|
||||
|
||||
The paths and variables sections define global variables for the system test
|
||||
suite, which every test case can read. Following the DRY principle, one can put
|
||||
common outputs of the tested binary in a variable, so that changing an error
|
||||
message does not result in an hour long update of the test suite. Both sections
|
||||
are merged together before being passed on to the test cases, thus they must not
|
||||
contain variables with the same name (doing so results in an error).
|
||||
|
||||
While the values in the variables section are simply passed on to the test cases
|
||||
the paths section is special as its contents are interpreted as relative paths
|
||||
(with respect to the test suite's root) and are expanded to absolute paths
|
||||
before being passed to the test cases. This can be used to inform each test case
|
||||
about the location of a built binary or a configuration file without having to
|
||||
rely on environment variables.
|
||||
|
||||
However, sometimes environment variables are very handy to implement variable
|
||||
paths or platform differences (like different build directories or file
|
||||
extensions). For this, the test suite supports the `ENV` and `ENV fallback`
|
||||
sections. In conjunction with the extended interpolation of the `ConfigParser`
|
||||
module, these can be quite useful. Consider the following example:
|
||||
|
||||
``` ini
|
||||
[General]
|
||||
timeout: 0.1
|
||||
|
||||
[ENV]
|
||||
variable_prefix: PREFIX
|
||||
file_extension: FILE_EXT
|
||||
|
||||
[ENV fallback]
|
||||
variable_prefix: ../build
|
||||
|
||||
[paths]
|
||||
binary: ${ENV:variable_prefix}/bin/binary${ENV:file_extension}
|
||||
important_file: ../conf/main.cfg
|
||||
|
||||
[variables]
|
||||
abort_error: ERROR
|
||||
abort_exit value: 1
|
||||
```
|
||||
|
||||
The `ENV` section is, similarly to the `paths` section, special insofar as the
|
||||
variables are extracted from the environment with the given name. E.g. the
|
||||
variable `file_extension` would be set to the value of the environment variable
|
||||
`FILE_EXT`. If the environment variable is not defined, then the test suite will
|
||||
look in the `ENV fallback` section for a fallback. E.g. in the above example
|
||||
`variable_prefix` has the fallback or default value of `../build` which will be
|
||||
used if the environment variable `PREFIX` is not set. If no fallback is provided
|
||||
then an empty string is used instead, which would happen to `file_extension` if
|
||||
`FILE_EXT` would be unset.
|
||||
|
||||
This can be combined with the extended interpolation of Python's `ConfigParser`,
|
||||
which allows to include variables from arbitrary sections into other variables
|
||||
using the `${sect:var_name}` syntax. This would be expanded to the value of
|
||||
`var_name` from the section `sect`. The above example only utilizes this in the
|
||||
`paths` section, but it can also be used in the `variables` section, if that
|
||||
makes sense for the use case.
|
||||
|
||||
Returning to the example config file, the path `binary` would be inferred in the
|
||||
following steps:
|
||||
1. extract `PREFIX` & `FILE_EXT` from the environment, if they don't exist use
|
||||
the default values from `ENV fallback` or ""
|
||||
2. substitute the strings `${ENV:variable_prefix}` and `${ENV:file_extension}`
|
||||
3. expand the relative path to an absolute path
|
||||
|
||||
Please note that while the `INI` file allows for variables with whitespaces or
|
||||
`-` in their names, such variables will cause errors as they are invalid
|
||||
variable names in Python.
|
||||
|
||||
|
||||
## Test cases
|
||||
|
||||
The test cases are defined in Python source files utilizing the unittest module,
|
||||
thus every file must also be a valid Python file. Each file defining a test case
|
||||
must start with `test_` and have the file extension `py`. To be discovered by
|
||||
the unittest module it must reside in a directory with a (empty) `__init__.py`
|
||||
file.
|
||||
|
||||
A test case should test one logical unit, e.g. test for regressions of a certain
|
||||
bug or check if a command line option works. Each test case can run multiple
|
||||
commands which results are compared to an expected standard output, standard
|
||||
error and return value. Should differences arise or should one of the commands
|
||||
take too long, then an error message with the exact differences is shown to the
|
||||
user.
|
||||
|
||||
An example test case file would look like this:
|
||||
|
||||
``` python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import system_tests
|
||||
|
||||
|
||||
class AnInformativeName(system_tests.Case):
|
||||
|
||||
filename = "invalid_input_file"
|
||||
commands = [
|
||||
"{binary} -c {import_file} -i {filename}"
|
||||
]
|
||||
retval = ["{abort_exit_value}"]
|
||||
stdout = ["Reading {filename}"]
|
||||
stderr = [
|
||||
"""{abort_error}
|
||||
error in {filename}
|
||||
"""
|
||||
]
|
||||
```
|
||||
|
||||
The first 6 lines are necessary boilerplate to pull in the necessary routines to
|
||||
run the actual tests (these are implemented in the module `system_tests` with
|
||||
the class `system_tests.Case` extending `unittest.TestCase`). When adding new
|
||||
tests one should choose a new class name that briefly summarizes the test. Note
|
||||
that the file name (without the extension) with the directory structure is
|
||||
interpreted as the module by Python and pre-pended to the class name when
|
||||
reporting about the tests. E.g. the file `regression/crashes/test_bug_15.py`
|
||||
with the class `OutOfBoundsRead` gets reported as
|
||||
`regression.crashes.test_bug_15.OutOfBoundsRead** already including a brief
|
||||
summary of this test.
|
||||
|
||||
**Caution:** Always import `system_tests` in the aforementioned syntax and don't
|
||||
use `from system_tests import Case`. This will not work, as the `system_tests`
|
||||
module stores the suite's config internally which will not be available if you
|
||||
perform a `from system_tests import Case` (this causes Python to create a copy
|
||||
of the class `system_tests.Case` for your module, without reading the
|
||||
configuration file).
|
||||
|
||||
In the following lines the lists `commands`, `retval`, `stdout` and `stderr`
|
||||
should be defined. These are lists of strings and must all have the same amount
|
||||
of elements.
|
||||
|
||||
The test suite at first takes all these strings and substitutes all values in
|
||||
curly braces with variables either defined in this class alongside (like
|
||||
`filename` in the above example) or with the values defined in the test suite's
|
||||
configuration file. Please note that defining a variable with the same name as a
|
||||
variable in the suite's configuration file will result in an error (otherwise
|
||||
one of the variables would take precedence leading to unexpected results). The
|
||||
substitution of values in performed using Python's string `format()` method and
|
||||
more elaborate format strings can be used when necessary.
|
||||
|
||||
In the above example the command would thus expand to:
|
||||
``` shell
|
||||
/path/to/the/dir/build/bin/binary -c /path/to/the/dir/conf/main.cfg -i invalid_input_file
|
||||
```
|
||||
and similarly for `stdout` and `stderr`.
|
||||
|
||||
Once the substitution is performed, each command is run using Python's
|
||||
`subprocess` module, its output is compared to the values in `stdout` and
|
||||
`stderr` and its return value to `retval`. Please note that for portability
|
||||
reasons the subprocess module is run with `shell=False`, thus shell expansions
|
||||
or pipes will not work.
|
||||
|
||||
As the test cases are implemented in Python, one can take full advantage of
|
||||
Python for the construction of the necessary lists. For example when 10 commands
|
||||
should be run and all return 0, one can write `retval = 10 * [0]` instead of
|
||||
writing 0 ten times. The same is of course possible for strings.
|
||||
|
||||
There are however some peculiarities with multiline strings in Python. Normal
|
||||
strings start and end with a single `"` but multiline strings start with three
|
||||
`"`. Also, while the variable names must be indented, new lines in multiline
|
||||
strings must not or additional whitespaces will be added. E.g.:
|
||||
|
||||
``` python
|
||||
stderr = [
|
||||
"""something
|
||||
else"""
|
||||
]
|
||||
```
|
||||
will actually result in the string:
|
||||
|
||||
```
|
||||
something
|
||||
else
|
||||
```
|
||||
and not:
|
||||
```
|
||||
something
|
||||
else
|
||||
```
|
||||
as the indentation might have suggested.
|
||||
|
||||
Also note that in this example the string will not be terminated with a newline
|
||||
character. To achieve that put the `"""` on the following line.
|
||||
|
||||
|
||||
## Advanced test cases
|
||||
|
||||
This section describes more advanced features that are probably not necessary
|
||||
the "standard" usage of the test suite.
|
||||
|
||||
|
||||
### Creating file copies
|
||||
|
||||
For tests that modify their input file it is useful to run these with a
|
||||
disposable copy of the input file and not with the original. For this purpose
|
||||
the test suite features a decorator which creates a copy of the supplied files
|
||||
and deletes the copies after the test ran.
|
||||
|
||||
Example:
|
||||
``` python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import system_tests
|
||||
|
||||
|
||||
@system_tests.CopyFiles("{filename}", "{some_path}/another_file.txt")
|
||||
class AnInformativeName(system_tests.Case):
|
||||
|
||||
filename = "invalid_input_file"
|
||||
commands = [
|
||||
"{binary} -c {import_file} -i {filename}"
|
||||
]
|
||||
retval = ["{abort_exit_value}"]
|
||||
stdout = ["Reading {filename}"]
|
||||
stderr = [
|
||||
"""{abort_error}
|
||||
error in {filename}
|
||||
"""
|
||||
]
|
||||
```
|
||||
|
||||
In this example, the test suite would automatically create a copy of the files
|
||||
`invalid_input_file` and `{some_path}/another_file.txt` (`some_path` would be of
|
||||
course expanded too) named `invalid_input_file_copy` and
|
||||
`{some_path}/another_file_copy.txt`. After the test ran, the copies are
|
||||
deleted. Please note that variable expansion in the filenames is possible.
|
||||
|
||||
|
||||
### Customizing the output check
|
||||
|
||||
Some tests do not require a "brute-force" comparison of the whole output of a
|
||||
program but only a very simple check (e.g. that a string is present). For these
|
||||
cases, one can customize how stdout and stderr checked for errors.
|
||||
|
||||
The `system_tests.Case` class has two public functions for the check of stdout &
|
||||
stderr: `compare_stdout` & `compare_stderr`. They have the following interface:
|
||||
``` python
|
||||
compare_stdout(self, i, command, got_stdout, expected_stdout)
|
||||
compare_stderr(self, i, command, got_stderr, expected_stderr)
|
||||
```
|
||||
with the parameters:
|
||||
- i: index of the command in the `commands` list
|
||||
- command: a string of the actually invoked command
|
||||
- got_stdout/stderr: the obtained stdout, post-processed depending on the
|
||||
platform so that lines always end with `\n`
|
||||
- expected_stdout/stderr: the expected output extracted from
|
||||
`self.stdout`/`self.stderr`
|
||||
|
||||
These functions can be overridden in child classes to perform custom checks (or
|
||||
to omit them completely, too). Please however note, that it is not possible to
|
||||
customize how the return value is checked. This is indented, as the return value
|
||||
is often used by the OS to indicate segfaults and ignoring it (in combination
|
||||
with flawed checks of the output) could lead to crashes not being noticed.
|
||||
|
||||
|
||||
### Manually expanding variables in strings
|
||||
|
||||
In case completely custom checks have to be run but one still wants to access
|
||||
the variables from the test suite, the class `system_test.Case` provides the
|
||||
function `expand_variables(self, string)`. It performs the previously described
|
||||
variable substitution using the test suite's configuration file.
|
||||
|
||||
Unfortunately, it has to run in a class member function. The `setUp()` function
|
||||
can be used for this, as it is run before each test. For example like this:
|
||||
``` python
|
||||
class SomeName(system_tests.Case):
|
||||
|
||||
def setUp(self):
|
||||
self.commands = [self.expand_variables("{some_var}/foo.txt")]
|
||||
self.stderr = [""]
|
||||
self.stdout = [self.expand_variables("{success_message}")]
|
||||
self.retval = [0]
|
||||
```
|
||||
|
||||
This example will work, as the test runner reads the data for `commands`,
|
||||
`stderr`, `stdout` and `retval` from the class instance. What however will not
|
||||
work is creating a new member in `setUp()` and trying to use it as a variable
|
||||
for expansion, like this:
|
||||
``` python
|
||||
class SomeName(system_tests.Case):
|
||||
|
||||
def setUp(self):
|
||||
self.new_var = "foo"
|
||||
self.another_string = self.expand_variables("{new_var}")
|
||||
```
|
||||
|
||||
This example fails in `self.expand_variables` because the expansion uses only
|
||||
static class members (which `new_var` is not). Also, if you modify a static
|
||||
class member in `setUp()` the changed version will **not** be used for variable
|
||||
expansion, as the variables are saved in a new dictionary **before** `setUp()`
|
||||
runs. Thus this:
|
||||
``` python
|
||||
class SomeName(system_tests.Case):
|
||||
|
||||
new_var = "foo"
|
||||
|
||||
def setUp(self):
|
||||
self.new_var = "bar"
|
||||
self.another_string = self.expand_variables("{new_var}")
|
||||
```
|
||||
|
||||
will result in `another_string` being "foo" and not "bar".
|
||||
|
||||
|
||||
### Possible pitfalls
|
||||
|
||||
- Do not provide a custom `setUpClass()` function for the test
|
||||
cases. `setUpClass()` is used by `system_tests.Case` to store the variables
|
||||
for expansion.
|
||||
|
||||
- Keep in mind that the variable expansion uses Python's `format()`
|
||||
function. This can make it more cumbersome to include formatted strings into
|
||||
variables like `commands` which will likely contain other variables from the
|
||||
test suite. E.g.: `commands = ["{binary} {:s}".format(f) for f in files]` will
|
||||
not work as `format()` will expect a value for binary. This can be worked
|
||||
around using either the old Python formatting via `%` or by formatting first
|
||||
and then concatenating the problematic parts.
|
||||
|
||||
|
||||
## Running the test suite
|
||||
|
||||
The test suite is written for Python 3 but is in principle also compatible with
|
||||
Python 2, albeit it is not regularly tested, so its functionality is not
|
||||
guaranteed with Python 2.
|
||||
|
||||
Then navigate to the `tests/` subdirectory and run:
|
||||
``` shell
|
||||
python3 runner.py
|
||||
```
|
||||
|
||||
The runner script also supports the optional arguments `--config_file` which
|
||||
allows to provide a different test suite configuration file than the default
|
||||
`suite.conf`. It also forwards the verbosity setting via the `-v`/`--verbose`
|
||||
flags to Python's unittest module.
|
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import unittest
|
||||
import sys
|
||||
|
||||
import system_tests
|
||||
|
||||
parser = argparse.ArgumentParser(description="The system test suite")
|
||||
|
||||
parser.add_argument(
|
||||
"--config_file",
|
||||
type=str,
|
||||
nargs=1,
|
||||
default=['suite.conf']
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action='count',
|
||||
default=1
|
||||
)
|
||||
args = parser.parse_args()
|
||||
conf_file = args.config_file[0]
|
||||
discovery_root = os.path.dirname(conf_file)
|
||||
|
||||
system_tests.configure_suite(conf_file)
|
||||
|
||||
discovered_tests = unittest.TestLoader().discover(discovery_root)
|
||||
test_res = unittest.runner.TextTestRunner(verbosity=args.verbose)\
|
||||
.run(discovered_tests)
|
||||
|
||||
sys.exit(0 if len(test_res.failures) + len(test_res.errors) == 0 else 1)
|
@ -0,0 +1,20 @@
|
||||
[General]
|
||||
timeout: 1
|
||||
|
||||
[ENV]
|
||||
exiv2_path: EXIV2_PATH
|
||||
binary_extension: EXIV2_EXT
|
||||
|
||||
[ENV fallback]
|
||||
exiv2_path: ../build/bin
|
||||
|
||||
[paths]
|
||||
exiv2: ${ENV:exiv2_path}/exiv2${ENV:binary_extension}
|
||||
exiv2json: ${ENV:exiv2_path}/exiv2json${ENV:binary_extension}
|
||||
data_path: ../test/data
|
||||
tiff-test: ${ENV:exiv2_path}/tiff-test${ENV:binary_extension}
|
||||
|
||||
[variables]
|
||||
error_58_message: corrupted image metadata
|
||||
error_57_message: invalid memory allocation request
|
||||
exiv2_exception_msg: Exiv2 exception in print action for file
|
@ -0,0 +1,331 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import configparser
|
||||
import os
|
||||
import inspect
|
||||
import subprocess
|
||||
import threading
|
||||
import shlex
|
||||
import sys
|
||||
import shutil
|
||||
import unittest
|
||||
|
||||
|
||||
if sys.platform == 'win32':
|
||||
def _cmd_splitter(cmd):
|
||||
return cmd
|
||||
|
||||
def _process_output_post(output):
|
||||
return output.replace('\r\n', '\n')
|
||||
|
||||
else:
|
||||
def _cmd_splitter(cmd):
|
||||
return shlex.split(cmd)
|
||||
|
||||
def _process_output_post(output):
|
||||
return output
|
||||
|
||||
|
||||
def _disjoint_dict_merge(d1, d2):
|
||||
"""
|
||||
Merges two dictionaries with no common keys together and returns the result.
|
||||
|
||||
>>> d1 = {"a": 1}
|
||||
>>> d2 = {"b": 2, "c": 3}
|
||||
>>> _disjoint_dict_merge(d1, d2) == {"a": 1, "b": 2, "c": 3}
|
||||
True
|
||||
|
||||
Calling this function with dictionaries that share keys raises a ValueError:
|
||||
>>> _disjoint_dict_merge({"a": 1, "b": 6}, {"b": 2, "a": 3})
|
||||
Traceback (most recent call last):
|
||||
..
|
||||
ValueError: Dictionaries have common keys.
|
||||
|
||||
"""
|
||||
inter = set(d1.keys()).intersection(set(d2.keys()))
|
||||
if len(inter) > 0:
|
||||
raise ValueError("Dictionaries have common keys.")
|
||||
res = d1.copy()
|
||||
res.update(d2)
|
||||
return res
|
||||
|
||||
|
||||
_parameters = {}
|
||||
|
||||
|
||||
def configure_suite(config_file):
|
||||
"""
|
||||
Populates a global datastructure with the parameters from the suite's
|
||||
configuration file.
|
||||
|
||||
This function performs the following steps:
|
||||
1. read in the file ``config_file`` via the ConfigParser module using
|
||||
extended interpolation
|
||||
2. check that the sections ``variables`` and ``paths`` are disjoint
|
||||
3. extract the environment variables given in the ``ENV`` section
|
||||
4. save all entries from the ``variables`` section in the global
|
||||
datastructure
|
||||
5. interpret all entries in the ``paths`` section as relative paths from the
|
||||
configuration file, expand them to absolute paths and save them in the
|
||||
global datastructure
|
||||
|
||||
For further information concerning the rationale behind this, please consult
|
||||
the documentation in ``doc.md``.
|
||||
"""
|
||||
|
||||
if not os.path.exists(config_file):
|
||||
raise ValueError(
|
||||
"Test suite config file {:s} does not exist"
|
||||
.format(os.path.abspath(config_file))
|
||||
)
|
||||
|
||||
config = configparser.ConfigParser(
|
||||
interpolation=configparser.ExtendedInterpolation(),
|
||||
delimiters=(':'),
|
||||
comment_prefixes=('#')
|
||||
)
|
||||
config.read(config_file)
|
||||
|
||||
_parameters["suite_root"] = os.path.split(os.path.abspath(config_file))[0]
|
||||
_parameters["timeout"] = config.getfloat("General", "timeout", fallback=1.0)
|
||||
|
||||
if 'variables' in config and 'paths' in config:
|
||||
intersecting_keys = set(config["paths"].keys())\
|
||||
.intersection(set(config["variables"].keys()))
|
||||
if len(intersecting_keys) > 0:
|
||||
raise ValueError(
|
||||
"The sections 'paths' and 'variables' must not share keys, "
|
||||
"but they have the following common key{:s}: {:s}"
|
||||
.format(
|
||||
's' if len(intersecting_keys) > 1 else '',
|
||||
', '.join(k for k in intersecting_keys)
|
||||
)
|
||||
)
|
||||
|
||||
# extract variables from the environment
|
||||
for key in config['ENV']:
|
||||
if key in config['ENV fallback']:
|
||||
fallback = config['ENV fallback'][key]
|
||||
else:
|
||||
fallback = ""
|
||||
config['ENV'][key] = os.getenv(config['ENV'][key]) or fallback
|
||||
|
||||
if 'variables' in config:
|
||||
for key in config['variables']:
|
||||
_parameters[key] = config['variables'][key]
|
||||
|
||||
if 'paths' in config:
|
||||
for key in config['paths']:
|
||||
rel_path = config['paths'][key]
|
||||
abs_path = os.path.abspath(
|
||||
os.path.join(_parameters["suite_root"], rel_path)
|
||||
)
|
||||
if not os.path.exists(abs_path):
|
||||
raise ValueError(
|
||||
"Path replacement for {short}: {abspath} does not exist"
|
||||
" (was expanded from {rel})".format(
|
||||
short=key,
|
||||
abspath=abs_path,
|
||||
rel=rel_path)
|
||||
)
|
||||
_parameters[key] = abs_path
|
||||
|
||||
|
||||
def _setUp_factory(old_setUp, *files):
|
||||
"""
|
||||
Factory function that returns a setUp function suitable to replace the
|
||||
existing setUp of a unittest.TestCase. The returned setUp calls at first
|
||||
old_setUp(self) and then creates a copy of all files in *files with the
|
||||
name: fname.ext -> fname_copy.ext
|
||||
|
||||
All file names in *files are at first expanded using self.expand_variables()
|
||||
and the path to the copy is saved in self._file_copies
|
||||
"""
|
||||
def setUp(self):
|
||||
old_setUp(self)
|
||||
self._file_copies = []
|
||||
for f in files:
|
||||
expanded_fname = self.expand_variables(f)
|
||||
fname, ext = os.path.splitext(expanded_fname)
|
||||
new_name = fname + '_copy' + ext
|
||||
self._file_copies.append(
|
||||
shutil.copyfile(expanded_fname, new_name)
|
||||
)
|
||||
return setUp
|
||||
|
||||
|
||||
def _tearDown_factory(old_tearDown):
|
||||
"""
|
||||
Factory function that returns a new tearDown method to replace an existing
|
||||
tearDown method. It at first deletes all files in self._file_copies and then
|
||||
calls old_tearDown(self).
|
||||
This factory is intended to be used in conjunction with _setUp_factory
|
||||
"""
|
||||
def tearDown(self):
|
||||
for f in self._file_copies:
|
||||
os.remove(f)
|
||||
old_tearDown(self)
|
||||
return tearDown
|
||||
|
||||
|
||||
def CopyFiles(*files):
|
||||
"""
|
||||
Decorator for subclasses of system_test.Case that automatically creates a
|
||||
copy of the files specified as the parameters to the decorator.
|
||||
|
||||
Example:
|
||||
>>> @CopyFiles("{some_var}/file.txt", "{another_var}/other_file.png")
|
||||
class Foo(Case):
|
||||
pass
|
||||
|
||||
The decorator will inject new setUp method that at first calls the already
|
||||
defined setUp(), then expands all supplied file names using
|
||||
Case.expand_variables and then creates copies by appending '_copy' before
|
||||
the file extension. The paths to the copies are stored in self._file_copies.
|
||||
|
||||
The decorator also injects a new tearDown method that deletes all files in
|
||||
self._file_copies and then calls the original tearDown method.
|
||||
|
||||
This function will also complain if it is called without arguments or
|
||||
without paranthesis, which is valid decorator syntax but is obviously a bug
|
||||
in this case.
|
||||
"""
|
||||
if len(files) == 0:
|
||||
raise ValueError("No files to copy supplied.")
|
||||
elif len(files) == 1:
|
||||
if isinstance(files[0], type):
|
||||
raise UserWarning(
|
||||
"Decorator used wrongly, must be called with filenames in paranthesis"
|
||||
)
|
||||
|
||||
def wrapper(cls):
|
||||
old_setUp = cls.setUp
|
||||
cls.setUp = _setUp_factory(old_setUp, *files)
|
||||
|
||||
old_tearDown = cls.tearDown
|
||||
cls.tearDown = _tearDown_factory(old_tearDown)
|
||||
|
||||
return cls
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class Case(unittest.TestCase):
|
||||
"""
|
||||
System test case base class, provides the functionality to interpret static
|
||||
class members as system tests and runs them.
|
||||
|
||||
This class reads in the members commands, retval, stdout, stderr and runs
|
||||
the format function on each, where format is called with the kwargs being a
|
||||
merged dictionary of all variables that were extracted from the suite's
|
||||
configuration file and all static members of the current class.
|
||||
|
||||
The resulting commands are then run using the subprocess module and compared
|
||||
against the expected values that were provided in the static
|
||||
members. Furthermore a threading.Timer is used to abort the execution if a
|
||||
configured timeout is reached.
|
||||
|
||||
The class itself must be inherited from, otherwise it is not useful at all,
|
||||
as it does not provide any static members that could be used to run system
|
||||
tests. However, a class that inherits from this class needn't provide any
|
||||
member functions at all, the inherited test_run() function performs all
|
||||
required functionality in child classes.
|
||||
"""
|
||||
|
||||
""" maxDiff set so that arbitrarily large diffs will be shown """
|
||||
maxDiff = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""
|
||||
This function adds the variables variable_dict & work_dir to the class.
|
||||
|
||||
work_dir - set to the file where the current class is defined
|
||||
variable_dict - a merged dictionary of all static members of the current
|
||||
class and all variables extracted from the suite's
|
||||
configuration file
|
||||
"""
|
||||
cls.variable_dict = _disjoint_dict_merge(cls.__dict__, _parameters)
|
||||
cls.work_dir = os.path.dirname(inspect.getfile(cls))
|
||||
|
||||
def compare_stdout(self, i, command, got_stdout, expected_stdout):
|
||||
"""
|
||||
Function to compare whether the expected & obtained stdout match.
|
||||
|
||||
This function is automatically invoked by test_run with the following
|
||||
parameters:
|
||||
i - the index of the current command that is run in self.commands
|
||||
command - the command that was run
|
||||
got_stdout - the obtained stdout, post-processed depending on the
|
||||
platform so that lines always end with \n
|
||||
expected_stdout - the expected stdout extracted from self.stdout
|
||||
|
||||
The default implementation simply uses assertMultiLineEqual from
|
||||
unittest.TestCase. This function can be overridden in a child class to
|
||||
implement a custom check.
|
||||
"""
|
||||
self.assertMultiLineEqual(expected_stdout, got_stdout)
|
||||
|
||||
def compare_stderr(self, i, command, got_stderr, expected_stderr):
|
||||
"""
|
||||
Same as compare_stdout only for standard-error.
|
||||
"""
|
||||
self.assertMultiLineEqual(expected_stderr, got_stderr)
|
||||
|
||||
def expand_variables(self, string):
|
||||
"""
|
||||
Expands all variables in curly braces in the given string using the
|
||||
dictionary variable_dict.
|
||||
|
||||
The expansion itself is performed by the builtin string method format().
|
||||
A KeyError indicates that the supplied string contains a variable
|
||||
in curly braces that is missing from self.variable_dict
|
||||
"""
|
||||
return str(string).format(**self.variable_dict)
|
||||
|
||||
def test_run(self):
|
||||
"""
|
||||
Actual system test function which runs the provided commands,
|
||||
pre-processes all variables and post processes the output before passing
|
||||
it on to compare_stderr() & compare_stdout().
|
||||
"""
|
||||
|
||||
for i, command, retval, stdout, stderr in zip(range(len(self.commands)),
|
||||
self.commands,
|
||||
self.retval,
|
||||
self.stdout,
|
||||
self.stderr):
|
||||
command, retval, stdout, stderr = map(
|
||||
self.expand_variables, [command, retval, stdout, stderr]
|
||||
)
|
||||
retval = int(retval)
|
||||
timeout = {"flag": False}
|
||||
|
||||
proc = subprocess.Popen(
|
||||
_cmd_splitter(command),
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
cwd=self.work_dir
|
||||
)
|
||||
|
||||
def timeout_reached(timeout):
|
||||
timeout["flag"] = True
|
||||
proc.kill()
|
||||
|
||||
t = threading.Timer(
|
||||
_parameters["timeout"], timeout_reached, args=[timeout]
|
||||
)
|
||||
t.start()
|
||||
got_stdout, got_stderr = proc.communicate()
|
||||
t.cancel()
|
||||
|
||||
self.assertFalse(timeout["flag"] and "Timeout reached")
|
||||
self.compare_stdout(
|
||||
i, command,
|
||||
_process_output_post(got_stdout.decode('utf-8')), stdout
|
||||
)
|
||||
self.compare_stderr(
|
||||
i, command,
|
||||
_process_output_post(got_stderr.decode('utf-8')), stderr
|
||||
)
|
||||
self.assertEqual(retval, proc.returncode)
|
@ -0,0 +1,42 @@
|
||||
## Writing new tests
|
||||
|
||||
The test suite is intended to run a binary and compare its standard output,
|
||||
standard error and return value against provided values. This is implemented
|
||||
using Python's `unittest` module and thus all test files are Python files.
|
||||
|
||||
The simplest test has the following structure:
|
||||
``` python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import system_tests
|
||||
|
||||
|
||||
class GoodTestName(system_tests.Case):
|
||||
|
||||
filename = "{data_path}/test_file"
|
||||
commands = ["{exiv2} " + filename, "{exiv2} " + filename + '_2']
|
||||
stdout = [""] * 2
|
||||
stderr = ["""{exiv2_exception_msg} """ + filename + """:
|
||||
{error_58_message}
|
||||
"""] * 2
|
||||
retval = [1] * 2
|
||||
```
|
||||
|
||||
The test suite will run the provided commands in `commands` and compare them to
|
||||
the output in `stdout` and `stderr` and it will compare the return values.
|
||||
|
||||
The strings in curly braces are variables either defined in this test's class or
|
||||
are taken from the suite's configuration file (see `doc.md` for a complete
|
||||
explanation).
|
||||
|
||||
When creating new tests, follow roughly these steps:
|
||||
|
||||
1. Choose an appropriate subdirectory where the test belongs. If none fits
|
||||
create a new one and put an empty `__init__.py` file there.
|
||||
|
||||
2. Create a new file with a name matching `test_*.py`. Copy the class definition
|
||||
from the above example and choose an appropriate class name.
|
||||
|
||||
3. Run the test suite via `python3 runner.py` and ensure that your test case is
|
||||
actually run! Either run the suite with the `-v` option which will output all
|
||||
test cases that were run or simply add an error and check if errors occur.
|
Loading…
Reference in New Issue