3: Readers and fixtures#
This lesson explains how to use and test a plugin’s reader function, built-in fixtures, custom fixtures, and enclosed testing.
Other lessons in this tutorial:#
3: This lesson (Readers and fixtures)
Resource links: Testing resources
This lesson covers:#
Resources#
The example plugin and all the tests discussed in this lesson are available in this GitHub repository.
Introduction#
In this lesson, we discuss a napari plugin called plugin_tests, generated using the napari plugin template, which has a reader and a widget. The reader is the template NumPy .npy
file reader, napari_get_reader
. It checks whether a path ends in .npy
. If it doesn’t, it returns None
, and if it does, it returns the reader_function
, which loads the data.
Reader#
The napari_get_reader
function is the first thing to test. In the top-level directory under src
, we have the plugin_tests
module. Inside plugin_tests
is the _tests
directory. This is a typical structure when writing tests. There is also a test_reader.py
file, which is empty. We will populate it with tests.
We are focused on testing the napari_get_reader
function. Sometimes it returns None
; sometimes it returns the reader_function
. We want to ensure that if we pass in a path that ends with .npy
, it gives us back a function we can call. We import numpy
and napari_get_reader
. numpy
will be used later.
Tip
Test as much as possible and focus on writing small tests that look at one indivisible unit. Give tests meaningful names that describe what they’re doing.
Built-in fixtures#
We use tmp_path to manage the writing and reading of files during test execution. tmp_path
is a pytest
fixture; it is not imported, it comes with pytest
. We pass tmp_path
as a parameter to our test function, and pytest
will inject it when the tests run. tmp_path
provides a temporary path used to save and manipulate files. Temporary paths, files, and directories created in this way during the testing process are automatically removed by pytest
when the tests are completed.
We create a file to read. First, we’ll build a file path, then generate a small amount of data. We’re just testing to see if the function returns a callable
as expected, so we don’t need a large array.
There are no specific requirements for the contents of the array in this case. We just need some sort of file to save to this temporary directory. The test file will not appear anywhere unless there is a pause during test execution.
Using napari_get_reader
with this path, we assert that the reader is callable. A function should be returned. If it isn’t, we could put an error message here.
# tmp_path is a pytest fixture
def test_get_reader_returns_callable(tmp_path):
"""Calling get_reader on numpy file returns callable"""
# write some fake data
my_test_file = str(tmp_path / "myfile.npy")
original_data = np.random.rand(20, 20)
np.save(my_test_file, original_data)
# try to read it back in
reader = napari_get_reader(my_test_file)
assert callable(reader)
Running the command pytest .
in the root directory of the plugin, we discover all the functions recognized as tests. It should recognize test_reader.py
because it’s a test file, prefixed with the word test. test_reader.py
was found and passed the test.
If the file did not end in .npy
the test would fail because what was returned wasn’t callable. This code has been modified to produce an error:
# tmp_path is a pytest fixture
def test_get_reader_returns_callable(tmp_path):
"""Calling get_reader on numpy file returns callable"""
# write some fake data
my_test_file = str(tmp_path / "myfile.np") # note ends in .np
original_data = np.random.rand(20, 20)
np.save(my_test_file, original_data)
# try to read it back in
reader = napari_get_reader(my_test_file)
assert callable(reader)
Once we run pytest
we can see that it traced back that the callable of reader
is False
and it has filled in the fact that reader
at the time of the assertion was None
. This is useful in debugging.
Custom fixtures and round-trip tests#
Next, we test to see if this function reads the data. This is a round-trip test. We will create a fixture to write the data to make things easier for ourselves. This fixture will be called test_reader_round_trip.
Whatever is returned out of a @pytest.fixture
decorated function is passed as an argument with the name of the fixture, to the test. We are going to call this pytest.fixture
decorated function write_im_to_file
. We’re going to give this fixture the tmp_path
fixture - fixtures can use fixtures!
write_im_to_file
returns a function we will call write_func
that we can pass a path to and have it write the numpy file to that path. write_func
is defined inside write_im_to_file
because it’s not needed anywhere else. When we use write_im_to_file
inside this test it will run write_func
.
We will have access to what write_func
returns once it’s been called inside the test. It returns both the path where the data has been written and the original data.
The benefit of creating this fixture is that whenever we want to write our own test data we don’t have to copy three lines of code, we can just use the fixture. This is useful in testing data with different structures like integers or a specific layer type. Those arguments could be passed to further customize your fixture.
We still want to make sure we get a reader when we call napari_get_reader
with the file. We call that reader
function with the file we created to see if it returns what we expect. Based on the reader spec, it should return a layer data list. Here is the full test, with the fixture:
@pytest.fixture
def write_im_to_file(tmp_path):
def write_func(filename):
my_test_file = str(tmp_path / filename)
original_data = np.random.rand(20, 20)
np.save(my_test_file, original_data)
return my_test_file, original_data
return write_func
def test_reader_round_trip(write_im_to_file):
my_test_file, original_data = write_im_to_file("myfile.npy")
reader = napari_get_reader(my_test_file)
assert callable(reader)
layer_data_list = reader(my_test_file)
assert isinstance(layer_data_list, List) and len(layer_data_list) > 0
layer_data_tuple = layer_data_list[0]
layer_data = layer_data_tuple[0]
np.testing.assert_allclose(layer_data, original_data)
We’re going to assert a list length greater than zero. There must be a layer in there; otherwise, we didn’t read it correctly. We also assert that it is a list.
We will test that inside that list is what we expected - layer data tuples. The first item of a layer data tuple is the actual data. We’re going to test that explicitly.
Then we assert, using numpy
’s asserting mechanism, np.testing.assert_allclose
that they are all close, even though they should be exactly the same. This is standard practice when working with floating point precision. NumPy also has other assertion options you may find useful. The layer data we read back with the reader function should be the same as the original data. If that’s true, then we made the entire round trip. We saved the file and we used the reader to read the file.
def test_reader_round_trip(write_im_to_file):
my_test_file, original_data = write_im_to_file("myfile.npy")
reader = napari_get_reader(my_test_file)
assert callable(reader)
layer_data_list = reader(my_test_file)
assert isinstance(layer_data_list, List) and len(layer_data_list) > 0
layer_data_tuple = layer_data_list[0]
layer_data = layer_data_tuple[0]
np.testing.assert_allclose(layer_data, original_data)
We run our tests again, and now two are collected, both passing.
Enclosed testing#
Note that although we’re testing a napari
plugin, we did not need a viewer or napari to test this. It’s important that we didn’t need those because napari and the napari viewer are out of our control. What we can control is the code we wrote. We wrote that data by simply mocking up an array and getting a temporary path to it. We could thoroughly test our functions in an enclosed way without relying on other people’s code or mocking up many complicated objects.
The next lesson in this series on testing is Test coverage.