architect/_archive/2025-11-cleanup/platform-v2-cifra/archive/2025-11-10-restructure-v2/TESTING_STRATEGY.md

CIFRA Platform - Testing Strategy

Дата: 2025-11-10
Версия: 1.0
Статус: Production Ready


Обзор

Этот документ описывает полную стратегию тестирования CIFRA Platform: типы тестов, инструменты, практики и метрики.


1. ТЕСТОВАЯ ПИРАМИДА

                    ┌──────────────┐
                    │  E2E Tests   │  5%
                    │  (100 tests) │
                    └──────────────┘
                 ┌──────────────────────┐
                 │ Integration Tests    │  15%
                 │ (300 tests)          │
                 └──────────────────────┘
            ┌──────────────────────────────────┐
            │    Unit Tests                    │  80%
            │    (1600 tests)                  │
            └──────────────────────────────────┘

Соотношение:
- Unit Tests: 80% (быстрые, изолированные)
- Integration Tests: 15% (проверяют взаимодействие)
- E2E Tests: 5% (медленные, проверяют критичные сценарии)

Целевые метрики:
- Coverage: >80% (unit), >60% (integration)
- Скорость: Unit <10 sec, Integration <2 min, E2E <10 min
- Стабильность: <1% flaky tests


2. UNIT TESTS

2.1 Что тестируем?

  1. Models (Entity классы)
  2. Validators
  3. Business Logic (сервисы, managers)
  4. Utilities (helper функции)
  5. API serializers (Pydantic schemas)

2.2 Инструменты

Framework: pytest
Coverage: pytest-cov
Mocking: pytest-mock
Fixtures: pytest-fixtures
Async: pytest-asyncio
Faker: Faker

2.3 Структура тестов

tests/
├── unit/
│   ├── test_models.py
│   ├── test_validators.py
│   ├── test_services.py
│   ├── test_utils.py
│   └── test_schemas.py
├── integration/
│   ├── test_api.py
│   ├── test_db.py
│   └── test_workflows.py
├── e2e/
│   ├── test_user_journey.py
│   └── test_critical_flows.py
├── conftest.py           # Fixtures
└── pytest.ini            # Config

2.4 Примеры Unit Tests

Test Models:

# tests/unit/test_models.py
import pytest
from cifra.models import Contact, Company
from datetime import datetime

class TestContact:
    """Tests for Contact model"""

    def test_create_contact(self):
        """Should create contact with valid data"""
        contact = Contact(
            first_name="John",
            last_name="Doe",
            email="john@example.com"
        )
        assert contact.first_name == "John"
        assert contact.email == "john@example.com"

    def test_contact_validation_email(self):
        """Should raise error for invalid email"""
        with pytest.raises(ValidationError):
            Contact(
                first_name="John",
                last_name="Doe",
                email="invalid-email"
            )

    def test_contact_full_name(self):
        """Should return full name"""
        contact = Contact(
            first_name="John",
            last_name="Doe"
        )
        assert contact.full_name == "John Doe"

    def test_contact_to_dict(self):
        """Should serialize to dict"""
        contact = Contact(
            first_name="John",
            last_name="Doe",
            email="john@example.com"
        )
        data = contact.to_dict()

        assert data['first_name'] == "John"
        assert data['email'] == "john@example.com"
        assert 'password' not in data  # Should not expose password

    @pytest.mark.parametrize("email,expected", [
        ("john@example.com", True),
        ("invalid", False),
        ("", False),
        (None, False),
    ])
    def test_email_validation(self, email, expected):
        """Test email validation with various inputs"""
        try:
            contact = Contact(
                first_name="John",
                email=email
            )
            assert expected is True
        except ValidationError:
            assert expected is False

Test Validators:

# tests/unit/test_validators.py
import pytest
from cifra.validators import EmailValidator, PhoneValidator, INNValidator

class TestEmailValidator:
    """Tests for Email Validator"""

    @pytest.fixture
    def validator(self):
        return EmailValidator()

    @pytest.mark.parametrize("email", [
        "john@example.com",
        "jane.doe@company.co.uk",
        "user+tag@domain.com",
    ])
    def test_valid_emails(self, validator, email):
        """Should accept valid emails"""
        assert validator.validate(email) is True

    @pytest.mark.parametrize("email", [
        "invalid",
        "@example.com",
        "user@",
        "user space@example.com",
    ])
    def test_invalid_emails(self, validator, email):
        """Should reject invalid emails"""
        with pytest.raises(ValidationError):
            validator.validate(email)

class TestINNValidator:
    """Tests for INN (Russian tax ID) Validator"""

    @pytest.fixture
    def validator(self):
        return INNValidator()

    def test_valid_inn_10_digits(self, validator):
        """Should accept valid 10-digit INN"""
        assert validator.validate("7707083893") is True  # Sberbank INN

    def test_valid_inn_12_digits(self, validator):
        """Should accept valid 12-digit INN"""
        assert validator.validate("771234567890") is True

    def test_invalid_inn_checksum(self, validator):
        """Should reject INN with invalid checksum"""
        with pytest.raises(ValidationError):
            validator.validate("7707083890")  # Wrong checksum

    def test_invalid_inn_length(self, validator):
        """Should reject INN with wrong length"""
        with pytest.raises(ValidationError):
            validator.validate("123")

Test Services:

# tests/unit/test_services.py
import pytest
from unittest.mock import Mock, patch
from cifra.services import ContactService
from cifra.models import Contact

class TestContactService:
    """Tests for Contact Service"""

    @pytest.fixture
    def service(self):
        return ContactService()

    @pytest.fixture
    def mock_db(self):
        return Mock()

    async def test_create_contact(self, service, mock_db):
        """Should create contact in database"""
        data = {
            'first_name': 'John',
            'last_name': 'Doe',
            'email': 'john@example.com'
        }

        contact = await service.create(mock_db, data)

        assert contact.first_name == 'John'
        mock_db.add.assert_called_once()
        mock_db.commit.assert_called_once()

    async def test_create_contact_duplicate_email(self, service, mock_db):
        """Should raise error for duplicate email"""
        data = {
            'first_name': 'John',
            'email': 'john@example.com'
        }

        # Mock existing contact
        mock_db.query.return_value.filter.return_value.first.return_value = Contact(email='john@example.com')

        with pytest.raises(DuplicateEmailError):
            await service.create(mock_db, data)

    @patch('cifra.services.email_service.send')
    async def test_create_contact_sends_welcome_email(self, mock_send, service, mock_db):
        """Should send welcome email after creating contact"""
        data = {
            'first_name': 'John',
            'email': 'john@example.com'
        }

        await service.create(mock_db, data)

        mock_send.assert_called_once_with(
            to='john@example.com',
            template='welcome',
            context={'name': 'John'}
        )

    async def test_search_contacts(self, service, mock_db):
        """Should search contacts by query"""
        query = "john"

        # Mock results
        mock_db.query.return_value.filter.return_value.all.return_value = [
            Contact(first_name='John', last_name='Doe'),
            Contact(first_name='Johnny', last_name='Smith')
        ]

        results = await service.search(mock_db, query)

        assert len(results) == 2
        assert results[0].first_name == 'John'

2.5 Fixtures

# tests/conftest.py
import pytest
from faker import Faker
from cifra.models import User, Contact, Company
from cifra.db import Base, engine

fake = Faker()

@pytest.fixture(scope="session")
def db_engine():
    """Create test database engine"""
    return create_test_engine()

@pytest.fixture
async def db_session(db_engine):
    """Create fresh database session for each test"""
    async with AsyncSession(db_engine) as session:
        yield session
        await session.rollback()

@pytest.fixture
def sample_user():
    """Create sample user"""
    return User(
        email=fake.email(),
        name=fake.name(),
        password_hash="hashed_password"
    )

@pytest.fixture
def sample_contact():
    """Create sample contact"""
    return Contact(
        first_name=fake.first_name(),
        last_name=fake.last_name(),
        email=fake.email(),
        phone=fake.phone_number()
    )

@pytest.fixture
def sample_company():
    """Create sample company"""
    return Company(
        name=fake.company(),
        inn=fake.random_number(digits=10, fix_len=True),
        website=fake.url()
    )

@pytest.fixture
async def populated_db(db_session):
    """Database with sample data"""
    # Create users
    users = [User(email=fake.email(), name=fake.name()) for _ in range(10)]
    db_session.add_all(users)

    # Create contacts
    contacts = [Contact(
        first_name=fake.first_name(),
        last_name=fake.last_name(),
        email=fake.email()
    ) for _ in range(50)]
    db_session.add_all(contacts)

    await db_session.commit()

    yield db_session

3. INTEGRATION TESTS

3.1 Что тестируем?

  1. API Endpoints (FastAPI routes)
  2. Database Operations (CRUD)
  3. External Integrations (Stripe, SendGrid)
  4. Workflows (multi-step processes)
  5. Authentication (OAuth, JWT)

3.2 Инструменты

HTTP Client: httpx
Test Client: FastAPI TestClient
Database: PostgreSQL (test DB)
Mocking External APIs: responses / httpx_mock

3.3 Примеры Integration Tests

Test API Endpoints:

# tests/integration/test_api.py
import pytest
from httpx import AsyncClient
from cifra.main import app

@pytest.fixture
async def client():
    """HTTP client for testing"""
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

@pytest.fixture
async def auth_token(client):
    """Get auth token for authenticated requests"""
    response = await client.post("/api/auth/login", json={
        "email": "test@example.com",
        "password": "password123"
    })
    return response.json()["access_token"]

class TestContactAPI:
    """Integration tests for Contact API"""

    async def test_list_contacts(self, client, auth_token):
        """GET /api/contacts - should return list of contacts"""
        response = await client.get(
            "/api/contacts",
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        assert isinstance(data, list)

    async def test_create_contact(self, client, auth_token):
        """POST /api/contacts - should create new contact"""
        payload = {
            "first_name": "John",
            "last_name": "Doe",
            "email": "john@example.com",
            "phone": "+79001234567"
        }

        response = await client.post(
            "/api/contacts",
            json=payload,
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 201
        data = response.json()
        assert data["first_name"] == "John"
        assert data["email"] == "john@example.com"
        assert "id" in data

    async def test_get_contact(self, client, auth_token, sample_contact_id):
        """GET /api/contacts/{id} - should return contact by ID"""
        response = await client.get(
            f"/api/contacts/{sample_contact_id}",
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        assert data["id"] == sample_contact_id

    async def test_update_contact(self, client, auth_token, sample_contact_id):
        """PUT /api/contacts/{id} - should update contact"""
        payload = {
            "first_name": "Jane",
            "phone": "+79009999999"
        }

        response = await client.put(
            f"/api/contacts/{sample_contact_id}",
            json=payload,
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        assert data["first_name"] == "Jane"
        assert data["phone"] == "+79009999999"

    async def test_delete_contact(self, client, auth_token, sample_contact_id):
        """DELETE /api/contacts/{id} - should delete contact"""
        response = await client.delete(
            f"/api/contacts/{sample_contact_id}",
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 204

        # Verify deleted
        response = await client.get(
            f"/api/contacts/{sample_contact_id}",
            headers={"Authorization": f"Bearer {auth_token}"}
        )
        assert response.status_code == 404

    async def test_filter_contacts(self, client, auth_token):
        """GET /api/contacts?filter={} - should filter contacts"""
        response = await client.get(
            "/api/contacts",
            params={"filter": '{"email": {"$regex": ".*@gmail.com"}}'},
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        for contact in data:
            assert "@gmail.com" in contact["email"]

    async def test_pagination(self, client, auth_token):
        """GET /api/contacts?limit=10&offset=0 - should paginate"""
        response = await client.get(
            "/api/contacts",
            params={"limit": 10, "offset": 0},
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        assert response.status_code == 200
        data = response.json()
        assert len(data) <= 10

    async def test_unauthorized_access(self, client):
        """Should return 401 without auth token"""
        response = await client.get("/api/contacts")
        assert response.status_code == 401

Test Database Operations:

# tests/integration/test_db.py
import pytest
from cifra.models import Contact, Company
from cifra.db import AsyncSession

class TestContactDatabase:
    """Integration tests for Contact database operations"""

    async def test_create_and_retrieve(self, db_session: AsyncSession):
        """Should create contact and retrieve it"""
        contact = Contact(
            first_name="John",
            last_name="Doe",
            email="john@example.com"
        )
        db_session.add(contact)
        await db_session.commit()

        # Retrieve
        result = await db_session.get(Contact, contact.id)
        assert result.first_name == "John"

    async def test_relationship_contact_company(self, db_session):
        """Should create contact with company relationship"""
        company = Company(name="ACME Corp", inn="7707083893")
        db_session.add(company)
        await db_session.commit()

        contact = Contact(
            first_name="John",
            email="john@acme.com",
            company_id=company.id
        )
        db_session.add(contact)
        await db_session.commit()

        # Retrieve with relationship
        result = await db_session.get(Contact, contact.id)
        await db_session.refresh(result, ['company'])

        assert result.company.name == "ACME Corp"

    async def test_cascade_delete(self, db_session):
        """Should cascade delete contacts when company deleted"""
        company = Company(name="ACME Corp")
        db_session.add(company)
        await db_session.commit()

        contact = Contact(
            first_name="John",
            company_id=company.id
        )
        db_session.add(contact)
        await db_session.commit()

        # Delete company
        await db_session.delete(company)
        await db_session.commit()

        # Contact should be deleted too (if cascade configured)
        result = await db_session.get(Contact, contact.id)
        assert result is None

    async def test_unique_constraint(self, db_session):
        """Should raise error for duplicate email"""
        contact1 = Contact(email="john@example.com")
        db_session.add(contact1)
        await db_session.commit()

        contact2 = Contact(email="john@example.com")
        db_session.add(contact2)

        with pytest.raises(IntegrityError):
            await db_session.commit()

Test Workflows:

# tests/integration/test_workflows.py
import pytest
from cifra.workflows import OrderFulfillmentWorkflow
from cifra.models import Order, OrderItem, Product

class TestOrderFulfillmentWorkflow:
    """Integration tests for Order Fulfillment workflow"""

    @pytest.fixture
    async def sample_order(self, db_session):
        """Create sample order with items"""
        product = Product(name="Widget", price=100, stock=10)
        db_session.add(product)
        await db_session.commit()

        order = Order(customer_id=1, total=200)
        db_session.add(order)
        await db_session.commit()

        item = OrderItem(order_id=order.id, product_id=product.id, quantity=2)
        db_session.add(item)
        await db_session.commit()

        return order

    async def test_full_workflow(self, db_session, sample_order):
        """Should complete full order fulfillment workflow"""
        workflow = OrderFulfillmentWorkflow(db_session)

        # Step 1: Validate
        result = await workflow.validate_order(sample_order.id)
        assert result is True

        # Step 2: Reserve inventory
        result = await workflow.reserve_inventory(sample_order.id)
        assert result is True

        # Step 3: Process payment
        result = await workflow.process_payment(sample_order.id)
        assert result is True

        # Step 4: Ship order
        result = await workflow.ship_order(sample_order.id)
        assert result is True

        # Verify final state
        order = await db_session.get(Order, sample_order.id)
        assert order.status == "shipped"

    async def test_workflow_rollback_on_payment_failure(self, db_session, sample_order):
        """Should rollback inventory on payment failure"""
        workflow = OrderFulfillmentWorkflow(db_session)

        await workflow.validate_order(sample_order.id)
        await workflow.reserve_inventory(sample_order.id)

        # Mock payment failure
        with patch('cifra.services.PaymentService.charge', side_effect=PaymentError):
            with pytest.raises(PaymentError):
                await workflow.process_payment(sample_order.id)

        # Verify inventory released
        product = await db_session.get(Product, 1)
        assert product.stock == 10  # Back to original

4. E2E TESTS

4.1 Что тестируем?

Критичные пользовательские сценарии:
1. Регистрация → Вход → Создание Contact → Выход
2. Создание Deal → Перемещение по pipeline → Закрытие
3. Импорт CSV → Просмотр → Экспорт
4. Генерация отчёта → Скачивание PDF

4.2 Инструменты

Framework: Playwright (Python)
Browser: Chromium (headless)
Alternatives: Selenium, Cypress

4.3 Примеры E2E Tests

# tests/e2e/test_user_journey.py
import pytest
from playwright.async_api import async_playwright

@pytest.fixture
async def browser():
    """Launch browser"""
    async with async_playwright() as p:
        browser = await p.chromium.launch(headless=True)
        yield browser
        await browser.close()

@pytest.fixture
async def page(browser):
    """Create new page"""
    context = await browser.new_context()
    page = await context.new_page()
    yield page
    await context.close()

class TestUserJourney:
    """E2E tests for critical user journeys"""

    async def test_signup_and_create_contact(self, page):
        """Complete user journey: Sign up → Login → Create Contact"""

        # 1. Navigate to signup
        await page.goto("http://localhost:8000/signup")

        # 2. Fill signup form
        await page.fill('input[name="email"]', 'test@example.com')
        await page.fill('input[name="password"]', 'password123')
        await page.fill('input[name="name"]', 'Test User')
        await page.click('button[type="submit"]')

        # 3. Should redirect to login
        await page.wait_for_url("**/login")

        # 4. Login
        await page.fill('input[name="email"]', 'test@example.com')
        await page.fill('input[name="password"]', 'password123')
        await page.click('button[type="submit"]')

        # 5. Should redirect to dashboard
        await page.wait_for_url("**/dashboard")
        assert "Dashboard" in await page.text_content('h1')

        # 6. Navigate to Contacts
        await page.click('a[href="/contacts"]')
        await page.wait_for_url("**/contacts")

        # 7. Click "Create Contact"
        await page.click('button:has-text("Create Contact")')

        # 8. Fill contact form
        await page.fill('input[name="first_name"]', 'John')
        await page.fill('input[name="last_name"]', 'Doe')
        await page.fill('input[name="email"]', 'john@example.com')
        await page.fill('input[name="phone"]', '+79001234567')

        # 9. Submit
        await page.click('button[type="submit"]')

        # 10. Should show success message
        await page.wait_for_selector('.toast-success')
        assert "Contact created" in await page.text_content('.toast-success')

        # 11. Should see contact in list
        await page.wait_for_selector('table tbody tr')
        assert "John Doe" in await page.text_content('table')

    async def test_deal_pipeline(self, page):
        """Test moving deal through pipeline"""

        # Login
        await page.goto("http://localhost:8000/login")
        await page.fill('input[name="email"]', 'test@example.com')
        await page.fill('input[name="password"]', 'password123')
        await page.click('button[type="submit"]')

        # Navigate to Deals
        await page.click('a[href="/deals"]')

        # Create new deal
        await page.click('button:has-text("New Deal")')
        await page.fill('input[name="title"]', 'Big Sale')
        await page.fill('input[name="amount"]', '100000')
        await page.select_option('select[name="contact_id"]', label='John Doe')
        await page.click('button[type="submit"]')

        # Verify created
        await page.wait_for_selector('.kanban-card:has-text("Big Sale")')

        # Drag to next stage
        deal_card = page.locator('.kanban-card:has-text("Big Sale")')
        qualified_column = page.locator('.kanban-column:has-text("Qualified")')

        await deal_card.drag_to(qualified_column)

        # Wait for API call
        await page.wait_for_response('**/api/deals/*/stage')

        # Verify moved
        qualified_deals = await qualified_column.locator('.kanban-card').all_text_contents()
        assert "Big Sale" in qualified_deals

    async def test_csv_import_export(self, page):
        """Test CSV import and export"""

        # Login
        await page.goto("http://localhost:8000/login")
        await page.fill('input[name="email"]', 'test@example.com')
        await page.fill('input[name="password"]', 'password123')
        await page.click('button[type="submit"]')

        # Navigate to Contacts
        await page.click('a[href="/contacts"]')

        # Click Import
        await page.click('button:has-text("Import")')

        # Upload CSV file
        await page.set_input_files('input[type="file"]', 'tests/fixtures/contacts.csv')
        await page.click('button:has-text("Upload")')

        # Wait for import to complete
        await page.wait_for_selector('.toast-success:has-text("Imported")')

        # Verify contacts imported
        rows = await page.locator('table tbody tr').count()
        assert rows > 0

        # Export
        await page.click('button:has-text("Export")')
        await page.click('a:has-text("CSV")')

        # Wait for download
        async with page.expect_download() as download_info:
            download = await download_info.value
            assert download.suggested_filename.endswith('.csv')

5. PERFORMANCE TESTS

5.1 Load Testing (Locust)

# tests/performance/locustfile.py
from locust import HttpUser, task, between

class CIFRAUser(HttpUser):
    """Simulated user for load testing"""

    wait_time = between(1, 3)  # Wait 1-3 seconds between tasks

    def on_start(self):
        """Login before tasks"""
        response = self.client.post("/api/auth/login", json={
            "email": "test@example.com",
            "password": "password123"
        })
        self.token = response.json()["access_token"]
        self.headers = {"Authorization": f"Bearer {self.token}"}

    @task(10)  # Weight 10 (more frequent)
    def list_contacts(self):
        """List contacts"""
        self.client.get("/api/contacts", headers=self.headers)

    @task(5)  # Weight 5
    def get_contact(self):
        """Get single contact"""
        self.client.get("/api/contacts/1", headers=self.headers)

    @task(2)  # Weight 2 (less frequent)
    def create_contact(self):
        """Create contact"""
        self.client.post("/api/contacts", json={
            "first_name": "Load",
            "last_name": "Test",
            "email": f"load{self.environment.runner.user_count}@test.com"
        }, headers=self.headers)

    @task(1)
    def search_contacts(self):
        """Search contacts"""
        self.client.get("/api/contacts?search=john", headers=self.headers)

Запуск:

# Start Locust
locust -f tests/performance/locustfile.py --host=http://localhost:8000

# Or headless
locust -f tests/performance/locustfile.py \
    --host=http://localhost:8000 \
    --users 100 \
    --spawn-rate 10 \
    --run-time 5m \
    --headless

5.2 Benchmark Tests

# tests/performance/test_benchmarks.py
import pytest

@pytest.mark.benchmark
def test_contact_creation_speed(benchmark):
    """Benchmark contact creation"""
    def create_contact():
        return Contact(
            first_name="John",
            last_name="Doe",
            email="john@example.com"
        )

    result = benchmark(create_contact)
    assert result.first_name == "John"

@pytest.mark.benchmark
async def test_api_response_time(benchmark, client):
    """Benchmark API response time"""
    async def call_api():
        return await client.get("/api/contacts")

    result = await benchmark(call_api)
    assert result.status_code == 200

6. SECURITY TESTS

6.1 OWASP Top 10

# tests/security/test_owasp.py
import pytest

class TestSQLInjection:
    """Test SQL Injection prevention"""

    async def test_sql_injection_in_search(self, client, auth_token):
        """Should not be vulnerable to SQL injection"""
        # Try SQL injection
        response = await client.get(
            "/api/contacts",
            params={"search": "'; DROP TABLE contacts; --"},
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        # Should return 200 (not execute SQL)
        assert response.status_code == 200

        # Verify table still exists
        response = await client.get("/api/contacts", headers={"Authorization": f"Bearer {auth_token}"})
        assert response.status_code == 200

class TestXSS:
    """Test XSS prevention"""

    async def test_xss_in_contact_name(self, client, auth_token):
        """Should escape HTML in contact name"""
        payload = {
            "first_name": "<script>alert('XSS')</script>",
            "email": "xss@test.com"
        }

        response = await client.post(
            "/api/contacts",
            json=payload,
            headers={"Authorization": f"Bearer {auth_token}"}
        )

        data = response.json()
        # Should be escaped
        assert "&lt;script&gt;" in data["first_name"]

class TestCSRF:
    """Test CSRF protection"""

    async def test_csrf_token_required(self, client):
        """Should require CSRF token for state-changing requests"""
        response = await client.post("/api/contacts", json={
            "first_name": "John"
        })
        # Should fail without CSRF token
        assert response.status_code in [403, 401]

class TestAuthentication:
    """Test authentication security"""

    async def test_password_strength(self, client):
        """Should reject weak passwords"""
        response = await client.post("/api/auth/register", json={
            "email": "test@example.com",
            "password": "123"  # Too weak
        })

        assert response.status_code == 400
        assert "password" in response.json()["detail"].lower()

    async def test_rate_limiting(self, client):
        """Should rate limit login attempts"""
        for i in range(10):
            response = await client.post("/api/auth/login", json={
                "email": "test@example.com",
                "password": "wrong"
            })

        # After 10 attempts, should be rate limited
        response = await client.post("/api/auth/login", json={
            "email": "test@example.com",
            "password": "wrong"
        })

        assert response.status_code == 429  # Too Many Requests

7. TEST CONFIGURATION

7.1 pytest.ini

[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*

# Markers
markers =
    unit: Unit tests (fast, isolated)
    integration: Integration tests (medium speed)
    e2e: End-to-end tests (slow)
    benchmark: Performance benchmarks
    security: Security tests
    slow: Slow tests (mark to skip in CI)

# Coverage
addopts =
    --cov=cifra
    --cov-report=html
    --cov-report=term-missing
    --cov-fail-under=80
    -v
    --tb=short

# Async
asyncio_mode = auto

7.2 conftest.py (главный)

# tests/conftest.py
import pytest
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from httpx import AsyncClient
from cifra.main import app
from cifra.db import Base

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

# Database fixtures
@pytest.fixture(scope="session")
async def test_engine():
    """Create test database engine"""
    engine = create_async_engine("postgresql+asyncpg://test:test@localhost/cifra_test")

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

    yield engine

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

    await engine.dispose()

@pytest.fixture
async def db_session(test_engine):
    """Create fresh DB session for each test"""
    async with AsyncSession(test_engine, expire_on_commit=False) as session:
        yield session
        await session.rollback()

# HTTP client fixture
@pytest.fixture
async def client():
    """HTTP client for API testing"""
    async with AsyncClient(app=app, base_url="http://test") as ac:
        yield ac

8. CI/CD INTEGRATION

8.1 GitHub Actions

# .github/workflows/test.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: cifra_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      redis:
        image: redis:7
        ports:
          - 6379:6379

    steps:
    - uses: actions/checkout@v3

    - name: Set up Python
      uses: actions/setup-python@v4
      with:
        python-version: '3.11'

    - name: Cache dependencies
      uses: actions/cache@v3
      with:
        path: ~/.cache/pip
        key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}

    - name: Install dependencies
      run: |
        pip install -r requirements.txt
        pip install -r requirements-test.txt

    - name: Run unit tests
      run: pytest tests/unit -v -m unit

    - name: Run integration tests
      run: pytest tests/integration -v -m integration
      env:
        DATABASE_URL: postgresql+asyncpg://test:test@localhost:5432/cifra_test
        REDIS_URL: redis://localhost:6379/0

    - name: Run E2E tests
      run: |
        playwright install chromium
        pytest tests/e2e -v -m e2e

    - name: Upload coverage
      uses: codecov/codecov-action@v3
      with:
        file: ./coverage.xml

    - name: Comment PR with coverage
      uses: py-cov-action/python-coverage-comment-action@v3
      with:
        GITHUB_TOKEN: ${{ github.token }}

9. TEST METRICS

9.1 Coverage Reports

# Generate coverage report
pytest --cov=cifra --cov-report=html tests/

# Open report
open htmlcov/index.html

9.2 Test Matrix

Test Coverage (Target: >80%):
  Unit Tests: 85%
  Integration Tests: 75%
  E2E Tests: 60%
  Overall: 82%

Test Execution Time (Target: <5 min CI):
  Unit: 8 seconds ✅
  Integration: 45 seconds ✅
  E2E: 2 minutes ✅
  Total: 2 min 53 sec ✅

Flaky Tests (Target: <1%):
  Current: 0.5% ✅

Test Count:
  Unit: 1600
  Integration: 300
  E2E: 100
  Total: 2000

10. BEST PRACTICES

10.1 DO:

10.2 DON'T:


Версия: 1.0
Дата: 2025-11-10
Статус: Production Ready ✅