Skip to content

Testing

A comprehensive guide to testing GitPulse.

Test pyramid

graph TB
    subgraph "Test Pyramid"
        E2E["E2E Tests ~10%"]
        INT["Integration Tests ~30%"]
        UNIT["Unit Tests ~60%"]
    end

    E2E --> |Slow, expensive| INT
    INT --> |Faster| UNIT

Running tests

All tests

Bash
1
2
3
4
5
6
7
8
# S coverage
pytest --cov=app --cov-report=html

# Bez coverage (rýchlejšie)
pytest

# Verbose output
pytest -v

Selective launch

Bash
# Len unit testy
pytest tests/unit/

# Konkrétny modul
pytest tests/unit/test_gaming_detection.py

# Konkrétny test
pytest tests/unit/test_gaming_detection.py::test_commit_burst_detection

# Podľa markeru
pytest -m "not slow"

Useful flags

Bash
# Zastaviť na prvom zlyhaní
pytest -x

# Zobraziť print() výstupy
pytest -s

# Paralelné spustenie
pytest -n auto

# Rerun failed tests
pytest --lf

# Watch mode (s pytest-watch)
ptw

Unit tests

Unit tests are fast and test isolated components.

Structure

Text Only
1
2
3
4
5
6
7
8
9
tests/unit/
+-- __init__.py
+-- conftest.py                     # Shared fixtures
+-- test_compliance_engine.py       # Compliance engine tests
+-- test_gaming_detection.py        # Gaming detection tests
+-- test_roles.py                   # Role hierarchy tests (36 tests)
+-- test_rubric_engine.py          # Rubric evaluation tests
+-- test_docker_lint.py            # Docker linting tests
+-- test_security.py               # Security module tests

An example of a unit test

Python
# tests/unit/test_gaming_detection.py
import pytest
from datetime import datetime, timedelta

from app.compliance.gaming_detection import GamingDetector
from app.compliance.gaming_types import GamingIndicator


class TestCommitBurstDetection:
    """Testy pre detekciu commit burstov."""

    @pytest.fixture
    def detector(self):
        return GamingDetector(
            burst_threshold=10,
            burst_window_hours=2,
        )

    def test_no_burst_when_commits_spread_out(self, detector):
        """Commits rozložené v čase by nemali triggernúť burst."""
        commits = [
            {"timestamp": datetime.now() - timedelta(hours=i)}
            for i in range(10)
        ]

        indicators = detector.detect_bursts(commits)

        assert len(indicators) == 0

    def test_burst_detected_when_many_commits_in_short_time(self, detector):
        """Veľa commitov v krátkom čase by malo triggernúť burst."""
        now = datetime.now()
        commits = [
            {"timestamp": now - timedelta(minutes=i * 5)}
            for i in range(15)
        ]

        indicators = detector.detect_bursts(commits)

        assert len(indicators) == 1
        assert indicators[0].type == GamingIndicator.COMMIT_BURST

    @pytest.mark.parametrize("commit_count,expected_bursts", [
        (5, 0),
        (10, 0),
        (11, 1),
        (25, 1),
    ])
    def test_burst_threshold(self, detector, commit_count, expected_bursts):
        """Test rôznych počtov commitov proti threshold."""
        now = datetime.now()
        commits = [
            {"timestamp": now - timedelta(minutes=i)}
            for i in range(commit_count)
        ]

        indicators = detector.detect_bursts(commits)

        assert len(indicators) == expected_bursts

Mocking

Python
# tests/unit/test_gitlab_service.py
from unittest.mock import AsyncMock, patch
import pytest

from app.services.gitlab_service import GitLabService


class TestGitLabService:

    @pytest.fixture
    def gitlab_service(self):
        return GitLabService(
            base_url="https://gitlab.example.com",
            token="test-token"
        )

    @pytest.mark.asyncio
    async def test_get_project(self, gitlab_service):
        """Test getting project info from GitLab API."""
        mock_response = {
            "id": 123,
            "name": "test-project",
            "path_with_namespace": "group/test-project"
        }

        with patch.object(
            gitlab_service, 
            "_request", 
            new_callable=AsyncMock,
            return_value=mock_response
        ):
            project = await gitlab_service.get_project(123)

            assert project["id"] == 123
            assert project["name"] == "test-project"

Integration tests

Integration tests test the interaction between components.

Database fixtures

Python
# tests/conftest.py
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker

from app.models.base import Base


@pytest.fixture(scope="session")
def event_loop():
    """Create event loop for async tests."""
    import asyncio
    loop = asyncio.new_event_loop()
    yield loop
    loop.close()


@pytest_asyncio.fixture(scope="function")
async def db_session():
    """Create test database session."""
    engine = create_async_engine(
        "sqlite+aiosqlite:///:memory:",
        echo=False,
    )

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

    async_session = sessionmaker(
        engine, class_=AsyncSession, expire_on_commit=False
    )

    async with async_session() as session:
        yield session

    await engine.dispose()

API tests

Python
# tests/integration/test_api_courses.py
import pytest
from httpx import AsyncClient
from fastapi import status

from app.main import app


@pytest.mark.asyncio
class TestCoursesAPI:

    @pytest_asyncio.fixture
    async def client(self):
        async with AsyncClient(app=app, base_url="http://test") as ac:
            yield ac

    async def test_create_course(self, client, auth_headers):
        """Test creating a new course."""
        course_data = {
            "code": "TP-2024",
            "name": "Tímový projekt",
            "academic_year": "2024/2025",
            "semester": "winter"
        }

        response = await client.post(
            "/api/v1/courses/",
            json=course_data,
            headers=auth_headers
        )

        assert response.status_code == status.HTTP_201_CREATED
        data = response.json()
        assert data["code"] == "TP-2024"
        assert "id" in data

    async def test_get_course_not_found(self, client, auth_headers):
        """Test 404 for non-existent course."""
        response = await client.get(
            "/api/v1/courses/99999",
            headers=auth_headers
        )

        assert response.status_code == status.HTTP_404_NOT_FOUND

Fixtures

Shared fixtures

Python
# tests/conftest.py
import pytest
from datetime import datetime

from app.models.course import Course
from app.models.team import Team
from app.models.user import User


@pytest.fixture
def sample_course():
    """Factory pre vytvorenie test course."""
    def _create(
        code: str = "TEST-001",
        name: str = "Test Course",
        **kwargs
    ):
        return Course(
            code=code,
            name=name,
            academic_year="2024/2025",
            semester="winter",
            **kwargs
        )
    return _create


@pytest.fixture
def sample_team(sample_course):
    """Factory pre vytvorenie test team."""
    def _create(
        name: str = "Team Alpha",
        course: Course = None,
        **kwargs
    ):
        return Team(
            name=name,
            course=course or sample_course(),
            **kwargs
        )
    return _create


@pytest.fixture
def auth_headers():
    """Headers s test auth token."""
    return {"Authorization": "Bearer test-token-123"}

Coverage

Report generation

Bash
1
2
3
4
5
6
7
8
9
# HTML report
pytest --cov=app --cov-report=html
open htmlcov/index.html

# Terminal report
pytest --cov=app --cov-report=term-missing

# XML (pre CI)
pytest --cov=app --cov-report=xml

Coverage configuration

TOML
# pyproject.toml
[tool.coverage.run]
source = ["src/app"]
omit = [
    "*/tests/*",
    "*/__pycache__/*",
    "*/migrations/*",
]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "if TYPE_CHECKING:",
    "raise NotImplementedError",
    "if __name__ == .__main__.:",
]
fail_under = 80

CI Pipeline

YAML
# .gitlab-ci.yml (test stage)
test:
  stage: test
  image: python:3.12-slim
  services:
    - postgres:16-alpine
  variables:
    DATABASE_URL: postgresql+asyncpg://test:test@postgres/test
    POSTGRES_USER: test
    POSTGRES_PASSWORD: test
    POSTGRES_DB: test
  script:
    - pip install -e ".[dev]"
    - pytest --cov=app --cov-report=xml --junitxml=report.xml
  coverage: '/TOTAL.*\s+(\d+%)$/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml
      junit: report.xml

Best practices

Good practices

  1. Test names describe expected behavior

    Python
    1
    2
    3
    4
    5
    # Dobre
    def test_burst_detected_when_many_commits_in_short_time():
    
    # Zle
    def test_burst():
    

  2. Arrange-Act-Assert pattern

    Python
    1
    2
    3
    4
    5
    6
    7
    8
    9
    def test_team_metrics_calculation(self, team_service):
        # Arrange
        team = create_team_with_commits(count=10)
    
        # Act
        metrics = team_service.calculate_metrics(team)
    
        # Assert
        assert metrics.commit_count == 10
    

  3. Isolated Tests

    Python
    1
    2
    3
    4
    5
    6
    # Každý test má vlastnú databázu/state
    @pytest.fixture
    async def clean_db():
        # Setup fresh DB
        yield db
        # Cleanup
    

Anti-patterns

Python
# 1. Testy závislé na poradí
def test_create_then_read():  # Závisí na test_create

# 2. Príliš veľa assertions
def test_everything():
    assert this
    assert that
    assert other  # Rozdeliť do viacerých testov

# 3. Sleep v testoch
import time
time.sleep(5)  # Použiť mock alebo async waiting

Further reading