Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Fixtures and configurations shared by the entire test suite."""

import logging
import re
from glob import glob

import pytest
Expand Down Expand Up @@ -42,3 +43,28 @@ def setup_logging(tmp_path):
logger_name="movement",
log_directory=(tmp_path / ".movement"),
)


def check_error_message(exception_info, expected_pattern):
"""Check that an exception's error message matches the expected pattern.

Parameters
----------
exception_info : ExceptionInfo
The ExceptionInfo object obtained from pytest.raises context manager.
expected_pattern : str
A regex pattern that should match the error message.

Returns
-------
bool
True if the error message matches the pattern, False otherwise.

"""
return re.search(expected_pattern, str(exception_info.value)) is not None


@pytest.fixture
def check_error():
"""Fixture that provides the check_error_message function."""
return check_error_message
143 changes: 143 additions & 0 deletions tests/test_unit/test_error_message_testing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
"""Demonstrates best practices for testing error messages in pytest.

This module serves as a guide and example for how to properly test
error messages across the movement codebase.
"""

import re
from contextlib import nullcontext as does_not_raise

import pytest


def example_function_with_error(value):
"""Example function that raises an error with a specific message."""
if value < 0:
raise ValueError("Value must be greater than or equal to zero.")
if value > 100:
raise ValueError("Value must be less than or equal to 100.")
if not isinstance(value, int):
raise TypeError(f"Expected int, got {type(value).__name__}.")
return value


class TestErrorMessages:
"""Demonstrates various approaches to testing error messages."""

@pytest.mark.parametrize(
"value, expected_exception, expected_message",
[
# Happy path - no error
(50, does_not_raise(), None),
# Testing for specific error messages
(-10, pytest.raises(ValueError), "greater than or equal to zero"),
(110, pytest.raises(ValueError), "less than or equal to 100"),
(1.5, pytest.raises(TypeError), "Expected int, got float"),
],
)
def test_using_match_parameter(
self, value, expected_exception, expected_message
):
"""Method 1: Using the match parameter with pytest.raises.

This approach directly uses the match parameter which expects a regex pattern.
"""
if expected_message:
with expected_exception as excinfo:
example_function_with_error(value)
# Additional validation if needed
assert expected_message in str(excinfo.value)
else:
with expected_exception:
example_function_with_error(value)

@pytest.mark.parametrize(
"value, expected_exception, expected_message",
[
# Happy path - no error
(50, does_not_raise(), None),
# Testing for specific error messages
(-10, pytest.raises(ValueError), "greater than or equal to zero"),
(110, pytest.raises(ValueError), "less than or equal to 100"),
(1.5, pytest.raises(TypeError), "Expected int, got float"),
],
)
def test_using_match_parameter_inline(
self, value, expected_exception, expected_message
):
"""Method 2: Using the match parameter directly in pytest.raises.

This approach combines the context manager creation with pattern matching.
"""
if expected_message:
with pytest.raises(
expected_exception.expected_exception, match=expected_message
):
example_function_with_error(value)
else:
with expected_exception:
example_function_with_error(value)

@pytest.mark.parametrize(
"value, expected_exception, expected_message",
[
# Happy path - no error
(50, does_not_raise(), None),
# Testing for specific error messages
(-10, pytest.raises(ValueError), "greater than or equal to zero"),
(110, pytest.raises(ValueError), "less than or equal to 100"),
(1.5, pytest.raises(TypeError), "Expected int, got float"),
],
)
def test_using_helper_function(
self, value, expected_exception, expected_message, check_error
):
"""Method 3: Using a helper function from conftest.py.

This approach uses a shared helper function to check error messages,
promoting consistency across the test suite.
"""
if expected_message:
with expected_exception as excinfo:
example_function_with_error(value)
assert check_error(excinfo, expected_message)
else:
with expected_exception:
example_function_with_error(value)

def test_with_exact_message_using_re_escape(self):
"""Method 4: When exact message matching is needed.

Use re.escape when you need to match the entire message exactly,
including any special regex characters.
"""
exact_message = "Value must be greater than or equal to zero."
with pytest.raises(ValueError, match=re.escape(exact_message)):
example_function_with_error(-10)


def test_recommended_approach():
"""The recommended approach for testing error messages in movement.

RECOMMENDED APPROACH:
For most cases, use pytest.raises with the match parameter.
This is the simplest and most direct method.
"""
# For simple substring matching
with pytest.raises(ValueError, match="greater than or equal to zero"):
example_function_with_error(-10)

# For exact message matching
exact_message = "Value must be greater than or equal to zero."
with pytest.raises(ValueError, match=re.escape(exact_message)):
example_function_with_error(-10)

# For regex pattern matching
with pytest.raises(TypeError, match=r"Expected int, got \w+\."):
example_function_with_error(1.5)

# When testing complex cases with multiple assertions
with pytest.raises(ValueError) as excinfo:
example_function_with_error(-10)
assert "greater than" in str(excinfo.value)
assert "zero" in str(excinfo.value)
49 changes: 38 additions & 11 deletions tests/test_unit/test_filtering.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from contextlib import nullcontext as does_not_raise

import pytest
Expand Down Expand Up @@ -71,26 +72,52 @@ def test_filter_with_nans_on_position(
)

@pytest.mark.parametrize(
"override_kwargs, expected_exception",
"override_kwargs, expected_exception, expected_match",
[
({"mode": "nearest", "print_report": True}, does_not_raise()),
({"axis": 1}, pytest.raises(ValueError)),
({"mode": "nearest", "axis": 1}, pytest.raises(ValueError)),
(
{"mode": "nearest", "print_report": True},
does_not_raise(),
None,
),
(
{"axis": 1},
pytest.raises(ValueError),
"keyword argument.*axis.*may not be overridden",
),
(
{"mode": "nearest", "axis": 1},
pytest.raises(ValueError),
"keyword argument.*axis.*may not be overridden",
),
],
)
def test_savgol_filter_kwargs_override(
self, valid_dataset, override_kwargs, expected_exception, request
self,
valid_dataset,
override_kwargs,
expected_exception,
expected_match,
request,
):
"""Test that overriding keyword arguments in the
Savitzky-Golay filter works, except for the ``axis`` argument,
which should raise a ValueError.
"""
with expected_exception:
savgol_filter(
request.getfixturevalue(valid_dataset).position,
window=3,
**override_kwargs,
)
if expected_match:
with expected_exception as excinfo:
savgol_filter(
request.getfixturevalue(valid_dataset).position,
window=3,
**override_kwargs,
)
assert re.search(expected_match, str(excinfo.value)) is not None
else:
with expected_exception:
savgol_filter(
request.getfixturevalue(valid_dataset).position,
window=3,
**override_kwargs,
)

@pytest.mark.parametrize(
"statistic, expected_exception",
Expand Down
10 changes: 5 additions & 5 deletions tests/test_unit/test_transforms.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
from typing import Any

import numpy as np
Expand Down Expand Up @@ -206,9 +207,8 @@ def test_scale_value_error(
expected_error_message: str,
):
"""Test invalid factors raise correct error type and message."""
with pytest.raises(ValueError) as error:
with pytest.raises(ValueError, match=re.escape(expected_error_message)):
scale(sample_data_2d, factor=invalid_factor)
assert str(error.value) == expected_error_message


@pytest.mark.parametrize(
Expand Down Expand Up @@ -236,8 +236,8 @@ def test_scale_invalid_3d_space(factor):
nparray_0_to_23().reshape(8, 3),
coords=invalid_coords,
)
with pytest.raises(ValueError) as error:
scale(invalid_sample_data_3d, factor=factor)
assert str(error.value) == (
expected_error_message = (
"Input data must contain ['z'] in the 'space' coordinates.\n"
)
with pytest.raises(ValueError, match=re.escape(expected_error_message)):
scale(invalid_sample_data_3d, factor=factor)