Skip to content

Testing

Learn how to run tests, write new tests, and maintain test coverage for gi.

Running Tests

Basic Test Execution

Run all tests:

# Run all tests
pytest

# Run with verbose output
pytest -v

# Run with coverage
pytest --cov=gi --cov-report=html

Specific Test Categories

Run specific test categories:

# Run only unit tests
pytest tests/test_*.py

# Run tests for specific module
pytest tests/test_fetch.py

# Run specific test function
pytest tests/test_fetch.py::test_get_template_success

Test Configuration

The test configuration is in pyproject.toml:

[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"

Test Structure

Test Organization

Tests are organized by module:

tests/
├── __init__.py
├── fixtures/
│   ├── Python.gitignore
│   └── Rust.gitignore
├── test_combine.py
├── test_fetch.py
├── test_names.py
└── test_util.py

Test Fixtures

Test fixtures provide sample data:

# tests/fixtures/Python.gitignore
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# C extensions
*.so

# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

Writing Tests

Test Structure

Follow the Arrange-Act-Assert pattern:

def test_function_name():
    """Test description."""
    # Arrange
    input_data = "test input"
    expected_output = "expected result"

    # Act
    result = function_under_test(input_data)

    # Assert
    assert result == expected_output

Example Test

# tests/test_names.py
import pytest
from gi.names import normalize_template_name

def test_normalize_template_name():
    """Test template name normalization."""
    # Test basic normalization
    assert normalize_template_name("python") == "Python"
    assert normalize_template_name("PYTHON") == "Python"

    # Test with .gitignore suffix
    assert normalize_template_name("python.gitignore") == "Python"

    # Test aliases
    assert normalize_template_name("csharp") == "VisualStudio"

Mocking External Dependencies

Use responses for mocking HTTP requests:

# tests/test_fetch.py
import responses
import pytest
from gi.fetch import TemplateFetcher

@responses.activate
def test_get_template_success():
    """Test successful template fetching."""
    # Mock the HTTP response
    responses.add(
        responses.GET,
        "https://raw.githubusercontent.com/github/gitignore/main/Python.gitignore",
        body="# Python template\n__pycache__/\n*.pyc",
        status=200
    )

    # Test the function
    fetcher = TemplateFetcher()
    result = fetcher.get_template("Python")

    assert result == "# Python template\n__pycache__/\n*.pyc"

Testing CLI Commands

Test CLI commands using typer.testing:

# tests/test_cli.py
from typer.testing import CliRunner
from gi.cli import app

def test_cli_help():
    """Test CLI help command."""
    runner = CliRunner()
    result = runner.invoke(app, ["--help"])

    assert result.exit_code == 0
    assert "gi" in result.output
    assert "help" in result.output

def test_cli_list():
    """Test CLI list command."""
    runner = CliRunner()
    result = runner.invoke(app, ["list"])

    assert result.exit_code == 0
    assert "Python" in result.output

Test Coverage

Coverage Configuration

Coverage is configured in pyproject.toml:

[tool.coverage.run]
source = ["gi"]
omit = [
    "*/tests/*",
    "*/test_*",
    "*/__pycache__/*",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise AssertionError",
    "raise NotImplementedError",
]

Running Coverage

# Run tests with coverage
pytest --cov=gi --cov-report=html

# Generate coverage report
coverage html

# View coverage report
open htmlcov/index.html

Coverage Goals

Maintain high test coverage:

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

Test Categories

Unit Tests

Test individual functions and methods:

def test_parse_lines():
    """Test line parsing functionality."""
    content = "line1\nline2\n\nline3"
    result = parse_lines(content)

    assert result == ["line1", "line2", "", "line3"]

Integration Tests

Test module interactions:

def test_fetch_and_combine():
    """Test fetching and combining templates."""
    fetcher = TemplateFetcher()
    combiner = TemplateCombiner()

    # Fetch templates
    python_template = fetcher.get_template("Python")
    node_template = fetcher.get_template("Node")

    # Combine templates
    combined = combiner.combine_templates({
        "Python": python_template,
        "Node": node_template
    })

    assert "Python" in combined
    assert "Node" in combined

End-to-End Tests

Test complete workflows:

def test_cli_workflow():
    """Test complete CLI workflow."""
    runner = CliRunner()

    # Test list command
    result = runner.invoke(app, ["list"])
    assert result.exit_code == 0

    # Test search command
    result = runner.invoke(app, ["search", "python"])
    assert result.exit_code == 0

    # Test main command
    result = runner.invoke(app, ["python", "node", "-o", "test.gitignore"])
    assert result.exit_code == 0
    assert Path("test.gitignore").exists()

    # Cleanup
    Path("test.gitignore").unlink()

Test Utilities

Custom Fixtures

Create reusable test fixtures:

# tests/conftest.py
import pytest
import tempfile
from pathlib import Path

@pytest.fixture
def temp_dir():
    """Create a temporary directory for tests."""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield Path(tmpdir)

@pytest.fixture
def sample_gitignore():
    """Provide sample .gitignore content."""
    return "# Python\n__pycache__/\n*.pyc\n\n# Node\nnode_modules/\n"

@pytest.fixture
def mock_fetcher():
    """Provide a mock template fetcher."""
    from unittest.mock import Mock
    fetcher = Mock()
    fetcher.get_template.return_value = "# Mock template\n*.mock"
    return fetcher

Test Helpers

Create helper functions for common test operations:

# tests/helpers.py
def create_test_file(path: Path, content: str) -> None:
    """Create a test file with content."""
    path.write_text(content)

def assert_file_contains(path: Path, content: str) -> None:
    """Assert that a file contains specific content."""
    assert path.exists()
    assert content in path.read_text()

def assert_gitignore_valid(path: Path) -> None:
    """Assert that a .gitignore file is valid."""
    assert path.exists()
    content = path.read_text()
    assert content.strip()  # Not empty
    assert not content.endswith("\n\n")  # No trailing newlines

Continuous Integration

GitHub Actions

Tests run automatically on GitHub Actions:

# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]

    steps:
    - uses: actions/checkout@v4

    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v4
      with:
        python-version: ${{ matrix.python-version }}

    - name: Install dependencies
      run: |
        pip install -e ".[dev]"

    - name: Run tests
      run: |
        pytest --cov=gi --cov-report=xml

    - name: Upload coverage to Codecov
      if: matrix.python-version == '3.11'
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

Local CI Testing

Test the CI process locally:

# Install act (GitHub Actions runner)
# https://github.com/nektos/act

# Run the CI workflow
act -j test

# Run with specific Python version
act -j test --matrix python-version:3.11

Test Best Practices

Naming Conventions

Use descriptive test names:

# Good
def test_normalize_template_name_with_gitignore_suffix():
    """Test that .gitignore suffix is removed from template names."""

# Avoid
def test_normalize():
    """Test normalization."""

Test Isolation

Ensure tests are independent:

def test_function():
    """Test that doesn't depend on other tests."""
    # Use fresh data for each test
    input_data = create_test_data()
    result = function_under_test(input_data)
    assert result is not None

Error Testing

Test error conditions:

def test_invalid_template_name():
    """Test handling of invalid template names."""
    with pytest.raises(ValueError, match="Invalid template name"):
        normalize_template_name("")

def test_network_error():
    """Test handling of network errors."""
    with responses.RequestsMock() as rsps:
        rsps.add(
            responses.GET,
            "https://api.github.com/repos/github/gitignore/contents/",
            status=500
        )

        fetcher = TemplateFetcher()
        with pytest.raises(NetworkError):
            fetcher.get_index()

Performance Testing

Test performance characteristics:

import time

def test_template_fetching_performance():
    """Test that template fetching is reasonably fast."""
    fetcher = TemplateFetcher()

    start_time = time.time()
    result = fetcher.get_template("Python")
    end_time = time.time()

    assert result is not None
    assert (end_time - start_time) < 5.0  # Should complete within 5 seconds

Debugging Tests

Verbose Output

Get detailed test output:

# Verbose output
pytest -v

# Extra verbose
pytest -vv

# Show local variables on failure
pytest -l

Debug Mode

Run tests in debug mode:

# Drop into debugger on failure
pytest --pdb

# Drop into debugger on first failure
pytest -x --pdb

Test Discovery

Debug test discovery:

# Show which tests would be run
pytest --collect-only

# Show test collection errors
pytest --collect-only -v

Maintenance

Regular Updates

Keep tests up to date:

# Run tests before committing
pytest

# Run tests with coverage
pytest --cov=gi

# Update test fixtures if needed
# Edit tests/fixtures/*.gitignore

Test Documentation

Document test strategies:

class TestTemplateFetcher:
    """Test suite for TemplateFetcher class."""

    def test_get_template_success(self):
        """Test successful template fetching.

        This test verifies that:
        1. HTTP requests are made correctly
        2. Response content is returned
        3. Caching works properly
        """
        # Test implementation

This comprehensive guide covers all aspects of testing gi, from writing tests to maintaining test coverage and debugging test issues.