Skip to content

Commit e696aa2

Browse files
authored
Add test examples to the wrapper development template (#12)
* Improve exception handling and boundaries of SimulationEngine. * Add wrapper API test examples. * Add usage tests examples. * Add unit tests examples. * Add integration tests example. * Describe which tests are mandatory and which are optional in the test module's docstring.
1 parent 4f96e82 commit e696aa2

6 files changed

Lines changed: 866 additions & 17 deletions

File tree

package_name/engine.py

Lines changed: 95 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""This file emulates communication with a simulation engine."""
22

33
from dataclasses import dataclass
4+
from functools import wraps
45
from typing import List, Optional
56

67
from numpy import ndarray
@@ -14,67 +15,136 @@ class Atom:
1415
velocity: ndarray
1516

1617

18+
class AtomNotFoundException(KeyError):
19+
"""Exception to be raised when a non-existent atom is requested.
20+
21+
Exception raised by `SimulationEngine` when an index that is not associated
22+
with an atom in the engine is looked up.
23+
"""
24+
25+
def __init__(self, *args):
26+
"""Initialize the exception with a default message."""
27+
default_msg = "No atom with given index registered in the engine"
28+
if not args:
29+
args = (default_msg,)
30+
super().__init__(*args)
31+
32+
33+
def raise_atom_not_found_exception(func):
34+
"""A decorator to automatically raise AtomNotFoundException.
35+
36+
Raises AtomNotFoundException when the first positional argument of the
37+
decorated method of `SimulationEngine` does not correspond to an atom that
38+
is registered in the engine.
39+
"""
40+
41+
@wraps(func)
42+
def decorated(self, index: int, *args, **kwargs):
43+
exception = AtomNotFoundException()
44+
if index < 0:
45+
raise exception
46+
try:
47+
atom = self._atoms[index]
48+
except IndexError as e:
49+
raise exception from e
50+
if atom is None:
51+
raise exception
52+
return func(self, index, *args, **kwargs)
53+
54+
return decorated
55+
56+
1757
class SimulationEngine:
18-
"""Simple engine sample code."""
58+
"""Simple engine sample code.
59+
60+
This simulation engine associates atoms with an integer index. Indexing
61+
starts at zero, and each time an atom is added, `last_index + 1` is
62+
associated with it. Indexes of deleted atoms are never reused for new
63+
atoms.
64+
"""
1965

20-
atoms: List[Optional[Atom]]
66+
_atoms: List[Optional[Atom]]
2167

2268
def __init__(self):
23-
"""Initialize the simulation engine."""
24-
self.atoms = list()
69+
"""Initializes the simulation engine."""
70+
self._atoms = list()
2571
print("Engine instantiated!")
2672

2773
def __str__(self) -> str:
2874
"""String representation of the simulation engine."""
2975
return "Some Engine Connection"
3076

3177
def run(self, time_steps: int = 1) -> None:
32-
"""Call the run command of the engine."""
78+
"""Calls the run command of the engine."""
3379
print("Now the engine is running")
34-
for atom in self.atoms:
80+
for atom in self._atoms:
3581
atom.position = atom.position + atom.velocity * time_steps
3682

3783
def add_atom(self, position: ndarray, velocity: ndarray) -> None:
38-
"""Add an atom to the engine.
84+
"""Adds an atom to the engine.
3985
4086
Args:
4187
position: A 3D array for the position of the atom.
4288
velocity: A 3D array for the velocity of the atom.
4389
"""
4490
print(
4591
"Add atom %s with position %s and velocity %s"
46-
% (len(self.atoms), position, velocity)
92+
% (len(self._atoms), position, velocity)
4793
)
48-
self.atoms.append(Atom(position, velocity))
94+
self._atoms.append(Atom(position, velocity))
4995

96+
@raise_atom_not_found_exception
5097
def delete_atom(self, index: int) -> None:
51-
"""Remove an atom from the engine.
98+
"""Removes an atom from the engine.
5299
53100
Args:
54101
index: The index of the atom to remove.
102+
103+
Raises:
104+
AtomNotFoundException: No atom with given index registered in the
105+
engine.
55106
"""
56-
self.atoms[index] = None
107+
exception = AtomNotFoundException()
108+
try:
109+
atom = self._atoms[index]
110+
except KeyError as e:
111+
raise exception from e
112+
if atom is None:
113+
raise exception
114+
115+
self._atoms[index] = None
57116

117+
@raise_atom_not_found_exception
58118
def update_position(self, index: int, position: ndarray) -> None:
59119
"""Update the position of the atom.
60120
61121
Args:
62122
index: The index of the atom to update
63123
position: The new position.
124+
125+
Raises:
126+
AtomNotFoundException: No atom with given index registered in the
127+
engine.
64128
"""
65129
print("Update atom %s. Setting position to %s" % (index, position))
66-
self.atoms[index].position = position
130+
self._atoms[index].position = position
67131

132+
@raise_atom_not_found_exception
68133
def update_velocity(self, index: int, velocity: ndarray) -> None:
69134
"""Update the velocity of an atom.
70135
71136
Args:
72137
index: The index of the atom
73138
velocity: The new velocity.
139+
140+
Raises:
141+
AtomNotFoundException: No atom with given index registered in the
142+
engine.
74143
"""
75144
print("Update atom %s. Setting velocity to %s" % (index, velocity))
76-
self.atoms[index].velocity = velocity
145+
self._atoms[index].velocity = velocity
77146

147+
@raise_atom_not_found_exception
78148
def get_velocity(self, index: int) -> ndarray:
79149
"""Get the velocity of an atom.
80150
@@ -83,16 +153,25 @@ def get_velocity(self, index: int) -> ndarray:
83153
84154
Returns:
85155
The velocity of the atom.
156+
157+
Raises:
158+
AtomNotFoundException: No atom with given index registered in the
159+
engine.
86160
"""
87-
return self.atoms[index].velocity
161+
return self._atoms[index].velocity
88162

89-
def get_position(self, index) -> ndarray:
163+
@raise_atom_not_found_exception
164+
def get_position(self, index: int) -> ndarray:
90165
"""Get the position of the atom.
91166
92167
Args:
93168
index: The index of the atom.
94169
95170
Returns:
96171
The position of the atom.
172+
173+
Raises:
174+
AtomNotFoundException: No atom with given index registered in the
175+
engine.
97176
"""
98-
return self.atoms[index].position
177+
return self._atoms[index].position

tests/__init__.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1-
"""Use this module to write tests for your wrapper."""
1+
"""Use this module to write tests for your wrapper.
2+
3+
Modify the files in this module to adapt them to your own wrapper. You can
4+
organize your test files and their contents in any way you wish and use any
5+
testing framework.
6+
7+
Writing tests that cover the typical use case of your wrapper (see
8+
`test_usage.py`) and tests that cover calls to all the wrapper API functions
9+
you use in your wrapper (see `test_wrapper_api.py`) is very strongly
10+
recommended, and may even be required if the wrapper is to be officially
11+
endorsed by us.
12+
13+
Optionally, you can also write unit tests (see `test_unit.py`) and integration
14+
tests (see `test_integration.py`).
15+
"""

tests/test_integration.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Tests the integration of the different modules used in your wrapper.
2+
3+
This file is meant to contain tests that verify that the different components
4+
of your software are also functional when they have to work together.
5+
6+
Modify this file to adapt it to your own wrapper. You can organize your tests
7+
in any way you wish (e.g. distribute them among different files) and use any
8+
testing framework, and you can omit this kind of testing in your package,
9+
although it is recommended not to do so.
10+
"""
11+
12+
import unittest
13+
from functools import wraps
14+
from inspect import getfullargspec
15+
from typing import get_type_hints
16+
17+
from package_name.wrapper import SimulationWrapper
18+
19+
20+
class TestIntegrationWrapperEngine(unittest.TestCase):
21+
"""Tests the integration of the wrapper and the engine classes.
22+
23+
As the engine is simple, only a test that checks that the correct types are
24+
passed by the wrapper to the engine functions is provided. As the
25+
consistency check of the wrapper already checks that the dimension or data
26+
types of the arrays passed by the wrapper are correct, this coverage should
27+
be sufficient.
28+
29+
Nevertheless, always be on guard:
30+
"A software tester walks into a bar. Asks to be seated outside, behind
31+
the counter and in the basement. Orders 1 beer, 374738493 beers, -3
32+
beers, 'asdfgh' beers, a mojito, 5.8 mojitos and a Rubik's Cube. Jumps
33+
on and dances on all the tables. Testing is complete.
34+
35+
The bar opens to the public and after a few hour a customer comes in
36+
and asks where the bathroom is. The bar explodes and bursts into
37+
flames."
38+
"""
39+
40+
def test_types_engine(self):
41+
"""Test that correct types are passed to the engine functions.
42+
43+
Tests that the correct types are passed to the engine functions. The
44+
engine functions are only called when the `commit` and `compute`
45+
functions from the wrapper API are called. And actually `compute` calls
46+
commit. Therefore, this test just needs to run a simulation to verify
47+
all calls made to the engine.
48+
49+
Actually this is something that you would do, at least for the static
50+
case, with a type checker such as mypy. However, it is a pretty good
51+
example of an integration test, that is why it is included.
52+
"""
53+
from simphony_osp.namespaces import ontology_namespace as namespace
54+
from simphony_osp.wrappers import SimulationWrapper
55+
56+
# decorate the methods on the engine so that they verify the declared
57+
# type hints
58+
session = SimulationWrapper()
59+
wrapper = session.driver.interface
60+
# read the diagram on the SimPhoNy documentation to understand how the
61+
# wrapper object was retrieved from the session object:
62+
# https://simphony.readthedocs.io/en/v4.0.0rc4/developers/
63+
# wrappers.html#wrapper-abstract-class
64+
session.driver.interface = self._prepare_wrapper_for_types_test(
65+
wrapper
66+
)
67+
68+
# run a simulation, that should raise no validation errors
69+
70+
with session:
71+
atm = namespace.Atom()
72+
pos = namespace.Position(value=[8, 5, 3], unit="m")
73+
vel = namespace.Velocity(value=[1, 2, 3], unit="m/s")
74+
atm[namespace.hasPart] = pos, vel
75+
session.compute(time_steps=5)
76+
77+
@staticmethod
78+
def _prepare_wrapper_for_types_test(
79+
wrapper: SimulationWrapper,
80+
) -> SimulationWrapper:
81+
"""Decorate relevant engine functions with a type-checking decorator.
82+
83+
The type-checking decorator verifies that the types that are passed
84+
to the engine functions match the type hints defined in their
85+
signature.
86+
87+
Actually this is something that you would do, at least for the static
88+
case, with a type checker such as mypy. However, it is a pretty good
89+
example of an integration test, that is why it is included.
90+
"""
91+
# prepare a decorator to validate type hints
92+
class ValidationError(Exception):
93+
"""Specific exception to be used only in this test."""
94+
95+
pass
96+
97+
def verify_hints(method):
98+
"""Makes the decorated function validate type hints.
99+
100+
The method to decorate must be a bound method.
101+
"""
102+
103+
@wraps(method)
104+
def decorated(*args, **kwargs):
105+
hints_kwargs = {argument: None for argument in kwargs}
106+
hints_args = {
107+
argument: None
108+
for argument in getfullargspec(method).args[
109+
1:
110+
] # exclude "self"
111+
if argument not in hints_kwargs
112+
}
113+
hints_return = None
114+
for argument, type_ in get_type_hints(method).items():
115+
if argument == "return":
116+
hints_return = type_
117+
elif argument in hints_kwargs:
118+
hints_kwargs[argument] = type_
119+
else:
120+
hints_args[argument] = type_
121+
122+
exception_text = (
123+
"Expected {argument} to be of {type_}, not {actual}"
124+
)
125+
for i, (argument, type_) in enumerate(hints_args.items()):
126+
if type_ is not None and not isinstance(args[i], type_):
127+
raise ValidationError(
128+
exception_text.format(
129+
argument=argument,
130+
type_=type_,
131+
actual=type(args[i]),
132+
)
133+
)
134+
for argument, type_ in hints_kwargs.items():
135+
if not isinstance(kwargs[argument], type_):
136+
raise ValidationError(
137+
exception_text.format(
138+
argument=argument,
139+
type_=type_,
140+
actual=type(kwargs[argument]),
141+
)
142+
)
143+
return_ = method(*args, **kwargs)
144+
if hints_return is not None and not isinstance(
145+
return_, hints_return
146+
):
147+
raise ValidationError(
148+
exception_text.format(
149+
argument="return",
150+
type_=hints_return,
151+
actual=type(return_),
152+
)
153+
)
154+
return return_
155+
156+
return decorated
157+
158+
# decorate the functions of the engine
159+
engine = wrapper._engine
160+
for engine_method in (
161+
"run",
162+
"add_atom",
163+
"delete_atom",
164+
"update_position",
165+
"update_velocity",
166+
"get_velocity",
167+
"get_position",
168+
):
169+
setattr(
170+
engine,
171+
engine_method,
172+
verify_hints(getattr(engine, engine_method)),
173+
)
174+
175+
return wrapper

0 commit comments

Comments
 (0)