Skip to content

Testing

If you have structured your code into three sections as suggested in structuring the codebase then testing will be simplest.

Core functionality in rust

Comprehensively testing your functionality directly in Rust gives you the fastest test execution and makes finding any issues easier, as you know that they can only originate in the underlying Rust functions, not in your wrapping, importing or use in Python.

Two levels of tests out-of-the-box

Rust supports two levels of tests and names them "Unit tests" and "Integration tests". I will stick to those terms for this section.

  • Unit tests:
    1. Go directly into the source file with the code they are testing.
    2. You should expect these tests to be more tightly coupled to the implementation detail and to have a higher change frequency for that reason. I find that I start out with many tests like this as I code new internal APIs for other parts of my code to use. Over time I delete many of them if I find that they add more cost than value when refactoring. When I break out a generic function I'll add new ones to help check it works as expected.
    3. Go in a dedicated module (called test by convention) and are annotated to be excluded from final builds:
      #[cfg(test)]
      mod test {
          use super::*;
      
          #[test]
          fn vec_to_string() {
      ...
      
  • Integration tests:
    1. Go in a separate directory tests.
    2. You should expect these tests to be coupled to your public API. Changes to these tests are a clear indication of the type of version number change you should apply.
    3. Changing the internal implementation details of your code base should not require you to change these tests.
    4. Can consist of as many different .rs files as you like.
    5. Need to import your library, just as your users would and each test needs to be annotated as such for the compiler:
      use fizzbuzz::MultiFizzBuzz;
      
      mod vectors {
      
          use super::*;
      
          #[test]
          fn test_small_vec() {
      ...
      

Chapter 11 of the rust book contains more details

rust/fizzbuzz/src/lib.rs - full source with unit tests at the end
//! A Trait implementation of "fizzbuzz" providing a default implementation for all types which can be
//! constructed from `0`,`3`,`5` and support the `%` operator.
//!
//!
//! ## Example usage for single item:
//!
//! ```
//! use fizzbuzz::FizzBuzz;
//! use std::borrow::Cow;
//!
//! let one: Cow<str> = 1.fizzbuzz().into();
//! let three: String = 3.fizzbuzz().into();
//! assert_eq!(&one, "1");
//! assert_eq!(three, "fizz");
//! ```
//!
//! ## Example usage for multiple items:
//!
//! ```
//! use fizzbuzz::MultiFizzBuzz;
//! use rayon::iter::ParallelIterator; // required to `.collect()` the results
//!
//! let one_to_five = vec![1,2,3,4,5];
//! let fizzbuzzed: Vec<String> = one_to_five.fizzbuzz().collect();
//! assert_eq!(fizzbuzzed, vec!["1", "2", "fizz", "4", "buzz"]);
//! ```

use std::borrow::Cow;

use rayon::prelude::*;
static BIG_VECTOR: usize = 300_000; // Size from which parallelisation makes sense

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
/// Represents a valid answer to fizzbuzz and provides conversion to `String` and `Cow<&str>` via `.into()`
pub enum FizzBuzzAnswer {
    Fizz,
    Buzz,
    Fizzbuzz,
    Number(String),
}

impl From<FizzBuzzAnswer> for Cow<'static, str> {
    fn from(answer: FizzBuzzAnswer) -> Self {
        match answer {
            FizzBuzzAnswer::Fizz => "fizz".into(),
            FizzBuzzAnswer::Buzz => "buzz".into(),
            FizzBuzzAnswer::Fizzbuzz => "fizzbuzz".into(),
            FizzBuzzAnswer::Number(n) => n.into(),
        }
    }
}

impl From<FizzBuzzAnswer> for String {
    fn from(answer: FizzBuzzAnswer) -> Self {
        match answer {
            FizzBuzzAnswer::Fizz => "fizz".into(),
            FizzBuzzAnswer::Buzz => "buzz".into(),
            FizzBuzzAnswer::Fizzbuzz => "fizzbuzz".into(),
            FizzBuzzAnswer::Number(n) => n,
        }
    }
}

/// Used to obtain the correct fizzbuzz answer for a given number
///
/// ### Required:
/// - fn fizzbuzz() -> String
pub trait FizzBuzz {
    /// Computes the FizzBuzz value for the implementing type.
    /// Returns a `FizzBuzzAnswer` which provides a structured representation
    /// of the FizzBuzz result.
    ///
    /// - `fizzbuzz` if the number is directly divisible by 5 *and* 3
    /// - `fizz` if the number is directly divisible by 3
    /// - `buzz` if the number is directly divisible by 5
    /// - the number in other cases
    ///
    /// A default implementation is available for any type `<Num>` which supports
    /// - `<Num>::try_from(<u8>)`: `Num` must be able to be constructed from `0`, `3` & `5`.
    /// - `std::fmt::Display`: Allows `Num` to be formatted as a `String`.
    /// - `PartialEq`: Enables comparison operations for `Num`.
    /// - `<&Num>::Rem<Num, Output = Num>`: Allows `&Num % Num`.
    fn fizzbuzz(&self) -> FizzBuzzAnswer;
}

/// Implements the FizzBuzz trait for any type `<T>` which supports `<T>::from(<u8>)`
/// and `<T> % <T>`
impl<Num> FizzBuzz for Num
where
    Num: TryFrom<u8> + std::fmt::Display + PartialEq,
    for<'a> &'a Num: std::ops::Rem<Num, Output = Num>,
{
    fn fizzbuzz(&self) -> FizzBuzzAnswer {
        let three = match <Num>::try_from(3_u8) {
            Ok(three) => three,
            Err(_) => return FizzBuzzAnswer::Number(self.to_string()),
        };
        let five = match <Num>::try_from(5_u8) {
            Ok(five) => five,
            Err(_) => return FizzBuzzAnswer::Number(self.to_string()),
        };
        let zero = match <Num>::try_from(0_u8) {
            Ok(zero) => zero,
            Err(_) => return FizzBuzzAnswer::Number(self.to_string()),
        };
        match (self % three == zero, self % five == zero) {
            (true, true) => FizzBuzzAnswer::Fizzbuzz,
            (true, false) => FizzBuzzAnswer::Fizz,
            (false, true) => FizzBuzzAnswer::Buzz,
            _ => FizzBuzzAnswer::Number(self.to_string()),
        }
    }
}

/// Used to obtain the correct `FizzBuzzAnswer` for a multiple fizzbuzz-able numbers
pub trait MultiFizzBuzz {
    /// Returns an iterator which provides the FizzBuzz values for the elements of the implementing type.
    ///
    /// Note:
    /// - This function **consumes** the input
    /// - The returned iterator is a `rayon::iter::IndexedParallelIterator`
    /// - The Items in the returned iterator will be converted to a requested type
    /// (e.g. `FizzBuzzAnswer`, `String`, `Cow<str>`)
    fn fizzbuzz<Rtn>(self) -> impl IndexedParallelIterator<Item = Rtn>
    where
        Rtn: From<FizzBuzzAnswer> + Send;
}

/// Implements the MultiFizzBuzz trait for any type which can be easily converted into a
/// `rayon::iter::IndexedParallelIterator` over Items which implement `fizzbuzz::FizzBuzz`
///
/// Note:
/// - The returned iterator is _lazy_ - no calculations are performed until you use it
/// - Collecting this iterator requires that `rayon::iter::ParallelIterator` is in scope
/// - This implementation will decide whether it is worth the overhead of spawning multiple parallel threads
impl<Iterable, Num> MultiFizzBuzz for Iterable
where
    Iterable: rayon::iter::IntoParallelIterator<Item = Num>,
    <Iterable as IntoParallelIterator>::Iter: IndexedParallelIterator,
    Num: FizzBuzz,
{
    fn fizzbuzz<Rtn>(self) -> impl IndexedParallelIterator<Item = Rtn>
    where
        Rtn: From<FizzBuzzAnswer> + Send,
    {
        let par_iter = self.into_par_iter();
        let min_len = if par_iter.len() < BIG_VECTOR {
            BIG_VECTOR //Don't parallelise when small
        } else {
            1
        };
        par_iter.with_min_len(min_len).map(|n| n.fizzbuzz().into())
    }
}

#[cfg(test)]
mod test {

    use super::*;

    #[test]
    fn big_vector_is_well_ordered() {
        let input: Vec<_> = (1..BIG_VECTOR + 2).collect();
        let output: Vec<FizzBuzzAnswer> = input.clone().fizzbuzz().collect();
        let mut expected: Vec<FizzBuzzAnswer> = vec![];
        for i in input.iter() {
            expected.push(i.fizzbuzz())
        }
        assert_eq!(output, expected);
    }

    #[test]
    fn fizzbuzz_range() {
        let input = 1..20;
        let mut expected: Vec<FizzBuzzAnswer> = vec![];
        for i in 1..20 {
            expected.push(i.fizzbuzz().into())
        }
        let output: Vec<FizzBuzzAnswer> = input.fizzbuzz().collect();
        assert_eq!(output, expected)
    }
}
rust/fizzbuzz/tests/test_vec.rs (simple example of an integration test) - full source

Code wrapped by pyo3

Given that you have tested the core code functionality well, you don't need to repeat a full suite of functional tests on the wrapped code. Instead you can focus on the wrapping and any type and error handling you implemented.

The pyo3-testing crate

The pyo3-testing crate is designed to make this step simple.

It is currently available as an independent crate until PR pyo3/#4099 lands.

Use it by adding a dependency in rust/fizzbuzzo3/Cargo.toml:

...
[dependencies]
  pyo3-testing = "0.3.1"
...

Testing without the pyo3-testing crate

If you chose to test without the pyo3-testing crate you will need to make use of the guidance in Chapter 3 of the pyo3 book and also be willing to accept random failures due to mistimed interpreter initialisation.

You can take a look at an example case from pyo3-testing to see how I worked around these issues.

Testing the wrapping once for each supported argument type

Use pyo3's capability to embed a python interpreter and call python code from rust to create one test per type to check that you get the right result when you call the resulting python function:

Example from rust/fizzbuzzo3/src.rs:

#[pyo3test]
#[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
fn test_fizzbuzz() {
    let result: String = fizzbuzz!(1i32);
    assert_eq!(result, "1");
}

Testing error cases

Again using pyo3's ability to embed python in rust, check that you get the expected python Exception type from bad input.

Example from rust/fizzbuzzo3/src.rs:

#[pyo3test]
#[allow(unused_macros)]
#[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
fn test_fizzbuzz_string() {
    with_py_raises!(PyTypeError, { fizzbuzz.call1(("4",)) })
}

Only unit tests possible

You can only create tests directly in the source file along side your wrappings. This should be fine, as your integration tests will be run natively by pytest.

If for some reason you did want to create external rust tests you need to change the library type in rust/fizzbuzzo3/Cargo.toml to crate-type = ["cdylib, lib"]

rust/fizzbuzzo3/src/lib.rs - full source (tests at the end)
use std::{borrow::Cow, ops::Neg};

use fizzbuzz::{FizzBuzz, FizzBuzzAnswer, MultiFizzBuzz};
use pyo3::{exceptions::PyValueError, prelude::*, types::PySlice};
use rayon::iter::{IndexedParallelIterator, IntoParallelIterator, ParallelIterator};

#[derive(FromPyObject)]
enum FizzBuzzable {
    Int(isize),
    Float(f64),
    Vec(Vec<isize>),
    Slice(MySlice),
}

#[derive(FromPyObject)]
struct MySlice {
    start: isize,
    stop: isize,
    step: Option<isize>,
}

impl IntoPy<Py<PyAny>> for MySlice {
    fn into_py(self, py: Python<'_>) -> Py<PyAny> {
        PySlice::new_bound(py, self.start, self.stop, self.step.unwrap_or(1)).into_py(py)
    }
}

/// A wrapper struct for FizzBuzzAnswer to provide a custom implementation of `IntoPy`.
enum FizzBuzzReturn {
    One(String),
    Many(Vec<Cow<'static, str>>),
}

impl From<FizzBuzzAnswer> for FizzBuzzReturn {
    fn from(value: FizzBuzzAnswer) -> Self {
        FizzBuzzReturn::One(value.into())
    }
}

impl IntoPy<Py<PyAny>> for FizzBuzzReturn {
    fn into_py(self, py: Python<'_>) -> Py<PyAny> {
        match self {
            FizzBuzzReturn::One(answer) => answer.into_py(py),
            FizzBuzzReturn::Many(answers) => answers.into_py(py),
        }
    }
}

/// Returns the correct fizzbuzz answer for any number or list/range of numbers.
///
/// This is an optimised algorithm compiled in rust. Large lists will utilise multiple CPU cores for processing.
/// Passing a slice, to represent a range, is fastest.
///
/// Arguments:
///     n: the number(s) to fizzbuzz
///
/// Returns:
///     In the case of a single number: a `str` with the correct fizzbuzz answer.
///     In the case of a list or range of inputs: a `list` of `str` with the correct fizzbuzz answers.
///
/// Examples:
///     a single `int`:
///     ```
///     >>> from fizzbuzz.fizzbuzzo3 import fizzbuzz
///     >>> fizzbuzz(1)
///     '1'
///     >>> fizzbuzz(3)
///     'fizz'
///     ```
///     a `list`:
///     ```
///     from fizzbuzz.fizzbuzzo3 import fizzbuzz
///     >>> fizzbuzz([1,3])
///     ['1', 'fizz']
///     ```
///     a `slice` representing a range:
///     ```
///     from fizzbuzz.fizzbuzzo3 import fizzbuzz
///     >>> fizzbuzz(slice(1,4,2))
///     ['1', 'fizz']
///     >>> fizzbuzz(slice(1,4))
///     ['1', '2', 'fizz']
///     >>> fizzbuzz(slice(4,1,-1))
///     ['4', 'fizz', '2']
///     >>> fizzbuzz(slice(1,5,-1))
///     []
///     ```
///     Note: Slices are inclusive on the left, exclusive on the right and can contain an optional step.
///     Note: Slices are inclusive on the left, exclusive on the right and can contain an optional step.
///     Negative steps require start > stop, positive steps require stop > start; other combinations return `[]`.
///     A step of zero is invalid and will raise a `ValueError`.
#[pyfunction]
#[pyo3(name = "fizzbuzz", text_signature = "(n)")]
fn py_fizzbuzz(num: FizzBuzzable) -> PyResult<FizzBuzzReturn> {
    match num {
        FizzBuzzable::Int(n) => Ok(n.fizzbuzz().into()),
        FizzBuzzable::Float(n) => Ok(n.fizzbuzz().into()),
        FizzBuzzable::Vec(v) => Ok(FizzBuzzReturn::Many(v.fizzbuzz().collect())),
        FizzBuzzable::Slice(s) => match s.step {
            // Can only be tested from python: Cannot create a PySlice with no step in rust.
            None => Ok(FizzBuzzReturn::Many((s.start..s.stop).fizzbuzz().collect())), // GRCOV_EXCL_LINE

            Some(1) => Ok(FizzBuzzReturn::Many((s.start..s.stop).fizzbuzz().collect())),

            Some(step) => match step {
                1.. => Ok(FizzBuzzReturn::Many(
                    (s.start..s.stop)
                        .into_par_iter()
                        .step_by(step.try_into().unwrap())
                        .fizzbuzz()
                        .collect(),
                )),

                //  ```python
                //  >>> foo[1:5:0]
                //  Traceback (most recent call last):
                //    File "<stdin>", line 1, in <module>
                //  ValueError: slice step cannot be zero
                //  ```
                0 => Err(PyValueError::new_err("step cannot be zero")),

                //  ```python
                //  >>> foo=[0,1,2,3,4,5,6]
                //  >>> foo[6:0:-2]
                //  [6, 4, 2]
                //  ```
                // Rust doesn't accept step < 0 or stop < start so need some trickery
                ..=-1 => Ok(FizzBuzzReturn::Many(
                    (s.start.neg()..s.stop.neg())
                        .into_par_iter()
                        .step_by(step.neg().try_into().unwrap())
                        .map(|x| x.neg())
                        .fizzbuzz()
                        .collect(),
                )),
            },
        },
    }
}

#[pymodule]
#[pyo3(name = "fizzbuzzo3")]
fn py_fizzbuzzo3(module: &Bound<'_, PyModule>) -> PyResult<()> {
    module.add_function(wrap_pyfunction!(py_fizzbuzz, module)?)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use pyo3::exceptions::{PyTypeError, PyValueError};
    use pyo3_testing::{pyo3test, with_py_raises};

    use super::*;

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz() {
        let result: String = fizzbuzz!(1i32);
        assert_eq!(result, "1");
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_float() {
        let result: String = fizzbuzz!(1f32);
        assert_eq!(result, "1");
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_vec() {
        let input = vec![1, 2, 3, 4, 5];
        let expected = vec![
            "1".to_string(),
            "2".to_string(),
            "fizz".to_string(),
            "4".to_string(),
            "buzz".to_string(),
        ];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[allow(unused_macros)]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_string() {
        with_py_raises!(PyTypeError, { fizzbuzz.call1(("4",)) })
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice() {
        let input = MySlice {
            start: 1,
            stop: 6,
            step: Some(1),
        };
        let expected = vec![
            "1".to_string(),
            "2".to_string(),
            "fizz".to_string(),
            "4".to_string(),
            "buzz".to_string(),
        ];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_no_step() {
        let input = MySlice {
            start: 1,
            stop: 6,
            step: None,
        };
        let expected = vec![
            "1".to_string(),
            "2".to_string(),
            "fizz".to_string(),
            "4".to_string(),
            "buzz".to_string(),
        ];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_step() {
        let input = MySlice {
            start: 1,
            stop: 6,
            step: Some(2),
        };
        let expected = vec!["1".to_string(), "fizz".to_string(), "buzz".to_string()];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_backwards() {
        let input = MySlice {
            start: 5,
            stop: 0,
            step: Some(1),
        };
        let result: Vec<String> = fizzbuzz!(input);
        let expected: Vec<String> = vec![];
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_negative_step() {
        let input = MySlice {
            start: 5,
            stop: 0,
            step: Some(-2),
        };
        let expected = vec!["buzz".to_string(), "fizz".to_string(), "1".to_string()];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_negative_step_boundaries() {
        let input = MySlice {
            start: 5,
            stop: 1,
            step: Some(-1),
        };
        let expected = vec![
            "buzz".to_string(),
            "4".to_string(),
            "fizz".to_string(),
            "2".to_string(),
        ];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }

    #[pyo3test]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_negative_step_boundaries_2() {
        let input = MySlice {
            start: 6,
            stop: 0,
            step: Some(-2),
        };

        let expected = vec!["fizz".to_string(), "4".to_string(), "2".to_string()];
        let result: Vec<String> = fizzbuzz!(input);
        assert_eq!(result, expected);
    }
    #[pyo3test]
    #[allow(unused_macros)]
    #[pyo3import(py_fizzbuzzo3: from fizzbuzzo3 import fizzbuzz)]
    fn test_fizzbuzz_slice_zero_step() {
        let slice: MySlice = py
            .eval_bound("slice(1,2,0)", None, None)
            .unwrap()
            .extract()
            .unwrap();
        with_py_raises!(PyValueError, { fizzbuzz.call1((slice,)) });
    }
}

Integration testing with pytest

Now that you are confident that your functionality is correct and your wrappings work, you can create your final tests in Python to validate that the wrapped functionality imports and really does what you expect when called from python.

pytest vs. unittest

Python offers testing via the unittest module in the standard library. pytest is a central part of the python ecosystem which removes a lot of the boilerplate and provides a lot of additional features. You will find it used in an overwhelmingly large number of libraries.

Testing that you can import your module

Testing the import mechanics is as simple as adding the import statement to the top of your test case.

From tests/test_fizbuzzo3.py:

from fizzbuzz.fizzbuzzo3 import fizzbuzz

If this doesn't work, you'll receive an ModuleNotFoundError when running your tests

Don't forget to recompile your wrapped functions

You'll need to (re)compile wrapped functions after changing them and before testing from python:

(.venv)/projectroot$ pip install -e .

Import and namespace errors

It's easy to end up in a situation where your pytests pass when you run them locally but imports fail at other times. See the warning in Structuring the Codebase for more details.

Avoid this problem by: 1. Following the guidance on project structure 1. Not placing any __init__.py files in your tests directory. Rely on pip install -e . to properly place your packing into globals

Testing functionality for each type

Similar to how you focussed your tests in rust on the wrapped code, you want to also focus here on ensuring you cover all the possible types you can pass to your function. You probably also want to check the functionality a bit more as well, but not to the extent you did in the core rust code.

From tests/test_fizbuzzo3.py:

...
def test_list():
  assert fizzbuzz([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15]) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz"
...

Testing error cases

pytest offers an easy way to check that an Exception is raised using raises:

From tests/test_fizbuzzo3.py:

import pytest
...
def test_string():
with pytest.raises(TypeError):
    fizzbuzz("1")

tests/test_fizzbuzzo3.py - full source:
import pytest
from fizzbuzz.fizzbuzzo3 import fizzbuzz


def test_lazy():
    assert fizzbuzz(1) == "1"
    assert fizzbuzz(2) == "2"
    assert fizzbuzz(3) == "fizz"
    assert fizzbuzz(4) == "4"
    assert fizzbuzz(5) == "buzz"
    assert fizzbuzz(6) == "fizz"
    assert fizzbuzz(15) == "fizzbuzz"


def test_fizzbuzz_empty_list():
    assert fizzbuzz([]) == []


def test_float():
    assert fizzbuzz(1.0) == "1"
    assert fizzbuzz(3.0) == "fizz"


def test_list():
    assert fizzbuzz(
        [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15],
    ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, fizzbuzz".split(", ")


def test_string():
    with pytest.raises(TypeError):
        fizzbuzz("1")


def test_1_to_100():
    results = [fizzbuzz(i) for i in range(1, 101)]
    every_3rd_has_fizz = all("fizz" in r for r in results[2::3])
    assert every_3rd_has_fizz
    every_5th_has_buzz = all("buzz" in r for r in results[4::5])
    assert every_5th_has_buzz
    every_15th_is_fizzbuzz = all(r == "fizzbuzz" for r in results[14::15])
    assert every_15th_is_fizzbuzz
    every_fizz_is_mod3 = all((i + 1) % 3 == 0 for i, r in enumerate(results) if r == "fizz")
    assert every_fizz_is_mod3
    every_buzz_is_mod5 = all((i + 1) % 5 == 0 for i, r in enumerate(results) if r == "buzz")
    assert every_buzz_is_mod5
    every_fizzbuzz_is_mod15 = all((i + 1) % 15 == 0 for i, r in enumerate(results) if r == "fizzbuzz")
    assert every_fizzbuzz_is_mod15
    all_numbers_correct = all(r == str(i + 1) for i, r in enumerate(results) if r not in ("fizz", "buzz", "fizzbuzz"))
    assert all_numbers_correct


def test_slice():
    assert fizzbuzz(
        slice(1, 16, 1),
        ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz".split(", ")


# This case is REALLY IMPORTANT as it cannot be tested via rust unit tests...
def test_slice_no_step():
    assert fizzbuzz(
        slice(1, 16),
        ) == "1, 2, fizz, 4, buzz, fizz, 7, 8, fizz, buzz, 11, fizz, 13, 14, fizzbuzz".split(", ")


def test_slice_negative_step():
    assert fizzbuzz(slice(15, 0, -3)) == "fizzbuzz, fizz, fizz, fizz, fizz".split(", ")


def test_slice_zero_step():
    with pytest.raises(ValueError, match="step cannot be zero"):
        fizzbuzz(slice(1, 16, 0))

Doctests

If you've followed the guidance on type hinting (later) you can also run doctests on the docstrings attached to your wrapped functions.

Add the following to ./pyproject.toml:

[tool.pytest.ini_options]
...
addopts = [
    ...
    "--doctest-modules",
    "--doctest-glob=*.pyi",
    ...
]
testpaths = ["tests", "python"]
...

./pyproject.toml - full source
[project]
    name = "fizzbuzz"
    description = "An implementation of the traditional fizzbuzz kata"
    # readme = "README.md"
    license = { text = "MIT" }
    authors = [{ name = "Mike Foster" }]
    dynamic = ["version"]
    requires-python = ">=3.8"
    classifiers = [
        "Programming Language :: Python :: 3",
        "License :: OSI Approved :: MIT License",
        "Operating System :: OS Independent",
        "Topic :: Software Development :: Libraries :: Python Modules",
        "Intended Audience :: Developers",
        "Natural Language :: English",
    ]

[project.urls]
    # Homepage = ""
    # Documentation = ""
    Repository = "https://github.com/MusicalNinjaDad/fizzbuzz"
    Issues = "https://github.com/MusicalNinjaDad/fizzbuzz/issues"
    # Changelog = ""

[tool.setuptools.dynamic]
    version = { file = "__pyversion__" }


# ===========================
#       Build, package
# ===========================

[build-system]
    requires = ["setuptools", "setuptools-rust"]
    build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
    where = ["python"]

[[tool.setuptools-rust.ext-modules]]
    # The last part of the name (e.g. "_lib") has to match lib.name in Cargo.toml,
    # but you can add a prefix to nest it inside of a Python package.
    target = "fizzbuzz.fizzbuzzo3"
    path = "rust/fizzbuzzo3/Cargo.toml"
    binding = "PyO3"
    features = ["pyo3/extension-module"] # IMPORTANT!!!
    debug = false # Adds `--release` to `pip install -e .` builds, necessary for performance testing

[tool.cibuildwheel]
    skip = [
        "*-musllinux_i686",    # No musllinux-i686 rust compiler available.
        "*-musllinux_ppc64le", # no rust target available.
        "*-musllinux_s390x",   # no rust target available.
    ]
    # Use prebuilt copies of pypa images with rust and just ready compiled (saves up to 25 mins on emulated archs)
    manylinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    manylinux-ppc64le-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_ppc64le-rust"
    manylinux-s390x-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_s390x-rust"
    manylinux-pypy_x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_x86_64-rust"
    manylinux-pypy_i686-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_i686-rust"
    manylinux-pypy_aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/manylinux2014_aarch64-rust"
    musllinux-x86_64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_x86_64-rust"
    musllinux-aarch64-image = "ghcr.io/musicalninjas/quay.io/pypa/musllinux_1_2_aarch64-rust"

    # `test-command` will FAIL if only passed `pytest {package}`
    # as this tries to resolve imports based on source files (where there is no `.so` for rust libraries), not installed wheel
    test-command = "pytest {package}/tests"
    test-extras = "test"

    [tool.cibuildwheel.linux]
        before-all = "just clean"
        archs = "all"

    [tool.cibuildwheel.macos]
        before-all = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && cargo install just && just clean"

    [tool.cibuildwheel.windows]
        before-all = "rustup target add aarch64-pc-windows-msvc i586-pc-windows-msvc i686-pc-windows-msvc x86_64-pc-windows-msvc && cargo install just && just clean"
        before-build = "pip install delvewheel"
        repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"


# ===========================
#  Test, lint, documentation
# ===========================

[project.optional-dependencies]
    stubs = ["pyo3-stubgen"]
    lint = ["ruff"]
    test = ["pytest", "pytest-doctest-mkdocstrings"]
    cov = ["fizzbuzz[test]", "pytest-cov"]
    doc = ["black", "mkdocs", "mkdocstrings[python]", "mkdocs-material", "fizzbuzz[stubs]"]
    dev = ["fizzbuzz[stubs,lint,test,cov,doc]"]

[tool.pytest.ini_options]
    xfail_strict = true
    addopts = [
        "--doctest-modules",
        "--doctest-mdcodeblocks",
        "--doctest-glob=*.pyi",
        "--doctest-glob=*.md",
    ]
    testpaths = ["tests", "python"]

[tool.coverage.run]
    branch = true
    omit = ["tests/*"]
    dynamic_context = "test_function"

[tool.ruff]
    line-length = 120
    format.skip-magic-trailing-comma = false
    format.quote-style = "double"

[tool.ruff.lint]
    select = ["ALL"]
    flake8-pytest-style.fixture-parentheses = false
    flake8-annotations.mypy-init-return = true
    extend-ignore = [
        "E701",   # One-line ifs are not great, one-liners with suppression are worse
        "ANN101", # Type annotation for `self`
        "ANN202", # Return type annotation for private functions
        "ANN401", # Using Any often enough that supressing individually is pointless
        "W291",   # Double space at EOL is linebreak in md-docstring
        "W292",   # Too much typing to add newline at end of each file
        "W293",   # Whitespace only lines are OK for us
        "D401",   # First line of docstring should be in imperative mood ("Returns ..." is OK, for example)
        "D203",   # 1 blank line required before class docstring (No thank you, only after the class docstring)
        "D212",   # Multi-line docstring summary should start at the first line (We start on new line, D209)
        "D215",   # Section underline is over-indented (No underlines)
        "D400",   # First line should end with a period (We allow any punctuation, D415)
        "D406",   # Section name should end with a newline (We use a colon, D416)
        "D407",   # Missing dashed underline after section (No underlines)
        "D408",   # Section underline should be in the line following the section's name (No underlines)
        "D409",   # Section underline should match the length of its name (No underlines)
        "D413",   # Missing blank line after last section (Not required)
    ]

[tool.ruff.lint.per-file-ignores]
    # Additional ignores for tests 
    "**/test_*.py" = [
        "INP001",  # Missing __init__.py
        "ANN",     # Missing type annotations
        "S101",    # Use of `assert`
        "PLR2004", # Magic number comparisons are OK in tests
        "D1",      # Don't REQUIRE docstrings for tests - but they are nice
    ]

    "**/__init__.py" = [
        "D104", # Don't require module docstring in __init__.py
        "F401", # Unused imports are fine: using __init__.py to expose them with implicit __ALL__ 
    ]

Running the tests

Running all rust tests (unit, integration, doc)

/projectroot$ cargo test -p fizzbuzz

or

/projectroot/rust/fizzbuzz$ cargo test

Running all tests on wrapped code

/projectroot$ cargo test -p fizzbuzzo3

or

/projectroot/rust/fizzbuzzo3$ cargo test

Running all python tests

(.venv)/projectroot$ pytest

Don't forget to recompile your wrapped functions

You'll need to (re)compile wrapped functions after changing them and before testing from python:

(.venv)/projectroot$ pip install -e .

Running all tests and lints with just

Just run just check or just reset check to also clean and recompile first

./justfile - full source
# list available recipes
list:
  @just --list --justfile {{justfile()}}

# remove pre-built rust and python libraries (excluding .venv)
clean:
    - cargo clean
    rm -rf .pytest_cache
    rm -rf build
    rm -rf dist
    rm -rf wheelhouse
    rm -rf .ruff_cache
    find . -depth -type d -not -path "./.venv/*" -name "__pycache__" -exec rm -rf "{}" \;
    find . -depth -type d -path "*.egg-info" -exec rm -rf "{}" \;
    find . -type f -name "*.egg" -delete
    find . -type f -name "*.so" -delete

# clean out coverage files
clean-cov:
    find . -type f -name "*.profraw" -delete
    rm -rf pycov
    rm -rf rustcov

# clean, remove existing .venv and rebuild the venv with pip install -e .[dev]
reset: clean clean-cov
    rm -rf .venv
    python -m venv .venv
    .venv/bin/python -m pip install --upgrade pip 
    .venv/bin/pip install -e .[dev]

# lint rust files with fmt & clippy
lint-rust:
  - cargo fmt --check
  - cargo clippy --workspace

# test rust workspace
test-rust:
  - cargo test --quiet --workspace

# lint and test rust
check-rust: lint-rust test-rust

# lint python with ruff
lint-python:
  - .venv/bin/ruff check .

# test python
test-python:
  - .venv/bin/pytest

# lint and test python
check-python: lint-python test-python

# lint and test both rust and python
check: check-rust check-python

#run coverage analysis on rust & python code
cov:
  RUSTFLAGS="-Cinstrument-coverage" cargo build
  RUSTFLAGS="-Cinstrument-coverage" LLVM_PROFILE_FILE="tests-%p-%m.profraw" cargo test
  -mkdir rustcov
  grcov . -s . --binary-path ./target/debug/ -t html,lcov --branch --ignore-not-existing --excl-line GRCOV_EXCL_LINE --output-path ./rustcov
  pytest --cov --cov-report html:pycov --cov-report term

# serve rust coverage results on localhost:8002 (doesn't run coverage analysis)
rust-cov:
  python -m http.server -d ./rustcov/html 8002

# serve python coverage results on localhost:8003 (doesn't run coverage analysis)
py-cov:
  python -m http.server -d ./pycov 8003

# serve python docs on localhost:8000
py-docs:
  mkdocs serve

#build and serve rust API docs on localhost:8001
rust-docs:
  cargo doc
  python -m http.server -d target/doc 8001

# build and test a wheel (a suitable venv must already by active!)
test-wheel: clean
  cibuildwheel --only cp312-manylinux_x86_64

TODO - CI