In this tutorial we are going to take a look at implementing a gym wrapper and prove that this wrapper works under specified conditions. The wrapper that we will build is specific for the game Pong and is taken from the Policy Gradient section from Algorithms. So afterwards you can use this wrapper for Pong games or try and adjust it for different games.
There are many different types of test, see tab: test types
for a short overview, and for this tutorial we will build a unittest, which has to be easy to write
, readable
, reliable
and fast
.
We are going to use the Python unittest module and framework, so let us get started.
There are a lot of different tests, for example:
selenium
. it offers web site builders the abaility to simulate customers.And many more:
Writing test cases can have great advantages in production if you have to combine many different types of codes. Also when writing a tests is hard,this is usualy because the code had some issues, such as:
For a full explanation and examples (in C#) of the above mentioned issues, there is this an excellent 20 minute read from Sergey Kolodiy in his blog Unit Tests, How to Write Testable Code and Why it Matters.
As starting point of our wrapper gym
already provides you with a basic wrapper that works for all gym games.
from gym import ObservationWrapper
Remember from chapter 2 that there are some inheritences that require you to implement a specific method? Well this is one of those classes. ObservationWrapper
requires you to implement the following method, or it will raise an error:
def observation(self, observation):
raise NotImplementedError
In order to test that the basic wrapper is working as expected we are going to write a small basic tests for this wrapper. If you are using Pycharm, you can automatically create a unit test file using:
right click -> new -> Python File -> Python unit test
You should get a file that has the following lines:
import unittest
class MyTestCase(unittest.TestCase):
def test_something(self):
self.assertEqual(True, False)
if __name__ == '__main__':
unittest.main()
Now adapt the code and check if the basewrapper works on the game Pong-v0
.
The following steps can be useful:
TestBaseWrapper
, and rename the test_something
to test_run
.test_run
method, implement a basic gym loop for the game Pong-v0
.ObservationWrapper
. (env_wrapped = gym.ObservationWrapper(env)
)Now remember that we have to implement the observation
method. Since we are using Python we can just overload the method by returning our own method. In any case create a new method observation(self, observation)
and point env_wrapped.observation
to our method.
import unittest
import gym
class TestBaseWrapper(unittest.TestCase):
def test_run(self):
env = gym.make('Pong-v0')
env_wrapped = gym.ObservationWrapper(env)
env_wrapped.observation = lambda x: x # Overload observation method, by returning same value.
env_wrapped.reset()
done = False
while not done:
action = env_wrapped.action_space.sample()
obs, reward, done, info = env_wrapped.step(action)
env.close()
if __name__ == '__main__':
unittest.main()
In this code I used a lambda function, which is basically a function that always has to return a value and is great for onliner functions. The lambda function is the same as defining:
def lambda(x):
return x
When running the test you should see something like this
Process finished with exit code 0
Ran 1 test in 3.213s
OK
Note: In Pycharm you can run the test using the green triangle, for terminal usage you can run the file itself python -m 'filename.py'
, or use python -m unittest
.
Unsure about when classes require you to implement anything? Here are a few tricks that you can use to find out:
Also if you are interested in what you can do with this inheritance, take a loog at this talk from Raymond Hettinger: Super considered super!, where he talks about it on the python Conference (PyCon).
For Pong we had to do some preprocessing namely:
[35:195]
)[::2, ::2, 0]
)[obs == 144] = 0
, [obs == 109] = 0
, [obs != 0] = 1
)obs.astype(np.float).ravel()
)Now instead of putting al this code in our agent we are going to take care of all this in our wrapper. For this we will create a new class PongWrapper
that inherits from the Observationwra[[er
and add a method called observation
, that will do all the preprocessing for us. So our PongWrapper
should look something like this:
class PongWrapper(ObservationWrapper):
def observation(self, observation: np.ndarray):
return observation
In case you would have to do multiple steps of processing, it is often better to split the tasks in different methods that are being called from observation
. This makes the testing per method simpler, and also helps to narrow down the cause of unexpected behaviour.
So implement the new methods crop
, down_sample_3d
, erase_value
and normalize
, they should take an observation (image) as input and return the altered image. Now for the difference with the previous image you will have to store the previous image in the class, so it might be usefull to initialize a zero array self._previous_obs
in the __init__
method of the appropiate size.
Now implement the whole preprocessor in this wrapper, or copy our code if you want to move on to the testing part.
def crop(self, img, min_row=5, max_row=10
)We are using staticmethods, to also allow acces outside the PongWrapper
class for those methods. It is not required for this exercise, but a standard improvement that Pycharm will note whenever there is no use of the self
argument. In general it is better to follow pycharms note, since it means that the function will not be copied for every class instance, but receives a single address. In this case it will also make the unit tests much easier, since we do not need to instantiate a class to test staticmethods, and thereby preventing the tightly coupled problem.
For more information about staticmethods
and classmethods
check out Python's Instance, Class, and Static Methods Demystified.
import numpy as np
from gym import ObservationWrapper
class PongWrapper(ObservationWrapper):
def __init__(self, env):
super().__init__(env)
self._previous_obs = np.zeros((80, 80))
def reset(self):
self._previous_obs = np.zeros((80, 80))
obs = self.env.reset()
return self.observation(obs)
def observation(self, obs: np.ndarray):
""" prepro 210x160x3 uint8 frame into 6400 (80x80) 1D float vector """
obs = self.crop(obs)
obs = self.down_sample_3d(obs)
obs = self.erase_value(obs, 144)
obs = self.erase_value(obs, 109)
obs = self.normalize(obs)
obs = obs.astype(np.float).ravel()
new_obs = obs - self._previous_obs
self._previous_obs = obs
return new_obs
@staticmethod
def crop(image, min_row=35, max_row=195):
image = image[min_row:max_row]
return image
@staticmethod
def down_sample_3d(image, sample_row=2, sample_col=2):
""" Take the first value in the 3th axis and sample down. """
image = image[::sample_row, ::sample_col, 0]
return image
@staticmethod
def erase_value(image, value):
image[image == value] = 0
return image
@staticmethod
def normalize(image):
image[image != 0] = 1
return image
In case you would have the tightly coupled problem with a class you can create a fake mock
object, for unit testing, (unittest.mock). But as general advice, whenever you have to mock
a lot of object, you probably want to refactor the code and test every mock
object separately. But we will not go into mock
testing here.
Now that we have our preprocessing in place, we will have to think about what we want to test and how to do that. For example, if we want to check if we get the proper results from the step
and reset
, how would we do this? Running the step
and reset
will require an environment and an action and what are the return values that we expect?
While working on new tests, the old tests should still be fine, so even now you should be able to run the test_run
test without problems on our new Wrapper. So copy the run_test
and use it in PongWrapper
and Check if it runs correctly for our PongWrapper.
After that we will take a look at how we can break up the process testing into separate small individual blocks that we can test.
When you run our code you should get an error namely:
ValueError: operands could not be broadcast together with shapes (6400,) (80,80)
This means that we have wrongly initialized self.previous_obs
shape, which should have been flattened. Changing np.zeros((80, 80))
to np.zeros(6400)
should fix this. Now luckily this error is straightforward, and this is also how your tests should be. Giving an error on a very specific point and telling the user what was different, than expected.
This test should also make you a bit warry of simply copying othermans code, without checking .
Now we are going to split the functionalities in different testing methods. Firstly we are going to take a look at the process
method, then the step
, followed by the reset
.
To test the observation
method we are implementing the test_observation
method in the TestPongWrapper
. For this we have to decide on a good testing image. We know that the final image will be a one dimensional array of 6400 floating values, of which all values that are not 144, or 109 will be either 0 or 1.
So a good image will have at least values that are equal to 144, 109 some arbitrary value different than zero and some zero values. Now we also know that the standard observation shape for Pong is 210x160x3. Now what would be a good test image?
There are many options that will work, but we choose to create a monotonically increasing image.
This will show us which axis we picked in the third dimension and when we crop we know the start values. For the downscaling we expect the difference to be always 2 and we can now predict the 109 and 144 value locations.
[[ 0, 1, 2], [ 1, 2, 3], ..., [159, 160, 161],
[ 1, 2, 3], [ 2, 3, 4], ..., [160, 161, 162],
...
[208, 209, 210], [209, 210, 211], ..., [367, 368, 369]
[209, 210, 211], [210, 211, 212], ..., [368, 369, 370]]
It also helps that the code is fairly straightforward.
test_image = np.array([[[i + j + k for i in range(3)] for j in range(160)] for k in range(210)])
For the location of the test_image
, we would like to reuse it in all the test cases, so we will initialize it in the setUp
method. This method will be invoked before evey test and assures that there is no coupling between the methods and is also a good place to make the gym environment.
def setUp(self) -> None:
""" Gets invoked before every test. """
env = gym.make('Pong-v0')
self.env_wrapped = PongWrapper(env)
self.test_image = np.array([[[i + j + k for i in range(3)] for j in range(160)] for k in range(210)])
(Extra) Side note: there is also a class variant, this method is invoked once when the test class is created and it is called setUpClass
, the attributes that are created here are shared between all tests.
Now after processing the test image with self.env_wrapped.process(self.test_image)
. We have to assert
or check that the dimensions and values are as we expect them to be. For this we can use the self.assertEqual(value 1, value 2, message)
which is part of the unittest.TestCase
class, from which we inherited. Now since we are working with numpy arrays, we need to use the method np.array_equal(array 1, array 2) -> bool
if we want to check if two array are the same.
Since everything is performed at once we can only make assertions (checks) about the shape of the processed observation and the locations of the zeros. This is an indicator that it is better to split the test up into smaller tests, so we can check every part separately and give the exact reason why the test is failing. Let's first assert
the shape and zero locations.
After the assertions we will check the separate methods that are required for process
. Think about what we have to check per method and how we can test it. The tabs will provide you with possible test ideas and example tests. Try to limit yourself to simple tests that get most normal cases, you can always add more tests if necessary.
self.assertEqual((6400,), image.shape, "Image shape not correct")
You might have tried to assert the zeros without performing any preprocessing, but the only way to do it is by manually cropping and sampling the image, followed by taking the 0, 144 and 109 values. Or basically repeating what we are doing in the method. This is not testing anything... so instead of doing that, we are going to check al the smaller methods that make up the big method.
Whenever you note that you cannot test a method properly, because it is relying on a lot of different methods, make sure that the composited methods are correct and use it to proof that the bigger one must be correct as well for the required input.
Another possible technique is to manually verify the positions that you expect. It might be a lot of work to do, but afterwards you can reuse it to test if the method works after refactoring or patches.
In general you want to test the boundary conditions and at least one normal run of your code. This is because if you code breaks it is usually due to some boundary condition that you didn't thought about.
To get most boundary condations we perform 3 tests.
The things that we will assert are the dimensions of the image, and if the values are all the same.
Tip: remember to use np.array_equal
to test if arrays are equal, otherwise you will get an error.
def test_crop(self):
# Test unchanging of image
image = self.env_wrapped.crop(self.test_image, min_row=0, max_row=210)
self.assertEqual((210, 160, 3), image.shape, "Shape mismatch after cropping.")
self.assertEqual(True, np.array_equal(image, self.test_image), "Image not cropped correctly.")
# Test normal cropping of image
image = self.env_wrapped.crop(self.test_image, min_row=35, max_row=195)
self.assertEqual((160, 160, 3), image.shape, "Shape mismatch after cropping.")
expected_image = np.array([[[i + j + k for i in range(3)] for j in range(160)] for k in range(35, 195)])
self.assertEqual(True, np.array_equal(image, expected_image), "Image not cropped correctly.")
# Test situation when max is smaller than minimum
image = self.env_wrapped.crop(self.test_image, min_row=160, max_row=35)
self.assertEqual((0, 160, 3), image.shape)
This is a test that has many possiblities. We can test all the row reductions (210), and all the column reductions (160) separetly, but that would leave us with 33.600 tests. Here we will meet one of the unit tests paradigms, only check some of the cases and write more test when you need it. So when do we have enough? In our case 4 tests are probabaly enough to catch most/all scenarios.
sample_row | sample_col | shape |
---|---|---|
1 | 1 | (210, 160) |
2 | 1 | (105, 160) |
1 | 4 | (210, 40) |
3 | 3 | ( 70, 54) |
For the test we will check the shape and the difference when we subtract the same image shifted 1 row or column. The shift depends on the axis that we are checking. To reason about why this makes sense, think about our input image. It is a monothonically increasing image. So when we skip values in a predefined pattern, such as every 2nd or 3th. The difference between them should be equal to the skipping distance. If you wonder if a different test would be ok for this, you can check it with the education committee.
For this take a look at the np.unique
, np.roll
and .tolist()
methods from numpy.
In total we will make 3 test for every row in the table
def test_down_sampling(self):
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=1, sample_col=1)
self.assertEqual((210, 160), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-1, 209], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-1, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=2, sample_col=1)
self.assertEqual((105, 160), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-2, 208], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-1, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=1, sample_col=4)
self.assertEqual((210, 40), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-1, 209], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-4, 156], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=3, sample_col=3)
self.assertEqual((70, 54), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-3, 207], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-3, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
We check the location of a value (for example 2
s) in the original image and verify that those location do no longer exist anymore after processing it. A useful tool for this is the np.where(image == 2)
. To verify that we are not changing other values we also check that the number of unique values are reduced by 1, between the original and new image.
For this take a look at the np.where
and np.unqiue
methods from numpy. In order to combine the values from np.where
you can use list(zip(*np.where(image == 2)))
to get the (x, y, z)
coordinates of all the twos.
def test_erase_value(self):
# Test that the image contains 2's
two_locations = list(zip(*np.where(self.test_image == 2)))
self.assertEqual([(0, 0, 2), (0, 1, 1), (0, 2, 0), (1, 0, 1), (1, 1, 0), (2, 0, 0)], two_locations,
msg="Two's not in original position.")
# Test that the image contains 371 unique values
self.assertEqual(371, np.unique(self.test_image).size)
# Test that the image no longer contains 2's
image = self.env_wrapped.erase_value(self.test_image, value=2)
self.assertEqual([], list(zip(*np.where(image == 2))), "Erase value not correct.")
# Test that the new image contains one less unique value.
self.assertEqual(370, np.unique(image).size)
For this test we used two different images, the self.test_image
to verify that all values are set to one (except for the [0, 0, 0], which is 0). And a zero image that we epect to stay 0. With that we have handled all possible cases.
def test_normalize(self):
# Test that the image contains all ones
image = self.env_wrapped.normalize(self.test_image)
expected_image = np.ones_like(image)
expected_image[0, 0, 0] = 0
self.assertEqual(True, np.array_equal(image, expected_image), "Normalize ones not correct.")
# Test that the image contains all zeros
image_zero = np.zeros_like(self.test_image)
image = self.env_wrapped.normalize(image_zero)
self.assertEqual(True, np.array_equal(image, image_zero), " Normalize zeros not correct.")
Now for this one, we will be using a new test image,namely an image with only one value. This can be made using:
image = np.ones_like(self.image_test) * value
Now by picking a list of values we can check what the difference between the values should be. The difference between the image is what we will get back as output for the step. A tool that can be used to check, if the return values are all containing the same values is np.unique
.
Now this is the pattern we chose as input:
input = [0, 1, 1, 2, 3, 0, -2, 1]
What is the expected difference outcome?
input = [0, 1, 1, 2, 3, 0, -2, 1]
output = [0, 1, 0, 0, 0, -1, 1, 0]
There are a few tricky things going one, for starters the difference between 1
and 2
image is zero. This is because we normalize everything between 0
and 1
. The second interesting thing is in the 0
and -2
difference value, which is not 0 as you would expect from having values only between 0
and 1
. The reason is that we turn anything not equal zero to one. This also includes negative values.
By performing these unit tests you might find out something unexpected. The thing you have to ask yourself then is, can this occur? And if so would it break the current code? In our case the negative 2 values can not occur. The reason is that all input images are np.uint8
. Which stands for unsigned integers, meaning no negative values (technically only integers between 0 and 255). So we do not need to check that case at all. Therefore we do not have to change the normalize
test to include this possibility of negative values.
Keeping it in or out is up to programmers discresssion. Some might think it unnecessary and would not understand why the value is being tested at all. Others might think it is usefull to know whenever you would change the normalization later on. As a rule of thumb, don't make more tests than you need, since you can always add them later on. For an example of that mindset, see the JMonkeyEngine
test directory, it includes test for specific issues
, that were added later on. Some tests are over 5 years old, while others are only months old.
def test_step(self):
input = [0, 1, 1, 2, 3, 0, -2, 1]
output = [0, 1, 0, 0, 0, -1, 1, 0]
for index, (value, difference) in enumerate(zip(input, output)):
fake_obs = np.ones_like(self.test_image) * value
received_obs = self.env_wrapped.process(fake_obs)
self.assertEqual([difference], np.unique(received_obs).tolist(), f"Difference is not correct {index}")
In here we are going to check if the reset is actively restoring the previous_obs
. One options is to run the simulation for a while and then reset the value, checking that the previous observation is set to zero. This is possible but brings in an outside source, namely the environment. A better way for doing this is inputting your own image and process it. We can repeat the trick from step
and show that the previous_obs
should not be zero while we step. And zero again when we reset.
def test_reset(self):
# Initial starting test
self.env_wrapped.reset()
self.assertEqual(True, np.any(self.env_wrapped.previous_obs), "Previous observation is not reset properly")
# Reset after some steps
for value in range(2, 10):
self.env_wrapped.process(np.ones_like(self.test_image) * value)
self.assertEqual(False, not np.any(self.env_wrapped.previous_obs), "Previous observation is still zero")
self.env_wrapped.reset()
self.assertEqual(True, np.any(self.env_wrapped.previous_obs), "Previous observation is not reset properly")
There are some great arguments for unit testing:
And of course some cons, or arguments to use it sparsely.
import unittest
import gym
import numpy as np
from .wrapper import PongWrapper
class TestPongWrapper(unittest.TestCase):
def setUp(self) -> None:
""" Gets invoked before every test. """
env = gym.make('Pong-v0')
self.env_wrapped = PongWrapper(env)
self.test_image = np.array([[[i + j + k for i in range(3)] for j in range(160)] for k in range(210)])
def test_run(self):
""" Basic test run of a whole gym game. """
self.env_wrapped.reset()
done = False
while not done:
action = self.env_wrapped.action_space.sample()
obs, reward, done, info = self.env_wrapped.step(action)
self.env_wrapped.close()
def test_observation(self):
""" Test the final output image. """
image = self.env_wrapped.observation(self.test_image)
# Assert shape
self.assertEqual((6400,), image.shape, "Image shape not correct")
# Assert zeros
# No, don't do it...
def test_crop(self):
# Test unchanging of image
image = self.env_wrapped.crop(self.test_image, min_row=0, max_row=210)
self.assertEqual((210, 160, 3), image.shape, "Shape mismatch after cropping.")
self.assertEqual(True, np.array_equal(image, self.test_image), "Image not cropped correctly.")
# Test normal cropping of image
image = self.env_wrapped.crop(self.test_image, min_row=35, max_row=195)
self.assertEqual((160, 160, 3), image.shape, "Shape mismatch after cropping.")
expected_image = np.array([[[i + j + k for i in range(3)] for j in range(160)] for k in range(35, 195)])
self.assertEqual(True, np.array_equal(image, expected_image), "Image not cropped correctly.")
# Test situation when max is smaller than minimum
image = self.env_wrapped.crop(self.test_image, min_row=160, max_row=35)
self.assertEqual((0, 160, 3), image.shape)
def test_down_sampling(self):
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=1, sample_col=1)
self.assertEqual((210, 160), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-1, 209], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-1, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=2, sample_col=1)
self.assertEqual((105, 160), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-2, 208], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-1, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=1, sample_col=4)
self.assertEqual((210, 40), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-1, 209], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-4, 156], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
image = self.env_wrapped.down_sample_3d(self.test_image, sample_row=3, sample_col=3)
self.assertEqual((70, 54), image.shape, "Shape mismatch after down sampling.")
self.assertEqual([-3, 207], np.unique(np.roll(image, 1, axis=0) - image).tolist(), "Image row not correct")
self.assertEqual([-3, 159], np.unique(np.roll(image, 1, axis=1) - image).tolist(), "Image column not correct")
def test_erase_value(self):
# Test that the image contains 2's
two_locations = list(zip(*np.where(self.test_image == 2)))
self.assertEqual([(0, 0, 2), (0, 1, 1), (0, 2, 0), (1, 0, 1), (1, 1, 0), (2, 0, 0)], two_locations,
msg="Two's not in original position.")
# Test that the image contains 371 unique values
self.assertEqual(371, np.unique(self.test_image).size)
# Test that the image no longer contains 2's
image = self.env_wrapped.erase_value(self.test_image, value=2)
self.assertEqual([], list(zip(*np.where(image == 2))), "Erase value not correct.")
# Test that the new image contains one less unique value.
self.assertEqual(370, np.unique(image).size)
def test_normalize(self):
# Test that the image contains all ones
image = self.env_wrapped.normalize(self.test_image)
expected_image = np.ones_like(image)
expected_image[0, 0, 0] = 0
self.assertEqual(True, np.array_equal(image, expected_image), "Normalize ones not correct.")
# Test that the image contains all zeros
image_zero = np.zeros_like(self.test_image)
image = self.env_wrapped.normalize(image_zero)
self.assertEqual(True, np.array_equal(image, image_zero), "Normalize zeros not correct.")
def test_step(self):
input = [0, 1, 1, 2, 3, 0, -2, 1]
output = [0, 1, 0, 0, 0, -1, 1, 0]
for index, (value, difference) in enumerate(zip(input, output)):
fake_obs = np.ones_like(self.test_image) * value
received_obs = self.env_wrapped.process(fake_obs)
self.assertEqual([difference], np.unique(received_obs).tolist(), f"Difference is not correct {index}")
def test_reset(self):
# Initial starting test
self.env_wrapped.reset()
self.assertEqual(True, np.any(self.env_wrapped.previous_obs), "Previous observation is not reset properly")
# Reset after some steps
for value in range(2, 10):
self.env_wrapped.observation(np.ones_like(self.test_image) * value)
self.assertEqual(False, not np.any(self.env_wrapped.previous_obs), "Previous observation is still zero")
self.env_wrapped.reset()
self.assertEqual(True, np.any(self.env_wrapped.previous_obs), "Previous observation is not reset properly")
if __name__ == '__main__':
unittest.main()
In this lesson we have taken a look at processing wrappers, which can be used to remove all the processing from the agent to the environment and help you clean up the agent. A disadvantage is that all the processing is now hidden away, and you have to keep that in mind.
Next to building a wrapper we made some tests showing that our wrapper is actually performing the way that we are expecting. This unit testing can be a requirement for some companies, but when you are using unit tests, make sure that they are testing only fragments of code, and are robust. Otherwise you keep writing unittests again and again, instead of actual code.