Testing

PicoGL includes a comprehensive testing framework to ensure reliability and compatibility across different platforms and OpenGL versions.

Test Structure

The test suite is organized as follows:

tests/
├── test_vertex_array_object.py      # VAO tests
├── test_vertex_buffer_group.py      # Legacy VBO tests
├── test_meshdata.py                 # MeshData tests
├── test_glmesh.py                   # Modern GLMesh tests
├── test_legacy_glmesh.py            # Legacy GLMesh tests
├── test_object_renderer.py          # ObjectRenderer tests
└── test_texture_renderer.py         # TextureRenderer tests

Running Tests

Basic Test Execution

Run all tests:

python -m unittest discover tests

Run specific test file:

python -m unittest tests.test_vertex_array_object

Run specific test method:

python -m unittest tests.test_vertex_array_object.TestVertexArrayObject.test_initialization

Run with verbose output:

python -m unittest discover tests -v

Using pytest

Install pytest:

pip install pytest pytest-cov

Run tests with pytest:

pytest tests/

Run with coverage:

pytest tests/ --cov=picogl

Run specific test:

pytest tests/test_vertex_array_object.py::TestVertexArrayObject::test_initialization

Test Categories

Unit Tests

Unit tests verify individual components in isolation:

VertexArrayObject Tests:

class TestVertexArrayObject(unittest.TestCase):
    def test_initialization(self):
        vao = VertexArrayObject()
        self.assertIsNotNone(vao)

    def test_add_vbo(self):
        vao = VertexArrayObject()
        vao.add_vbo(index=0, data=vertices, size=3)
        self.assertEqual(len(vao.vbos), 1)

    def test_add_ebo(self):
        vao = VertexArrayObject()
        vao.add_ebo(data=indices)
        self.assertIsNotNone(vao.ebo)

MeshData Tests:

class TestMeshData(unittest.TestCase):
    def test_from_raw(self):
        data = MeshData.from_raw(vertices=vertices, colors=colors)
        self.assertIsNotNone(data)
        self.assertEqual(data.vertex_count, len(vertices) // 3)

    def test_draw(self):
        data = MeshData.from_raw(vertices=vertices, colors=colors)
        data.draw()  # Should not raise exception

Integration Tests

Integration tests verify component interactions:

Renderer Integration:

class TestRendererIntegration(unittest.TestCase):
    def test_object_renderer_initialization(self):
        context = GLContext()
        data = MeshData.from_raw(vertices=vertices, colors=colors)
        renderer = ObjectRenderer(context=context, data=data)
        renderer.initialize()
        self.assertTrue(renderer.initialized)

    def test_texture_renderer_initialization(self):
        context = GLContext()
        data = MeshData.from_raw(vertices=vertices, uvs=uvs)
        renderer = TextureRenderer(context=context, data=data)
        renderer.initialize_textures()
        self.assertIsNotNone(renderer.texture)

Performance Tests

Performance tests verify rendering performance:

Rendering Performance:

class TestRenderingPerformance(unittest.TestCase):
    def test_render_performance(self):
        start_time = time.time()
        for _ in range(1000):
            renderer.render()
        end_time = time.time()
        frame_time = (end_time - start_time) / 1000
        self.assertLess(frame_time, 0.016)  # 60 FPS

    def test_memory_usage(self):
        initial_memory = psutil.Process().memory_info().rss
        # Create and use renderer
        renderer = create_renderer()
        renderer.render()
        final_memory = psutil.Process().memory_info().rss
        memory_increase = final_memory - initial_memory
        self.assertLess(memory_increase, 100 * 1024 * 1024)  # 100MB

Compatibility Tests

Compatibility tests verify cross-platform functionality:

OpenGL Version Compatibility:

class TestOpenGLCompatibility(unittest.TestCase):
    def test_modern_opengl(self):
        if has_modern_opengl():
            vao = VertexArrayObject()
            self.assertIsNotNone(vao)

    def test_legacy_opengl(self):
        if has_legacy_opengl():
            vbg = VertexBufferGroup()
            self.assertIsNotNone(vbg)

    def test_immediate_mode(self):
        # Test immediate mode fallback
        pass

Test Mocking

OpenGL Mocking

Since OpenGL requires a graphics context, tests use mocking:

Global OpenGL Mocking:

class TestVertexArrayObject(unittest.TestCase):
    def setUp(self):
        # Mock all OpenGL functions
        self.gl_patches = [
            patch('picogl.backend.modern.core.vertex.array.object.glGenVertexArrays'),
            patch('picogl.backend.modern.core.vertex.array.object.glBindVertexArray'),
            patch('picogl.backend.modern.core.vertex.array.object.glDeleteVertexArrays'),
            # ... more patches
        ]
        for patch_obj in self.gl_patches:
            patch_obj.start()

    def tearDown(self):
        for patch_obj in self.gl_patches:
            patch_obj.stop()

Specific Function Mocking:

def test_error_handling(self):
    with patch('picogl.backend.modern.core.vertex.array.object.glGenVertexArrays') as mock_gen:
        mock_gen.side_effect = OpenGLError("Context not available")
        with self.assertRaises(OpenGLError):
            VertexArrayObject()

Dependency Mocking

Mock external dependencies:

NumPy Mocking:

@patch('numpy.array')
def test_array_creation(self, mock_array):
    mock_array.return_value = np.array([1, 2, 3])
    result = create_vertex_array()
    mock_array.assert_called_once()

PIL Mocking:

@patch('PIL.Image.open')
def test_texture_loading(self, mock_open):
    mock_image = MagicMock()
    mock_open.return_value = mock_image
    texture = load_texture("test.png")
    mock_open.assert_called_once_with("test.png")

Test Data

Test Fixtures

Create reusable test data:

Vertex Data:

class TestData:
    @staticmethod
    def create_triangle_vertices():
        return np.array([
            [0, 0, 0],
            [1, 0, 0],
            [0, 1, 0]
        ], dtype=np.float32)

    @staticmethod
    def create_triangle_colors():
        return np.array([
            [1, 0, 0],
            [0, 1, 0],
            [0, 0, 1]
        ], dtype=np.float32)

    @staticmethod
    def create_triangle_faces():
        return np.array([
            [0, 1, 2]
        ], dtype=np.uint32)

Mock Objects:

class MockGLContext:
    def __init__(self):
        self.vaos = {}
        self.shader = MagicMock()
        self.mvp_matrix = np.identity(4, dtype=np.float32)

    def create_shader_program(self, vertex_file, fragment_file):
        self.shader = MagicMock()
        return self.shader

Test Utilities

Create helper functions for testing:

OpenGL Context Testing:

def create_mock_opengl_context():
    """Create a mock OpenGL context for testing."""
    with patch('OpenGL.GL.glGetString') as mock_get_string:
        mock_get_string.return_value = b"OpenGL 3.3.0"
        return mock_get_string

Error Testing:

def test_with_gl_error(error_code, expected_exception):
    """Test function with specific OpenGL error."""
    with patch('OpenGL.GL.glGetError') as mock_get_error:
        mock_get_error.return_value = error_code
        with pytest.raises(expected_exception):
            function_that_uses_opengl()

Continuous Integration

GitHub Actions

Test Workflow:

name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: [3.7, 3.8, 3.9, '3.10', '3.11']

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

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

    - name: Run tests
      run: |
        python -m unittest discover tests

    - name: Run coverage
      run: |
        pytest tests/ --cov=picogl --cov-report=xml

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

Build Workflow:

name: Build
on: [push, release]

jobs:
  build:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

    steps:
    - uses: actions/checkout@v2
    - name: Set up Python
      uses: actions/setup-python@v2
      with:
        python-version: '3.9'

    - name: Install dependencies
      run: |
        pip install build twine

    - name: Build package
      run: |
        python -m build

    - name: Upload artifacts
      uses: actions/upload-artifact@v2
      with:
        name: dist
        path: dist/

Test Coverage

Coverage Requirements

Minimum Coverage: * Overall: 80% * Critical modules: 90% * New code: 95%

Coverage Exclusions: * Test files * Example files * Platform-specific code * Fallback implementations

Coverage Report:

pytest tests/ --cov=picogl --cov-report=html --cov-report=term

Coverage Configuration:

[coverage:run]
source = picogl
omit =
    */tests/*
    */examples/*
    */__pycache__/*
    */legacy/*

[coverage:report]
exclude_lines =
    pragma: no cover
    def __repr__
    raise AssertionError
    raise NotImplementedError

Code Quality

Linting

flake8 Configuration:

[flake8]
max-line-length = 88
extend-ignore = E203, W503
exclude =
    .git,
    __pycache__,
    .venv,
    _build,
    dist

Run Linting:

flake8 picogl/
flake8 tests/

Type Checking

mypy Configuration:

[mypy]
python_version = 3.7
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_incomplete_defs = True
check_untyped_defs = True
disallow_untyped_decorators = True
no_implicit_optional = True
warn_redundant_casts = True
warn_unused_ignores = True
warn_no_return = True
warn_unreachable = True
strict_equality = True

Run Type Checking:

mypy picogl/
mypy tests/

Formatting

black Configuration:

[tool.black]
line-length = 88
target-version = ['py37']
include = '\.pyi?$'
extend-exclude = '''
/(
    \.git
  | \.mypy_cache
  | \.tox
  | \.venv
  | _build
  | buck-out
  | build
  | dist
)/
'''

Run Formatting:

black picogl/
black tests/

Test Best Practices

Writing Tests

Test Naming:

def test_initialization_with_valid_data(self):
    """Test initialization with valid input data."""
    pass

def test_initialization_raises_error_with_invalid_data(self):
    """Test initialization raises error with invalid data."""
    pass

Test Structure:

def test_functionality(self):
    # Arrange
    input_data = create_test_data()
    expected_result = create_expected_result()

    # Act
    actual_result = function_under_test(input_data)

    # Assert
    self.assertEqual(actual_result, expected_result)

Test Documentation:

def test_complex_functionality(self):
    """Test complex functionality with multiple inputs.

    This test verifies that the function handles:
    - Valid input data
    - Edge cases
    - Error conditions

    Expected behavior:
    - Returns correct result for valid input
    - Handles edge cases gracefully
    - Raises appropriate errors for invalid input
    """
    pass

Test Maintenance

Keep Tests Simple: * One assertion per test * Clear test names * Minimal setup

Avoid Test Dependencies: * Tests should be independent * No shared state * Clean up after tests

Regular Test Updates: * Update tests when code changes * Remove obsolete tests * Add tests for new features

Test Performance: * Keep tests fast * Use mocking to avoid slow operations * Parallel test execution when possible

Debugging Tests

Verbose Output:

python -m unittest discover tests -v

Debug Specific Test:

import pdb

def test_debugging(self):
    pdb.set_trace()  # Set breakpoint
    # Test code here

Test Isolation:

def test_isolated(self):
    # Use fresh instances
    context = GLContext()
    data = MeshData.from_raw(vertices=vertices, colors=colors)
    # Test without side effects

For more information about testing, see the Development guide.