Testing Quick Reference#

Quick reference guide and cheat sheet for testing in geomstats.

Quick Decision Tree#

What kind of test do I need?

Are you fixing a bug or adding a small feature?
└─→ YES → Write a simple test (see Simple Tests)
└─→ NO ↓

Are you adding a new geometric space with standard operations?
└─→ YES → Use the three-layer architecture (see Advanced Tests)
└─→ NO ↓

Are you testing vectorization/batch operations?
└─→ YES → Use the three-layer architecture
└─→ NO → Write a simple test

Running Tests#

# Run specific test
pytest tests/tests_geomstats/test_geometry/test_my_space.py::test_my_function

# Run all tests in a file
pytest tests/tests_geomstats/test_geometry/test_my_space.py

# Run all geometry tests
pytest tests/tests_geomstats/test_geometry/

# Run only smoke tests (fast)
pytest -m smoke

# Run with verbose output
pytest -v tests/tests_geomstats/

# Run with specific backend
GEOMSTATS_BACKEND=pytorch pytest tests/

# Run with coverage
pytest --cov=geomstats tests/

# Run and show print statements
pytest -s tests/tests_geomstats/test_geometry/test_my_space.py

Simple Tests Template#

When: Bug fixes, edge cases, simple features

Where: tests/tests_geomstats/test_geometry/test_my_space.py

import pytest
import geomstats.backend as gs
from geomstats.geometry.my_space import MySpace

def test_my_simple_function():
    """Test description."""
    space = MySpace(dim=3)
    point = gs.array([1.0, 2.0, 3.0])

    result = space.my_function(point)
    expected = gs.array([2.0, 4.0, 6.0])

    gs.testing.assert_allclose(result, expected)


@pytest.mark.parametrize("dim", [2, 3, 5, 10])
def test_identity_shape(dim):
    """Test with multiple parameters."""
    space = MySpace(dim=dim)
    assert space.identity.shape == (dim, dim)


@pytest.mark.smoke
def test_smoke_test():
    """Fast sanity check."""
    space = MySpace()
    assert space.dim > 0

Assertions Cheat Sheet#

# Numerical comparison (preferred for arrays)
gs.testing.assert_allclose(result, expected, atol=1e-6, rtol=1e-5)

# Exact equality check
assert gs.all(result == expected)

# Boolean checks
assert result is True
assert result is False

# Shape checks
assert result.shape == (3, 3)
assert result.ndim == 2

# Type checks
assert isinstance(result, gs.ndarray)

# Exceptions
with pytest.raises(ValueError):
    space.invalid_operation()

# Approximate scalar
assert abs(result - expected) < 1e-6

Common Test Markers#

@pytest.mark.smoke          # Fast, basic test
@pytest.mark.random         # Uses random data
@pytest.mark.slow           # Expensive test
@pytest.mark.vec            # Vectorization test
@pytest.mark.mathprop       # Mathematical property
@pytest.mark.skip(reason="...") # Skip this test
@pytest.mark.xfail(reason="...") # Expected failure

# Backend-specific (from geomstats.test.test_case)
@np_only                    # NumPy only
@torch_only                 # PyTorch only
@autograd_only              # Autograd only

Testing Patterns#

Test a Mathematical Property#

@pytest.mark.mathprop
def test_distance_is_symmetric():
    """Test d(a,b) == d(b,a)."""
    space = MySpace()
    point_a = space.random_point()
    point_b = space.random_point()

    dist_ab = space.distance(point_a, point_b)
    dist_ba = space.distance(point_b, point_a)

    gs.testing.assert_allclose(dist_ab, dist_ba)

Test Inverse Operations#

def test_exp_log_inverse():
    """Test exp(log(x)) == x."""
    space = MySpace()
    base_point = space.random_point()
    point = space.random_point()

    log_point = space.log(point, base_point)
    exp_log_point = space.exp(log_point, base_point)

    gs.testing.assert_allclose(exp_log_point, point, atol=1e-6)

Test Edge Cases#

def test_distance_to_self_is_zero():
    """Distance from point to itself is zero."""
    space = MySpace()
    point = space.random_point()

    distance = space.distance(point, point)

    gs.testing.assert_allclose(distance, 0.0, atol=1e-10)

Test with Random Points#

@pytest.mark.random
def test_random_point_belongs():
    """Random points should belong to the space."""
    space = MySpace()

    for _ in range(10):
        point = space.random_point()
        assert space.belongs(point)

Three-Layer Architecture Quick Reference#

File Structure#

tests/tests_geomstats/test_geometry/
├── test_my_space.py           # Layer 3: Concrete tests
└── data/
    └── my_space.py             # Layer 1: Test data

geomstats/test_cases/geometry/
└── my_space.py                 # Layer 2: Test cases

Layer 1: Test Data Template#

File: tests/tests_geomstats/test_geometry/data/my_space.py

from geomstats.test.data import TestData

class MySpaceTestData(TestData):
    """Test data for MySpace."""

    # Configuration
    trials = 3
    tolerances = {
        "my_method": {"atol": 1e-5},
    }
    skips = ("optional_method",)

    def my_method_test_data(self):
        """Test data for my_method."""
        data = [
            dict(input_val=x, expected=y),
            dict(input_val=x2, expected=y2),
        ]
        return self.generate_tests(data)

    def my_method_vec_test_data(self):
        """Vectorization test data."""
        return self.generate_vec_data()

    def my_random_test_test_data(self):
        """Random test data."""
        return self.generate_random_data()

Layer 2: Test Case Template#

File: geomstats/test_cases/geometry/my_space.py

from geomstats.test_cases.geometry.base import OpenSetTestCase

class MySpaceTestCase(OpenSetTestCase):
    """Test case for MySpace."""

    def test_my_method(self, input_val, expected, atol):
        """Test my_method."""
        result = self.space.my_method(input_val)
        self.assertAllClose(result, expected, atol=atol)

    @pytest.mark.vec
    def test_my_method_vec(self, n_reps, atol):
        """Vectorization test."""
        input_val = self._get_random_input()
        expected = self.space.my_method(input_val)

        vec_data = generate_vectorization_data(
            data=[dict(input_val=input_val, expected=expected, atol=atol)],
            arg_names=["input_val"],
            expected_name="expected",
            n_reps=n_reps,
        )
        self._test_vectorization(vec_data)

    @pytest.mark.random
    def test_my_random_test(self, n_points, atol):
        """Random test."""
        points = self.space.random_point(n_points)
        # Test logic...

Layer 3: Concrete Test Template#

File: tests/tests_geomstats/test_geometry/test_my_space.py

import pytest
from geomstats.geometry.my_space import MySpace
from geomstats.test.parametrizers import DataBasedParametrizer
from geomstats.test_cases.geometry.my_space import MySpaceTestCase
from .data.my_space import MySpaceTestData

@pytest.mark.smoke
class TestMySpace(MySpaceTestCase, metaclass=DataBasedParametrizer):
    """Concrete tests for MySpace."""
    space = MySpace(dim=3)
    testing_data = MySpaceTestData()

Common TestData Methods#

# Basic test generation
self.generate_tests(data)

# Random tests
self.generate_random_data()

# Vectorization tests
self.generate_vec_data()
self.generate_vec_data(vec_type="sym")      # All combinations
self.generate_vec_data(vec_type="basic")    # Basic vectorization
self.generate_vec_data(vec_type="repeat-0-2")  # Specific args

# Shape tests
self.generate_shape_data()

Test Case Base Classes#

Choose the appropriate base class:

from geomstats.test_cases.geometry.base import (
    TestCase,              # Base for all tests
    OpenSetTestCase,       # For open sets
    LevelSetTestCase,      # For level sets (manifolds)
)

from geomstats.test_cases.geometry.lie_group import (
    LieGroupTestCase,      # For Lie groups
    MatrixLieGroupTestCase,  # For matrix Lie groups
)

from geomstats.test_cases.geometry.manifold import (
    ManifoldTestCase,      # For general manifolds
)

from geomstats.test_cases.geometry.riemannian_metric import (
    RiemannianMetricTestCase,  # For metrics
)

Debugging Failed Tests#

Read the Error Message#

FAILED test_my_space.py::TestMySpace::test_belongs[point0]

# [point0] tells you which parameter set failed

Run Specific Parameter Set#

# Run just the failing parameter
pytest test_my_space.py::TestMySpace::test_belongs[point0]

# Show more details
pytest -vv test_my_space.py::TestMySpace::test_belongs[point0]

Add Print Statements#

def test_my_method(self, input_val, expected, atol):
    print(f"Testing with input: {input_val}")
    print(f"Expected: {expected}")
    result = self.space.my_method(input_val)
    print(f"Got: {result}")
    self.assertAllClose(result, expected, atol=atol)

Run with:

pytest -s test_my_space.py::TestMySpace::test_my_method

Check Test Discovery#

# See all tests that will run
pytest --collect-only tests/tests_geomstats/test_geometry/test_my_space.py

Common Errors#

Method Name Mismatch#

# Error: test_belongs exists but belongs_test_data is missing
# Fix: Add belongs_test_data() method to your TestData class

Parameter Name Mismatch#

# Error: test_belongs(point, expected) but data has {"pt": ..., "exp": ...}
# Fix: Match parameter names exactly

# Test method:
def test_belongs(self, point, expected, atol):
    pass

# Data method:
def belongs_test_data(self):
    return [dict(point=p, expected=e)]  # Names must match!

Missing self.space#

# Error: AttributeError: 'TestMySpace' object has no attribute 'space'
# Fix: Define space in your concrete test class

class TestMySpace(MySpaceTestCase, metaclass=DataBasedParametrizer):
    space = MySpace()  # Don't forget this!
    testing_data = MySpaceTestData()

Import Errors#

# Always use geomstats.backend
import geomstats.backend as gs  # Good

import numpy as np  # Bad - not backend-agnostic

Coverage Requirements#

Geomstats requires 90% coverage for new code:

# Check coverage
pytest --cov=geomstats --cov-report=term-missing tests/

# Generate HTML report
pytest --cov=geomstats --cov-report=html tests/
# Open htmlcov/index.html in browser

What to Test#

✅ Test these:

  • Public methods (not starting with _)

  • Different parameter combinations

  • Edge cases (zero, identity, boundary values)

  • Error handling

  • Mathematical properties

❌ Don’t test these:

  • Private methods (_method_name)

  • Third-party library code

  • Simple getters/setters

Complete Example#

Here’s a complete example combining everything:

# tests/tests_geomstats/test_geometry/data/circle.py
from geomstats.test.data import TestData
import geomstats.backend as gs

class CircleTestData(TestData):
    tolerances = {"belongs": {"atol": 1e-5}}

    def belongs_test_data(self):
        data = [
            dict(point=gs.array([1.0, 0.0]), expected=True),
            dict(point=gs.array([0.0, 1.0]), expected=True),
            dict(point=gs.array([2.0, 0.0]), expected=False),
        ]
        return self.generate_tests(data)

    def belongs_vec_test_data(self):
        return self.generate_vec_data()


# geomstats/test_cases/geometry/circle.py
from geomstats.test_cases.geometry.base import LevelSetTestCase

class CircleTestCase(LevelSetTestCase):
    def test_belongs(self, point, expected, atol):
        result = self.space.belongs(point)
        self.assertAllClose(result, expected, atol=atol)


# tests/tests_geomstats/test_geometry/test_circle.py
import pytest
from geomstats.geometry.circle import Circle
from geomstats.test.parametrizers import DataBasedParametrizer
from geomstats.test_cases.geometry.circle import CircleTestCase
from .data.circle import CircleTestData

@pytest.mark.smoke
class TestCircle(CircleTestCase, metaclass=DataBasedParametrizer):
    space = Circle()
    testing_data = CircleTestData()

    # Can still add simple tests directly
    def test_dimension_is_one(self):
        assert self.space.dim == 1

Further Resources#

Checklist Before Submitting#

  • [ ] Tests pass locally: pytest tests/

  • [ ] Tests pass on all backends (if applicable)

  • [ ] Coverage is at least 90%: pytest --cov=geomstats

  • [ ] Code follows PEP8: ruff check .

  • [ ] Docstrings are complete

  • [ ] Tests have descriptive names and docstrings

  • [ ] Committed test data if using three-layer architecture

  • [ ] Added markers (@pytest.mark.smoke, etc.) where appropriate