test(generator): добавлен pytest и реорганизованы тесты
- Зачем: - были только standalone скрипты без системы запуска - нужна стандартная система тестирования для CI/CD - Что: - добавлен pytest и pytest-asyncio в requirements.txt - создана директория tests/ с conftest.py (fixtures) - разделены тесты по модулям: test_config, test_generation, test_history - добавлены команды в Makefile: generator-test, generator-test-build, generator-test-cov - удалены устаревшие test_local.py и test_comprehensive.py - обновлена документация в README.md - Проверка: - make generator-test — 23/23 тестов пройдено
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
Pytest fixtures для тестирования генератора.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Добавляем родительскую директорию в путь
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import pytest
|
||||
from generator import Config, EventDictionary
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def data_dir():
|
||||
"""Путь к директории с тестовыми данными."""
|
||||
return Path(__file__).parent.parent.parent / "data"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_dictionary(data_dir):
|
||||
"""Загруженный словарь событий."""
|
||||
return EventDictionary.load(data_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def base_config(data_dir):
|
||||
"""Базовая конфигурация для тестов."""
|
||||
return Config(
|
||||
kafka_bootstrap_servers="localhost:9092",
|
||||
tick_seconds=5,
|
||||
lambda_base_per_min=200,
|
||||
jitter_pct=20,
|
||||
min_events_per_tick=5,
|
||||
max_events_per_tick=50,
|
||||
data_dir=data_dir,
|
||||
seed=42,
|
||||
enabled=True,
|
||||
metrics_port=9109,
|
||||
clickhouse_host="localhost",
|
||||
clickhouse_port=9000,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_no_jitter(base_config):
|
||||
"""Конфигурация без jitter."""
|
||||
from dataclasses import replace
|
||||
return replace(base_config, jitter_pct=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_temp_dir(tmp_path):
|
||||
"""Временная директория с пустыми JSONL файлами."""
|
||||
for fname in ["browser_events.jsonl", "location_events.jsonl",
|
||||
"device_events.jsonl", "geo_events.jsonl"]:
|
||||
(tmp_path / fname).touch()
|
||||
return tmp_path
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Тесты конфигурации генератора.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from generator import Config
|
||||
|
||||
|
||||
class TestConfigValidation:
|
||||
"""Тесты валидации конфигурации."""
|
||||
|
||||
def test_tick_seconds_must_be_positive(self, base_config):
|
||||
"""tick_seconds должен быть >= 1."""
|
||||
from dataclasses import replace
|
||||
with pytest.raises(ValueError, match="GEN_TICK_SECONDS"):
|
||||
replace(base_config, tick_seconds=0)
|
||||
|
||||
def test_lambda_base_must_be_positive(self, base_config):
|
||||
"""lambda_base_per_min должен быть >= 1."""
|
||||
from dataclasses import replace
|
||||
with pytest.raises(ValueError, match="GEN_LAMBDA_BASE_PER_MIN"):
|
||||
replace(base_config, lambda_base_per_min=0)
|
||||
|
||||
def test_data_dir_must_exist(self, base_config):
|
||||
"""data_dir должен существовать."""
|
||||
from dataclasses import replace
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
replace(base_config, data_dir=Path("/nonexistent/path"))
|
||||
|
||||
def test_valid_config_passes(self, base_config):
|
||||
"""Валидная конфигурация создается без ошибок."""
|
||||
assert base_config.tick_seconds == 5
|
||||
assert base_config.lambda_base_per_min == 200
|
||||
assert base_config.jitter_pct == 20
|
||||
|
||||
|
||||
class TestConfigDefaults:
|
||||
"""Тесты значений по умолчанию."""
|
||||
|
||||
def test_default_tick_seconds_is_5(self, data_dir):
|
||||
"""По умолчанию tick_seconds = 5 (rev5)."""
|
||||
import os
|
||||
# Сохраняем текущее значение
|
||||
orig_value = os.environ.get("GEN_TICK_SECONDS")
|
||||
try:
|
||||
if "GEN_TICK_SECONDS" in os.environ:
|
||||
del os.environ["GEN_TICK_SECONDS"]
|
||||
config = Config(
|
||||
kafka_bootstrap_servers="localhost:9092",
|
||||
tick_seconds=int(os.getenv("GEN_TICK_SECONDS", "5")),
|
||||
lambda_base_per_min=200,
|
||||
jitter_pct=20,
|
||||
min_events_per_tick=5,
|
||||
max_events_per_tick=50,
|
||||
data_dir=data_dir,
|
||||
seed=None,
|
||||
enabled=True,
|
||||
metrics_port=9109,
|
||||
clickhouse_host="localhost",
|
||||
clickhouse_port=9000,
|
||||
)
|
||||
assert config.tick_seconds == 5
|
||||
finally:
|
||||
if orig_value is not None:
|
||||
os.environ["GEN_TICK_SECONDS"] = orig_value
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Тесты генерации событий.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from generator import EventGenerator, EventDictionary
|
||||
|
||||
|
||||
class TestEventGeneration:
|
||||
"""Тесты генерации событий."""
|
||||
|
||||
def test_event_dictionary_loads(self, event_dictionary):
|
||||
"""Словарь событий загружается корректно."""
|
||||
assert len(event_dictionary.browser_events) == 1000
|
||||
assert len(event_dictionary.location_events) == 1000
|
||||
assert len(event_dictionary.device_events) == 1000
|
||||
assert len(event_dictionary.geo_events) == 1000
|
||||
|
||||
def test_dictionary_consistency(self, event_dictionary):
|
||||
"""Все записи в словаре имеют корректные связи."""
|
||||
browser_event_ids = {e["event_id"] for e in event_dictionary.browser_events}
|
||||
browser_click_ids = {e["click_id"] for e in event_dictionary.browser_events}
|
||||
|
||||
location_orphaned = sum(
|
||||
1 for loc in event_dictionary.location_events
|
||||
if loc["event_id"] not in browser_event_ids
|
||||
)
|
||||
device_orphaned = sum(
|
||||
1 for dev in event_dictionary.device_events
|
||||
if dev["click_id"] not in browser_click_ids
|
||||
)
|
||||
geo_orphaned = sum(
|
||||
1 for geo in event_dictionary.geo_events
|
||||
if geo["click_id"] not in browser_click_ids
|
||||
)
|
||||
|
||||
assert location_orphaned == 0, f"Found {location_orphaned} orphaned location records"
|
||||
assert device_orphaned == 0, f"Found {device_orphaned} orphaned device records"
|
||||
assert geo_orphaned == 0, f"Found {geo_orphaned} orphaned geo records"
|
||||
|
||||
def test_generate_batch_structure(self, event_dictionary, base_config):
|
||||
"""Батч имеет правильную структуру."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(10)
|
||||
|
||||
assert "browser_events" in batch
|
||||
assert "location_events" in batch
|
||||
assert "device_events" in batch
|
||||
assert "geo_events" in batch
|
||||
|
||||
def test_generate_batch_size(self, event_dictionary, base_config):
|
||||
"""Батч содержит правильное количество событий."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(10)
|
||||
|
||||
assert len(batch["browser_events"]) == 10
|
||||
assert len(batch["location_events"]) == 10
|
||||
assert len(batch["device_events"]) == 10
|
||||
assert len(batch["geo_events"]) == 10
|
||||
|
||||
def test_event_ids_are_new_uuids(self, event_dictionary, base_config):
|
||||
"""event_id и click_id — новые UUID, не из оригинальных данных."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(1)
|
||||
|
||||
original_event_ids = {e["event_id"] for e in event_dictionary.browser_events}
|
||||
original_click_ids = {e["click_id"] for e in event_dictionary.browser_events}
|
||||
|
||||
browser_event = batch["browser_events"][0]
|
||||
assert browser_event["event_id"] not in original_event_ids
|
||||
assert browser_event["click_id"] not in original_click_ids
|
||||
|
||||
# Проверяем, что это валидный UUID
|
||||
uuid.UUID(browser_event["event_id"])
|
||||
uuid.UUID(browser_event["click_id"])
|
||||
|
||||
def test_event_timestamp_format(self, event_dictionary, base_config):
|
||||
"""event_timestamp имеет правильный формат."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(1)
|
||||
|
||||
browser_event = batch["browser_events"][0]
|
||||
timestamp = browser_event["event_timestamp"]
|
||||
|
||||
# Должен парситься как datetime
|
||||
dt = datetime.fromisoformat(timestamp.replace(" ", "T"))
|
||||
assert dt.year >= 2024
|
||||
|
||||
def test_links_consistency(self, event_dictionary, base_config):
|
||||
"""Связи между событиями сохраняются."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(5)
|
||||
|
||||
for i, browser in enumerate(batch["browser_events"]):
|
||||
event_id = browser["event_id"]
|
||||
click_id = browser["click_id"]
|
||||
|
||||
# Location должен иметь тот же event_id
|
||||
assert batch["location_events"][i]["event_id"] == event_id
|
||||
|
||||
# Device и Geo должны иметь тот же click_id
|
||||
assert batch["device_events"][i]["click_id"] == click_id
|
||||
assert batch["geo_events"][i]["click_id"] == click_id
|
||||
|
||||
def test_required_fields_present(self, event_dictionary, base_config):
|
||||
"""Все обязательные поля присутствуют в событиях."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
batch = generator.generate_batch(1)
|
||||
|
||||
browser = batch["browser_events"][0]
|
||||
required_fields = [
|
||||
"event_id", "event_timestamp", "event_type", "click_id",
|
||||
"browser_name", "browser_user_agent", "browser_language"
|
||||
]
|
||||
|
||||
for field in required_fields:
|
||||
assert field in browser, f"Missing field: {field}"
|
||||
|
||||
|
||||
class TestPoissonDistribution:
|
||||
"""Тесты статистической модели."""
|
||||
|
||||
def test_calculate_events_respects_bounds(self, event_dictionary, base_config):
|
||||
"""Расчет количества событий уважает границы."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
|
||||
samples = [generator._calculate_events_count() for _ in range(100)]
|
||||
|
||||
assert all(s >= base_config.min_events_per_tick for s in samples)
|
||||
assert all(s <= base_config.max_events_per_tick for s in samples)
|
||||
|
||||
def test_jitter_increases_variance(self, event_dictionary, base_config, config_no_jitter):
|
||||
"""Jitter увеличивает дисперсию."""
|
||||
gen_with = EventGenerator(event_dictionary, base_config)
|
||||
gen_without = EventGenerator(event_dictionary, config_no_jitter)
|
||||
|
||||
samples_with = [gen_with._calculate_events_count() for _ in range(200)]
|
||||
samples_without = [gen_without._calculate_events_count() for _ in range(200)]
|
||||
|
||||
mean_with = sum(samples_with) / len(samples_with)
|
||||
mean_without = sum(samples_without) / len(samples_without)
|
||||
|
||||
var_with = sum((x - mean_with) ** 2 for x in samples_with) / len(samples_with)
|
||||
var_without = sum((x - mean_without) ** 2 for x in samples_without) / len(samples_without)
|
||||
|
||||
assert var_with > var_without, \
|
||||
f"Jitter should increase variance: {var_with} vs {var_without}"
|
||||
|
||||
def test_mean_is_reasonable(self, event_dictionary, base_config):
|
||||
"""Среднее значение в разумных пределах."""
|
||||
generator = EventGenerator(event_dictionary, base_config)
|
||||
|
||||
samples = [generator._calculate_events_count() for _ in range(500)]
|
||||
mean = sum(samples) / len(samples)
|
||||
|
||||
# Ожидаем: lambda_base * tick_seconds / 60 * hour_factor
|
||||
# hour_factor обычно 0.7-1.2
|
||||
expected_base = base_config.lambda_base_per_min * base_config.tick_seconds / 60.0
|
||||
|
||||
# Допустимое отклонение до 50%
|
||||
assert mean > expected_base * 0.5, f"Mean {mean} too low (expected ~{expected_base})"
|
||||
assert mean < expected_base * 1.5, f"Mean {mean} too high (expected ~{expected_base})"
|
||||
|
||||
|
||||
class TestEmptyData:
|
||||
"""Тесты обработки пустых данных."""
|
||||
|
||||
def test_empty_jsonl_handled(self, empty_temp_dir):
|
||||
"""Пустые JSONL файлы обрабатываются корректно."""
|
||||
dictionary = EventDictionary.load(empty_temp_dir)
|
||||
|
||||
assert len(dictionary.browser_events) == 0
|
||||
assert len(dictionary.location_events) == 0
|
||||
assert len(dictionary.device_events) == 0
|
||||
assert len(dictionary.geo_events) == 0
|
||||
|
||||
def test_generate_batch_with_empty_dict(self, empty_temp_dir, base_config):
|
||||
"""Генерация с пустым словарем (должна работать, но без событий)."""
|
||||
from dataclasses import replace
|
||||
dictionary = EventDictionary.load(empty_temp_dir)
|
||||
config = replace(base_config, data_dir=empty_temp_dir)
|
||||
generator = EventGenerator(dictionary, config)
|
||||
|
||||
# С пустым словарем генерация упадет при choice()
|
||||
# Это ожидаемое поведение — проверяем что падает с IndexError
|
||||
with pytest.raises(IndexError):
|
||||
generator.generate_batch(1)
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Тесты истории батчей.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from generator import InMemoryBatchHistory, BatchRecord
|
||||
|
||||
|
||||
class TestInMemoryBatchHistory:
|
||||
"""Тесты in-memory истории."""
|
||||
|
||||
def test_add_record(self):
|
||||
"""Добавление записи в историю."""
|
||||
history = InMemoryBatchHistory()
|
||||
record = BatchRecord(
|
||||
batch_id="test_1",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
sent_total=100,
|
||||
sent_browser=25,
|
||||
sent_location=25,
|
||||
sent_device=25,
|
||||
sent_geo=25,
|
||||
status="success",
|
||||
error_message=None,
|
||||
)
|
||||
history.add(record)
|
||||
|
||||
stats = history.get_stats()
|
||||
assert stats["total_batches"] == 1
|
||||
assert stats["success_rate"] == 1.0
|
||||
assert stats["last_batch_status"] == "success"
|
||||
|
||||
def test_history_limits_to_1000(self):
|
||||
"""История ограничена 1000 записями."""
|
||||
history = InMemoryBatchHistory()
|
||||
|
||||
for i in range(1100):
|
||||
record = BatchRecord(
|
||||
batch_id=f"batch_{i}",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
sent_total=10,
|
||||
sent_browser=2,
|
||||
sent_location=2,
|
||||
sent_device=2,
|
||||
sent_geo=2,
|
||||
status="success",
|
||||
error_message=None,
|
||||
)
|
||||
history.add(record)
|
||||
|
||||
assert len(history.batches) == 1000
|
||||
assert history.batches[0].batch_id == "batch_100" # Первые 100 удалены
|
||||
|
||||
def test_success_rate_calculation(self):
|
||||
"""Правильный расчет success rate."""
|
||||
history = InMemoryBatchHistory()
|
||||
|
||||
# 3 success, 2 error
|
||||
for i in range(5):
|
||||
record = BatchRecord(
|
||||
batch_id=f"batch_{i}",
|
||||
started_at=datetime.now(timezone.utc),
|
||||
finished_at=datetime.now(timezone.utc),
|
||||
sent_total=100,
|
||||
sent_browser=25,
|
||||
sent_location=25,
|
||||
sent_device=25,
|
||||
sent_geo=25,
|
||||
status="success" if i < 3 else "error",
|
||||
error_message=None if i < 3 else "Test error",
|
||||
)
|
||||
history.add(record)
|
||||
|
||||
stats = history.get_stats()
|
||||
assert stats["total_batches"] == 5
|
||||
assert stats["success_rate"] == 0.6 # 3/5
|
||||
|
||||
def test_empty_history_returns_empty_stats(self):
|
||||
"""Пустая история возвращает пустой dict."""
|
||||
history = InMemoryBatchHistory()
|
||||
stats = history.get_stats()
|
||||
assert stats == {}
|
||||
|
||||
|
||||
class TestBatchRecord:
|
||||
"""Тесты структуры записи."""
|
||||
|
||||
def test_record_creation(self):
|
||||
"""Создание записи с всеми полями."""
|
||||
now = datetime.now(timezone.utc)
|
||||
record = BatchRecord(
|
||||
batch_id="abc123",
|
||||
started_at=now,
|
||||
finished_at=now,
|
||||
sent_total=400,
|
||||
sent_browser=100,
|
||||
sent_location=100,
|
||||
sent_device=100,
|
||||
sent_geo=100,
|
||||
status="partial",
|
||||
error_message="Some errors occurred",
|
||||
)
|
||||
|
||||
assert record.batch_id == "abc123"
|
||||
assert record.sent_total == 400
|
||||
assert record.status == "partial"
|
||||
assert record.error_message == "Some errors occurred"
|
||||
Reference in New Issue
Block a user