Integration Testing#
The purpose of integration testing is to examine the interation of a small group of units. (i.e. procedures) This group should not be arbitrary but represent some coherent piece of functionality.
To aid in creating such tests the LFRic core provides a framework for writing them and support in the build system.
Test Structure#
Following the pattern determined by unit testing each project may have an
integration-test directory. Tests may be added at this level but it is
often neater to further organise them with a directory tree.
Each test consists of at least a Fortran source file containing a program
program unit and simlarly named Python script. e.g. special_test.f90 and
special_test.py.
There may be additional Fortran source files but the purpose of the resulting executable is to set up and exercise the functionality being tested. It should communicate the result of this usually by writing to standard out.
The python script is then resposible for interpreting the output of the Fortran program and determining whether the test has passed or not.
Test Framework#
To help implement the Python script a framework is provided in
infrastructure/build/testframework.
All tests must ultimately derive from testframework.test.AbstractTest which
manages the running of the test but also provides methods which may be
overridden for your own purposes.
The first is test() which is abstract so must be over-ridden. This is where
you implement your test. You are given the return code of the test execution
along with the contents of standard out and standard error. You may raise a
testframework.exception.TestFailed exception to indicate a failure.
Otherwise return a string describing the test which succeeded.
Optionally you may override the post_execution() method which is called
after execution is complete but before the test is performed. This is useful
for gathering additional output from the execution, e.g. XIOS output files.
There are also methods filterOut and filterErr which may be overridden
in order to groom the standard out and standard error text before it is used in
the test. For example, removing chatter from the MPI library.
The TestFailed exception is constructed with a message but may also relay
return code, standard out, standard error and log file, depending on what is
relevant to your test.
Finally testframework.testengine provides the TestEngine class which is
used to execute tests.
Common Test Types#
The abstract test class can be extended to support any kind of test you wish to carry out however a number of common cases are already provided by the framework:
TestSimple serial tests which involve running the executable and inspecting the standard out and standard error.
MpiTestLaunches the executable under MPI and strips any chatter from the MPI library before passing on standard out and standard error for scrutiny.
LFRicLoggingTestRuns the test in parallel but also harvests the
PETxxx.Loglog files generated by the LFRic logging framework. These log files may be obtained by calling thegetLFRicLoggingLog()method, passing the MPI rank you are interested in.
Example#
All of this is best illustrated with an example. We will be looking at testing the command-line argument handling capabilities of the infrastructure.
Firstly we need a program in
infrastructure/integration-tests/cli_mod_test.f90:
program cli_mod_test
use, intrinsic :: iso_fortran_env, only : output_unit
use cli_mod, only : get_initial_filename
implicit none
character(:), allocatable :: filename
call get_initial_filename( filename )
write( output_unit, '(A)' ) filename
end program cli_mod_test
As you can see, this program simply fetches the configuration filename from the command line and prints it out.
With that in place we need a script
(infrastructure/integration-tests/cli_mod_test.py) to test the result of
executing the program and determining whether it is working or not:
import sys
from testframework import Test, TestEngine, TestFailed
class cli_mod_normal_test(Test):
def __init__(self):
self._INJECT = 'onwards/waffles.nml'
super().__init__( [sys.argv[1], self._INJECT] )
def test(self, returncode: int, out: str, err: str) -> str:
if returncode != 0:
raise TestFailed(f"Unexpected failure of test executable: {returncode}")
if out.strip() != self._INJECT:
raise TestFailed(
f"Expected filename '{self._INJECT}' but found '{out.strip()}'."
)
return "Filename extracted from command line"
This first test intercepts object construction in order to keep a copy of the argument it is passing to the executable.
When the test has been run the first check is that the executable exited with a success (zero) return code. Provided it did, the standard output is checked against the argument passed in. Assuming everything was okay the passing test is described in the return value.
class cli_mod_too_few_test(Test):
def test(self, returncode: int, out: str, err: str):
if returncode == 0:
raise TestFailed("Unexpected success with no arguments")
return "Command line with no arguments returned with error"
This test is simpler, it doesn’t intercept any of the set up and just tests that the return code was not zero, a successful execution.
if __name__ == '__main__':
TestEngine.run(cli_mod_normal_test())
TestEngine.run(cli_mod_too_few_test())
Finally, when the script is executed, we use the TestEngine to run the
tests we’ve defined.
Test Resources#
Some tests will need additional supporting resources. They may need a mesh or
a namelist file. These can be placed in a directory resources within the
test directory. This directory will be copied into the execution location so
the contents should be accessed using filenames like resources/mesh.nc.
Sometimes a group of tests need common resources. These can be found in
integration-test/support/resources. A symlink from the exeuction directory
to this directory will be created and should be referred to like this:
shared-resources/mesh.nc.