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