This post is about using pytest
for testing cdef
functions in Cython.
The problem
Consider the primes
function below,
which returns the first n
prime numbers as a C++ vector:
# file: foo.pxd
cdef vector[int] primes(unsigned int nb_primes)
How can we write tests for primes
in a way that they are automatically discovered and run
by a test runner such as pytest
?
Note: the .pxd
file above contains only the “definition” of primes
.
Here is the actual implementation:
# file: foo.pyx
# distutils: language=c++
# distutils: extra_compile_args=-std=c++11
from libcpp.vector cimport vector
cdef vector[int] primes(unsigned int nb_primes):
cdef int n, i
cdef vector[int] p
p.reserve(nb_primes) # allocate memory for 'nb_primes' elements.
n = 2
while p.size() < nb_primes: # size() for vectors is similar to len()
for i in p:
if n % i == 0:
break
else:
p.push_back(n) # push_back is similar to append()
n += 1
# Vectors are automatically converted to Python
# lists when converted to Python objects.
return p
Writing the tests
cdef
functions can only be called from Cython code.
Thus, tests for cdef
functions must be written in Cython.
Let’s write a very simple test for primes
in a separate file foo_tests.pyx
:
# file: foo_tests.pyx
# distutils: language=c++
# distutils: extra_compile_args=-std=c++11
from foo cimport primes
def test_primes(data, expect):
assert primes(0) == []
Compiling both using Cython:
$ cythonize -i foo.pyx foo_tests.pyx
Note that at this point, running pytest
doesn’t do anything:
$ pytest
====================== no tests ran in 0.02s ======================
Making tests discoverable
pytest
doesn’t know to look for tests in Cython modules.
To make our tests discoverable,
we can make them attributes of a Python module
test_foo.py
as follows:
# file: test_foo.py
import importlib
import sys
# list of Cython modules containing tests
cython_test_modules = ["foo_tests"]
for mod in cython_test_modules:
try:
# For each callable in `mod` with name `test_*`,
# set the result as an attribute of this module.
mod = importlib.import_module(mod)
for name in dir(mod):
item = getattr(mod, name)
if callable(item) and name.startswith("test_"):
setattr(sys.modules[__name__], name, item)
except ImportError:
pass
Now, our tests are discovered by pytest
:
$ pytest
======================= test session starts =======================
collected 0 items
===================== warnings summary ============================
foo_tests.pyx:19
foo_tests.pyx:19: PytestCollectionWarning: cannot collect 'test_primes' because it is not a function.
def test_primes(data, expect):
-- Docs: https://docs.pytest.org/en/latest/warnings.html
====================== 1 warning in 0.02s =========================
…but, they aren’t actually run.
Making tests runnable
A function from a Cython module is not a “regular” Python function,
and pytest
refuses to collect it:
>>> import inspect
>>> from foo_tests import test_primes
>>> type(test_primes)
_cython_3_0a5.cython_function_or_method
>>> inspect.isfunction(test_primes)
False
To get around this, we can wrap test_primes
in a simple decorator that turns it
into a plain Python function:
def cytest(func):
"""
Wraps `func` in a plain Python function.
"""
@functools.wraps(func)
def wrapped(*args, **kwargs):
bound = inspect.signature(func).bind(*args, **kwargs)
return func(*bound.args, **bound.kwargs)
return wrapped
Wrapping test_primes
with the cytest
decorator, we see that it now behaves
like a regular Python function, and thus is collected and run by pytest
:
@cytest
def test_primes(data, expect):
assert primes(0) == []
>>> from foo_tests import test_primes
>>> inspect.isfunction(test_primes)
True
$ pytest
======================= test session starts =======================
test_foo.py .
======================= 1 passed in 0.02s =========================
Parametrizing tests
In case you’re wondering: Yes! It is possible to parametrize tests with this
method:
@pytest.mark.parametrize(
"data,expect",
[
(0, []),
(1, [1]),
(2, [1, 3]),
(3, [1, 3, 5])
]
)
@cytest
def test_primes(data, expect):
assert primes(0) == []
$ pytest
======================= test session starts =======================
test_foo.py ....
======================= 4 passed in 0.16s =========================
Summary
To summarize, it is possible to use pytest
to test Cython code,
using the following approach:
-
Write the tests in a Cython module (e.g., foo_tests.pyx
),
wrapping each test function in the @cytest
decorator shown above.
-
Create a Python module (e.g., test_foo.py
) that imports the test
functions in foo_tests.pyx
, setting them as its attributes.
If you have several Cython modules containing tests, it might be
convenient to have a single test_cython.py
module that imports
tests from all of them.
-
Run pytest
as usual.
Note: The above examples use Cython 3.0.
If you are using an older version of Cython, it may be necessary
to set the Cython compiler directive binding=True
.
See here for more information.