Skip to content

Latest commit

 

History

History
593 lines (439 loc) · 11.9 KB

File metadata and controls

593 lines (439 loc) · 11.9 KB

Testing Guide

This guide covers testing practices and guidelines for Rite.

Testing Framework

Rite uses Pytest as the testing framework with Coverage for code coverage measurement.

Running Tests

Basic Commands

# Run all tests
make test

# Run with coverage
make coverage

# Run specific test file
poetry run pytest tst/path/to/test_file.py

# Run specific test class
poetry run pytest tst/path/to/test_file.py::TestClassName

# Run specific test method
poetry run pytest tst/path/to/test_file.py::TestClassName::test_method

# Run with verbose output
poetry run pytest -v

# Run with output capture disabled
poetry run pytest -s

Using Test Markers

# Run only unit tests
poetry run pytest -m unit

# Run only integration tests
poetry run pytest -m integration

# Run only slow tests
poetry run pytest -m slow

# Run everything except slow tests
poetry run pytest -m "not slow"

# Combine markers
poetry run pytest -m "unit and not slow"

Test Structure

File Organization

Tests mirror the src/ structure:

src/rite/
├── crypto/
│   ├── uuid/
│   │   └── uuid_hex.py
│   └── hash/
│       └── hash_sha256.py
└── text/
    └── slug/
        └── slug_is_valid.py

tst/rite/
├── crypto/
│   ├── uuid/
│   │   └── test_uuid_hex.py
│   └── hash/
│       └── test_hash_sha256.py
└── text/
    └── slug/
        └── test_slug_is_valid.py

Test File Template

# =============================================================================
# Test: Module Name
# =============================================================================

"""Tests for rite.module.function.
"""


# =============================================================================
# Imports
# =============================================================================

# Import | Future
from __future__ import annotations

# Import | Standard Library
import pytest

# Import | Local Modules
from rite.module import function


class TestFunction:
    """Tests for function."""

    def test_basic_usage(self) -> None:
        """Test basic functionality."""
        result = function("input")
        assert result == "expected"

    def test_edge_cases(self) -> None:
        """Test edge cases."""
        # Test empty input
        result = function("")
        assert result == ""

        # Test None input
        with pytest.raises(TypeError):
            function(None)

    @pytest.mark.parametrize(
        "input_val,expected",
        [
            ("test1", "result1"),
            ("test2", "result2"),
            ("test3", "result3"),
        ],
    )
    def test_multiple_cases(
        self, input_val: str, expected: str
    ) -> None:
        """Test multiple parameter combinations."""
        assert function(input_val) == expected

    @pytest.mark.slow
    def test_performance(self) -> None:
        """Test performance with large dataset."""
        large_input = "x" * 1000000
        result = function(large_input)
        assert len(result) > 0

Test Types

Unit Tests

Test individual functions in isolation:

class TestHashSha256:
    """Unit tests for hash_sha256."""

    @pytest.mark.unit
    def test_basic_hash(self) -> None:
        """Test basic hashing."""
        result = hash_sha256("test")
        assert len(result) == 64  # SHA256 produces 64 hex chars

    @pytest.mark.unit
    def test_empty_string(self) -> None:
        """Test hashing empty string."""
        result = hash_sha256("")
        assert result is not None

Integration Tests

Test multiple components working together:

class TestFileOperations:
    """Integration tests for file operations."""

    @pytest.mark.integration
    def test_file_workflow(self, tmp_path: Path) -> None:
        """Test complete file workflow."""
        # Create test file
        source = tmp_path / "source.txt"
        source.write_text("test content")

        # Copy file
        dest = tmp_path / "dest.txt"
        file_copy(source, dest)

        # Verify
        assert dest.read_text() == "test content"
        assert file_exists(dest)

Parametrized Tests

Test multiple inputs efficiently:

@pytest.mark.parametrize(
    "input_str,expected",
    [
        ("hello", "hello"),
        ("HELLO", "hello"),
        ("Hello World", "hello-world"),
        ("hello_world", "hello-world"),
        ("", ""),
    ],
)
def test_to_slug(input_str: str, expected: str) -> None:
    """Test slug conversion with various inputs."""
    assert to_slug(input_str) == expected

Exception Tests

Test error handling:

def test_invalid_input() -> None:
    """Test handling of invalid input."""
    with pytest.raises(ValueError, match="Invalid input"):
        function("invalid")

def test_type_error() -> None:
    """Test type checking."""
    with pytest.raises(TypeError):
        function(None)

Fixtures

Built-in Fixtures

def test_temp_file(tmp_path: Path) -> None:
    """Test with temporary directory."""
    test_file = tmp_path / "test.txt"
    test_file.write_text("content")
    assert test_file.read_text() == "content"

def test_with_monkeypatch(monkeypatch: pytest.MonkeyPatch) -> None:
    """Test with environment variable."""
    monkeypatch.setenv("TEST_VAR", "value")
    assert os.getenv("TEST_VAR") == "value"

Custom Fixtures

Create in conftest.py:

# tst/conftest.py
import pytest


@pytest.fixture
def sample_data() -> dict[str, Any]:
    """Provide sample test data."""
    return {
        "name": "test",
        "value": 42,
        "active": True,
    }


@pytest.fixture
def temp_file(tmp_path: Path) -> Path:
    """Create a temporary test file."""
    file_path = tmp_path / "test.txt"
    file_path.write_text("test content")
    return file_path


# Use in tests
def test_with_fixture(sample_data: dict[str, Any]) -> None:
    """Test using custom fixture."""
    assert sample_data["name"] == "test"

Code Coverage

Measuring Coverage

# Run tests with coverage
poetry run pytest --cov=rite --cov-report=html --cov-report=term

# Open HTML report
open htmlcov/index.html

# Generate XML report (for CI)
poetry run pytest --cov=rite --cov-report=xml

Coverage Goals

  • Overall: >80% coverage
  • New code: >90% coverage
  • Critical modules: 100% coverage

Excluding from Coverage

In code:

def debug_function():  # pragma: no cover
    """This function is only for debugging."""
    pass

In .coveragerc:

[report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError
    if __name__ == .__main__.:

Mocking

Using unittest.mock

from unittest.mock import Mock, patch, MagicMock


def test_with_mock() -> None:
    """Test with mocked dependency."""
    mock_service = Mock()
    mock_service.get_data.return_value = {"key": "value"}

    result = function_using_service(mock_service)

    mock_service.get_data.assert_called_once()
    assert result["key"] == "value"


@patch("rite.module.external_function")
def test_with_patch(mock_external: Mock) -> None:
    """Test with patched function."""
    mock_external.return_value = "mocked"

    result = function()

    assert result == "mocked"
    mock_external.assert_called()

Test Markers

Defined in pytest.ini:

[pytest]
markers =
    unit: Unit tests (fast, isolated)
    integration: Integration tests (multiple components)
    slow: Slow tests (skip in development)
    smoke: Smoke tests (quick sanity check)
    regression: Regression tests (known bugs)
    security: Security tests

Usage:

@pytest.mark.unit
def test_fast() -> None:
    pass

@pytest.mark.integration
@pytest.mark.slow
def test_complex() -> None:
    pass

Best Practices

Test Names

# ✅ Good: Descriptive test names
def test_empty_string_returns_empty_slug() -> None:
    pass

def test_uppercase_letters_converted_to_lowercase() -> None:
    pass

# ❌ Bad: Vague test names
def test_function() -> None:
    pass

def test_1() -> None:
    pass

Arrange-Act-Assert

def test_user_creation() -> None:
    """Test user creation."""
    # Arrange
    name = "John Doe"
    email = "john@example.com"

    # Act
    user = create_user(name, email)

    # Assert
    assert user.name == name
    assert user.email == email
    assert user.is_active is True

One Assertion Per Test

# ✅ Good: Focused tests
def test_user_name() -> None:
    user = create_user("John")
    assert user.name == "John"

def test_user_email() -> None:
    user = create_user("John", "john@example.com")
    assert user.email == "john@example.com"

# ❌ Bad: Multiple unrelated assertions
def test_user() -> None:
    user = create_user("John", "john@example.com")
    assert user.name == "John"
    assert user.email == "john@example.com"
    assert user.is_active is True
    assert len(user.id) > 0

Test Independence

# ✅ Good: Tests don't depend on each other
class TestCounter:
    def test_increment(self) -> None:
        counter = Counter()
        counter.increment()
        assert counter.value == 1

    def test_decrement(self) -> None:
        counter = Counter()
        counter.decrement()
        assert counter.value == -1

# ❌ Bad: Tests depend on execution order
class TestCounter:
    counter = Counter()

    def test_increment(self) -> None:
        self.counter.increment()
        assert self.counter.value == 1

    def test_another_increment(self) -> None:
        # Assumes previous test ran first
        self.counter.increment()
        assert self.counter.value == 2

Continuous Integration

Tests run automatically in CI:

# .github/workflows/ci-cd.yml
- name: Run tests with coverage
  run: poetry run pytest --cov=rite --cov-report=xml

- name: Upload coverage
  uses: codecov/codecov-action@v4

Multi-Environment Testing

Test across Python versions:

# Using tox
make tox

# Or directly
poetry run tox -e py310,py311,py312

Performance Testing

Benchmark Tests

@pytest.mark.benchmark
def test_performance(benchmark):
    """Benchmark function performance."""
    result = benchmark(expensive_function, "input")
    assert result is not None

Profiling

# Profile tests
poetry run pytest --profile

# With detailed output
poetry run pytest --profile-svg

Debugging Tests

Print Debugging

# Show print output
poetry run pytest -s

# Show local variables on failure
poetry run pytest -l

Using pdb

def test_debug() -> None:
    """Test with debugger."""
    result = function("input")
    import pdb; pdb.set_trace()  # Breakpoint
    assert result == "expected"

Run with:

poetry run pytest --pdb

Common Patterns

Testing Files

def test_file_operations(tmp_path: Path) -> None:
    """Test file operations."""
    # Create test file
    test_file = tmp_path / "test.txt"
    test_file.write_text("content")

    # Test
    result = read_file(test_file)
    assert result == "content"

Testing Exceptions

def test_exception_message() -> None:
    """Test exception details."""
    with pytest.raises(ValueError) as exc_info:
        function("invalid")

    assert "invalid" in str(exc_info.value)

Testing Async Code

@pytest.mark.asyncio
async def test_async_function() -> None:
    """Test async function."""
    result = await async_function()
    assert result is not None

Resources

See Also