testr¶
The testr
package provides a framework for unit, regression, and integration
testing of a set of packages within a production runtime environment. The
primary component of testr
is lightweight testing script and file structure
definition that simple but can support complex testing as needed. The secondary
component is a couple of helper functions that make it easier to define and run
Python package unit tests. This includes a test_helper
module with some
helper functions.
Package testing: unit, regression, integration¶
The use case here is a production environment where one has the capability (and indeed
requirement) to build and test within a testing environment prior to deploying to
production. The starting assumption is that the testr
package is installed in the
test environment and that all new or updated packages have also been installed to the
environment.
Package testing and definition in the testr
framework is done entirely by convention
rather than configuration. What this means is that there is a certain directory structure
and file naming convention that must be followed and which controls how testing is done.
There are no configuration files at all.
The following definitions are provided to give a general feel for the different styles of testing supported here. The definitions are by no means rigorous and there is certainly overlap.
Unit testing refers to dedicated tests of individual package functionality that are conceptually independent of the environment and result in an immediate pass/fail for each test.
Regression testing refers to tests that typically produce output files which are then
compared against reference outputs. The testr
package contains functions
to facilitate this process by munging the outputs to remove uninteresting
differences (like time stamps or the user who ran a script).
Integration testing refers to ensuring that the packages work within an updated environment. This overlaps with unit and regression testing, but is linked to potential cross-package issues. A key example is updating a core package that may produce unexpected results or issue deprecation warnings downstream that are not desirable.
Basic structure¶
When testr
is installed it creates a console script run_testr
that can
be run from the command line in your environment testing directory. This
directory can be anywhere, but as we will see the ideal setup is to have this
be a git / GitHub repository.
A full-featured example of the environment testing setup is in the ska_testr GitHub repository. A more minimal example would be:
get_version_id # Executable script or program
packages/
py_package/ # py_package is a Python package
helper_script.py # Helper script called by test_regress.sh
test_unit.py # Run built-in unit tests for my
test_regress.sh # Additional regression tests for integration testing
post_regress.py # Copy regression output files to the regress/ directory
post_check_logs.py # Check logs for "warning" or "error"
other_package/ # Some package without unit tests
test_regress_long.sh # Long-running regression test that isn't run every time
post_regress_long.py # Copy regression output files to the regress/ directory
get_version_id¶
The get_version_id
file must be an executable script or program that produces
a single line of output corresponding to a unique version identifier for the
environment. This is used to organize the outputs by version and later to
compare the regression outputs. In the Ska runtime environment this script
produces a version identifier like 0.18-r609-0d91665
.
Packages¶
The packages
directory must contain sub-directories corresponding to
each package being explicitly tested. For Python packages the directory
name should be the same as the importable package name. This allows the
simple unit testing idiom (shown later) to infer the package name to import.
Test scripts¶
Test scripts are located within the package sub-directory. The key concept to understand here is that the tests consist entirely of scripts or executable files that have the following properties:
File name begins with
test_
.File is either a Python script (
test_*.py
), Bash script (test_*.sh
), or an executable file.When run the test file returns a successful exit status (0) for PASS and a non-successful exit status (not 0) for FAIL.
Tests are run in alphabetical order.
Those are the only rules, so the testing can run the gamut from simple to arbitrarily complex.
Post-processing scripts¶
After the tests are run then any post-processing scripts in the package directory will be run (again in alphabetical order). These are exactly the same as test files except:
File name begins with
post_
.Post-processing scripts will be run after all the test scripts are done.
A key point is that the post-processing scripts can generate failures. For instance a common task is checking the output log files for unexpected warnings that do not generate a failure.
Environment variables¶
Several environment variables are defined prior to spawning jobs that run the test or post-processing scripts.
Name |
Definition |
---|---|
TESTR_REGRESS_DIR |
Regression output directory for this package |
TESTR_INTERPRETER |
Interpreter that will be used (python, bash, or None) |
TESTR_PACKAGE |
Package name |
TESTR_PACKAGES_REPO |
URL root for cloning package repo |
TESTR_FILE |
Script file name |
TESTR_OUT_DIR |
Testing output directory for this package |
Filename conventions¶
There are no required conventions beyond the previously mentioned rules, but
following some simple conventions will make life easier and more organized. This
is especially true because of the way that tests are selected based on the
file name and the --include
and --exclude
arguments of run_testr
.
The table below shows keywords that are included in the file name, separated
by underscores.
Keywords |
Meaning |
---|---|
unit |
Run built-in unit tests |
regress |
Regression tests that compare to reference outputs |
git |
Test that requires cloning a git repo to run |
long |
Long-running test that does not need to be run every time |
Standard filenames and examples of the keywords are shown in the example directory structure above.
Running the tests¶
The run_testr
command has the following options:
$ run_testr --help
usage: run_testr [-h] [--test-spec TEST_SPEC] [--root ROOT]
[--outputs-dir OUTPUTS_DIR] [--include INCLUDES]
[--exclude EXCLUDES] [--collect-only]
[--packages-repo PACKAGES_REPO] [--overwrite]
optional arguments:
-h, --help show this help message and exit
--test-spec TEST_SPEC
Test include/exclude specification (default=None)
--root ROOT Directory containing standard testr configuration
--outputs-dir OUTPUTS_DIR
Directory containing all output package test
runs. Absolute, or relative to CWD
--include INCLUDES Include tests that match glob pattern
--exclude EXCLUDES Exclude tests that match glob pattern
--collect-only Collect tests but do not run
--packages-repo PACKAGES_REPO
Base URL for package git repos
--overwrite Overwrite existing outputs directory instead of
deleting
For the example directory structure, doing run_testr
(with no custom options) would
run the tests, reporting test status for each test and then finish with a summary of test
status like so:
*************************************************
*** Package Script Status ***
*** ------------- -------------------- ------ ***
*** other_package test_regress_long.sh pass ***
*** other_package post_regress_long.py pass ***
*** py_package test_regress.sh pass ***
*** py_package test_unit.py pass ***
*** py_package post_check_logs.py pass ***
*** py_package post_regress.py pass ***
*************************************************
The working directory would contain the following new sub-directories. The first step in
processing is to copy the test and post-process scripts from packages
into
outputs/<version_id>
, where <version_id>
is the output of get_version_id
. The
scripts are then run from that directory, so they are free to write outputs directly into
the current directory or any sub-directories therein.
# Testing and post-process scripts and outputs
outputs/
last -> 0.18-r609-0d91665
0.18-r609-0d91665/
logs/
all_tests.json # Master log file in JUnit's XML format
test.log # Master log file of test processing and results
py_package/
helper_script.py
test_unit.py
test_unit.py.log # Log file from running test_unit.py
test_regress.sh
test_regress.sh.log # Log file
post_regress.py
post_regress.py.log # Log file
post_check_logs.py
post_check_logs.py.log
out.dat # Example data file from test_regress.sh
index.html # Example web page from test_regress.sh
other_package/
test_regress_long.sh
test_regress_long.sh.log
post_regress_long.py
post_regress_long.py.log
big_data.dat # More data
# Regression outputs, copied from outputs/ by post_regress* scripts
regress/
py_package/
out.dat # Example data file from test_regress.sh
index.html # Example web page from test_regress.sh
other_package/
big_data.dat # More data
Selecting tests¶
The --include
and --exclude
options can be used to select specific tests and
post-process scripts. The default is to include all files.
The values for both options are a comma-separated list of shell file “glob” patterns
that are used to match files. The selection filtering first finds all files that
match the --include
argument (which defaults to *
), and then removes any
files that match the --exclude
argument (which defaults to None
).
The glob patterns are compared to the full filename that includes the package name
and test name, for instance py_package/test_regress.sh
.
For practical convenience, any specified values (e.g. py_package
) automatically
have a *
appended. Some examples:
$ run_testr --include=py_package # py_package (but also py_package_2)
$ run_testr --include=py_package/ # py_package only
$ run_testr --exclude=py_package/ # exclude py_package but run all others
$ run_testr --exclude='*_long' # exclude long tests
$ run_testr --exclude='*_long,*_unit' # exclude long and unit tests
Comparing regression outputs¶
In this example the 0.18-r609-0d91665 regress outputs might correspond to
the current production installation. Then suppose you create a test
environment with version id 0.19-r610-1b65555
and run the full
test suite. Then you can do:
$ cd regress
$ diff -r 0.18-r609-0d91665/ 0.19-r610-1b65555/
...
A key point is that effort should be made to clean the regression outputs to strip them of uninteresting diffs like a date or the user that ran the tests. See the post_regress.py example for more discussion on that.
One option from here is to copy the regress outputs into a git-versioned repo in a new branch and then use GitHub to do comparisons.
Test specification files¶
One might like to maintain a set of unit and regression tests in a single testing repository but to run somewhat different combinations of those tests in different circumstances. For instance one testing environment might not have the necessary resources to run all tests. The tests themselves might manage this (i.e. pytest “skip”) but it may be simpler and more explicit to manage these lists of tests directly.
The --test-spec
command line option provides a way to do this. If you provide
an option like --test-spec=HEAD
then the following happens:
A file in the current directory named
HEAD
is opened and read:It must contain a list of include / exclude specifications like those for the
-include
and-exclude
options.The exclude specifications are preceded by a minus sign (
-
).Blank lines or those starting with
#
are ignored.
The test specification file include and exclude files are appended to any provided on the command line.
The regression outputs are put into a sub-directory
HEAD
within theregress
directory.
Example test specification file:
# Includes
acisfp_check
dpa_check
# Excludes
-*_long*
File recipes¶
Here we show some example testing files taken from ska_testr GitHub repository in order to illustrate some useful recipes or idioms.
test_unit.py¶
Python packages that have built-in (inline) unit tests (see Python testing helpers) can be tested with the following:
import testr
testr.testr()
This uses pytest to run the included tests for the package. By default it provides flags to run in verbose mode with all test code output copied to the console. This allows any stray warnings to be emitted.
You can do the same thing without using the testr.testr()
function as follows.
The important thing is to raise an exception if there were test failures:
import xija
n_fail = xija.test('-v', '-s', '-k not test_minusz')
if n_fail > 0:
raise ValueError(str(n_fail) + ' test failures')
test_unit_git.sh¶
Python packages that do not have inline unit tests or for other reasons require the source repo for unit testing can use the following recipe:
/usr/bin/git clone ${TESTR_PACKAGES_REPO}/${TESTR_PACKAGE}
cd ${TESTR_PACKAGE}
git checkout master
py.test -v -s
This illustrates some environment variables that are always defined when running test code.
post_regress.py¶
Here is an example from the Ska dpa_check test that copies a few
key outputs into the regress
directory after stripping out the
user name and run time.
from testr.packages import make_regress_files
regress_files = ['out/index.rst',
'out/run.dat',
'out/states.dat',
'out/temperatures.dat']
clean = {'out/index.rst': [(r'^Run time.*', '')],
'out/run.dat': [(r'#.*py run at.*', '')]}
make_regress_files(regress_files, clean=clean)
post_check_logs.py¶
Here is an example again from the Ska dpa_check test that checks the
test processing log files for any occurrences of warning
or error
.
In this case there are a couple of lines where this is expected, so those
matches are ignored. Any other matches will raise an exception and signal
a FAIL for this package:
from testr.packages import check_files
check_files('test_*.log',
checks=['warning', 'error'],
allows=['99% quantile value of', 'in output at out'])
test_regress_long.sh¶
This is an example that is not really a recipe but a demonstration of doing a complex regression test that requires some setup and uses several scripts to regenerate database tables from scratch. It then uses a helper script those to write a subset of database contents in a clean ASCII format for regression comparisons. This is in the kadi package.
# Get three launcher scripts manage.py update_events and update_cmds
/usr/bin/git clone ${TESTR_PACKAGES_REPO}/kadi
# Copy some scripts to the current directory
cd kadi
cp manage.py update_events update_cmds ltt_bads.dat ../
cd ..
rm -rf kadi
export KADI=$PWD
./manage.py syncdb --noinput
START='2015:001'
STOP='2015:030'
./update_events --start=$START --stop=$STOP
./update_cmds --start=$START --stop=$STOP
# Write event and commands data using test database
./write_events_cmds.py --start=$START --stop=$START --data-root=events_cmds
Another complicated example is in the Ska.engarchive package. This one is slightly different because it generates new database values and then immediately compares with the current production database.
Summary Logs¶
Testr produces a summary log which includes all tests run. It parses the logs produced by pytest, and tests are grouped in suites following the test hierarchy. Tests that do not use pytest are grouped in a test suite at the top level. The log is written in JSON format and looks something like the following:
{
"test_suite": {
"name": "Quaternion-tests",
"package": "Quaternion",
"test_cases": [
{
"name": "post_check_logs.py",
"file": "Quaternion/post_check_logs.py",
"timestamp": "2020:06:16T09:43:13",
"log": "Quaternion/post_check_logs.py.log",
"status": "fail",
"failure": {
"message": "post_check_logs.py failed",
"output": null
}
}
],
"timestamp": "2020:06:16T09:43:13",
"properties": {
"system": "Darwin",
"architecture": "64bit",
"hostname": "saos-MacBook-Pro.local",
"platform": "Darwin-19.5.0",
"package": "Quaternion",
"package_version": "3.5.2.dev9+g7ee8b10.d20200616",
"t_start": "2020:06:16T09:43:13",
"t_stop": "2020:06:16T09:43:14",
"regress_dir": null,
"out_dir": "Quaternion"
}
},
"test_suites": [
{
"test_cases": [
{
"name": "test_shape",
"classname": "Quaternion.tests.test_all",
"file": "Quaternion/tests/test_all.py",
"line": "43",
"status": "pass"
},
{
"name": "test_init_exceptions",
"classname": "Quaternion.tests.test_all",
"file": "Quaternion/tests/test_all.py",
"line": "50",
"failure": {
"message": "Exception: Unexpected exception here",
"output": "def test_init_exceptions():\n> raise Exception('Unexpected exception here')\nE Exception: Unexpected exception here\n\nQuaternion/tests/test_all.py:52: Exception"
},
"status": "fail"
},
{
"name": "test_from_q",
"classname": "Quaternion.tests.test_all",
"file": "Quaternion/tests/test_all.py",
"line": "83",
"skipped": {
"message": "no way of currently testing this",
"output": "Quaternion/tests/test_all.py:83: <py._xmlgen.raw object at 0x7f9ca044fb38>"
},
"status": "skipped"
}
],
"name": "Quaternion-pytest",
"properties": {
"system": "Darwin",
"architecture": "64bit",
"hostname": "saos-MacBook-Pro.local",
"platform": "Darwin-19.5.0",
"package": "Quaternion",
"package_version": "3.5.2.dev9+g7ee8b10.d20200616",
"t_start": "2020:06:16T09:43:11",
"t_stop": "2020:06:16T09:43:13",
"regress_dir": null,
"out_dir": "Quaternion"
},
"log": "Quaternion/test_unit.py.log",
"hostname": "saos-MacBook-Pro.local",
"timestamp": "2020:06:16T09:43:11",
"package": "Quaternion",
"file": "Quaternion/test_unit.py"
}
]
}
Python testing helpers¶
The modules testr.runner
and testr.setup_helper
provide the infrastructure
to enable easy and uniform running of pytest test functions in two ways:
By importing the package (locally or installed) and doing
<package>.test(*args, **kwargs)
Within a local development repo using
python setup.py test --args='arg1 kwarg2=val2'
wherearg1
andkwarg2
are valid pytest arguments.
__init__.py
¶
Typical usage within the package __init__.py
file:
def test(*args, **kwargs):
'''
Run py.test unit tests.
'''
import testr
return testr.test(*args, **kwargs)
This will run any tests that included as a test
sub-package in the package
distribution. The following package layout shows an example of such inlined tests:
setup.py # your setuptools Python package metadata
mypkg/
__init__.py
appmodule.py
...
tests/
test_app.py
...
A key advantage of providing inline tests is that they can be run post-install
in the production environment, providing positive confirmation that the actual
installed package is working as expected. See the next section for an example
setup.py
file which shows including this inline test sub-package.
setup.py
¶
Typical usage in setup.py
:
from setuptools import setup
try:
from testr.setup_helper import cmdclass
except ImportError:
cmdclass = {}
setup(name='my_package',
packages=['my_package', 'my_package.tests'],
tests_require=['pytest'],
cmdclass=cmdclass,
)
API¶
testr.runner¶
Provide a test() function that can be called from package __init__.
- testr.runner.get_full_version(calling_frame_globals, calling_frame_filename)[source]¶
Return a full version which includes git info if the module was imported from the git repo source directory.
- testr.runner.test(*args, **kwargs)[source]¶
Run py.test unit tests for the calling package with specified
args
andkwargs
.This temporarily changes to the directory above the installed package directory and effectively runs
py.test <packagename> <args> <kwargs>
.If the kwarg
raise_exception=True
is provided then any test failures will result in an exception being raised. This can be used to make a shell-level failure.- Parameters
*args – positional args to pass to pytest
raise_exception – test failures raise an exception (default=False)
package_from_dir – set package name from parent directory name (default=False)
verbose – run pytest in verbose (-v) mode (default=False)
show_output – run pytest in show output (-s) mode (default=False)
**kwargs – additional keyword args to pass to pytest
- Returns
number of test failures
- testr.runner.testr(*args, **kwargs)[source]¶
Run py.test unit tests for the calling package. This just calls the
test()
function but includes defaults that are more appropriate for integrated package testing using run_testr.- Parameters
*args – positional args to pass to pytest
raise_exception – test failures raise an exception (default=True)
package_from_dir – set package name from parent directory name (default=True)
verbose – run pytest in verbose (-v) mode (default=True)
show_output – run pytest in show output (-s) mode (default=True)
**kwargs – additional keyword args to pass to pytest
- Returns
number of test failures
testr.setup_helper¶
Define a test runner command class suitable for use in setup.py
so
that python setup.py test
runs tests via pytest.
- class testr.setup_helper.PyTest(dist, **kw)[source]¶
Test runner command class suitable for use in
setup.py
so thatpython setup.py test
runs tests via pytest.- initialize_options()[source]¶
Set default values for all the options that this command supports. Note that these defaults may be overridden by other commands, by the setup script, by config files, or by the command-line. Thus, this is not the place to code dependencies between options; generally, ‘initialize_options()’ implementations are just a bunch of “self.foo = None” assignments.
This method must be implemented by all command classes.
testr.test_helper¶
Provide helper functions that are useful for unit testing.
- testr.test_helper.has_dirs(*paths)[source]¶
All of the
paths
exist and each is a directoryInput path(s) can contain ~ (home dir) or environment variables like $SKA or ${SKA}.
- testr.test_helper.has_paths(*paths)[source]¶
All of the
paths
existInput path(s) can contain ~ (home dir) or environment variables like $SKA or ${SKA}.
- testr.test_helper.has_sybase()[source]¶
Return True if the system apparently can run Sybase queries from Python.
In detail, this is True if the SYBASE and SYBASE_OCS env variables are set and the correct Python shared object library exists on the system.
- testr.test_helper.on_head_network()[source]¶
Return True if the system is apparently on the HEAD network.
This looks for a list of subnets corresponding to linux machines listed in the HEAD nodeinfo database. Last updated on 2022-08-30. It also requires that the path /proj/sot/ska exists (because there are machines on those subnets that do not have /proj/sot/ska on their filesystem).