Мониторинг мониторинга - звучит как масло масляное, но именно этим мы постоянно занимаемся в PingZen. Ведь наш сервис должен быть надёжнее, чем объекты, которые он отслеживает. Если PingZen упадёт, кто сообщит о падении вашего сайта?

Сегодня я залезу под капот тестирования PingZen и расскажу, как мы дошли до 10 900 автоматических тестов (5100 на бэкенде и 5812 на фронтенде). Все цифры и технологии - строго из нашего репозитория . Без хайпа, только факты, код и архитектурные решения.А статью подробнее про Pingzen можете посмотреть здесь

Почему тесты - это не роскошь, а necessity для мониторинга

Когда я начинал проект, у меня была иллюзия: «Мониторинг - это просто проверять HTTP-статус, тут ошибиться сложно». Реальность оказалась иной:

  • 22 протокола - от HTTP до Minecraft handshake и DNS over TCP. Каждый имеет свои тонкости (например, UDP-пакеты могут теряться, и это не всегда означает сбой сервера).

  • Сложная логика детекции сбоев - нужно отличать временные сетевые глюки от реального падения. У нас есть настройки confirmation_threshold (сколько неудач подряд нужно для статуса DOWN) и recovery_threshold (сколько успешных для восстановления). Если в этой логике ошибка, пользователи будут получать ложные тревоги или, что ещё хуже, пропускать реальные падения.

  • Метрики и временные ряды - мы храним историю проверок в VictoriaMetrics. Неправильная запись или агрегация метрик приведёт к искажённым графикам и отчётам.

  • Уведомления - алерты должны уходить в Telegram, Slack, Email, Discord, Webhooks без дубликатов и с гарантией доставки. Одно неверное условие - и ночью проснётся вся команда.

  • Масштабирование - сейчас у нас ~191 монитор (активных), но мы готовимся к росту. Тесты должны гарантировать, что при увеличении нагрузки ничего не развалится.

Без автоматических тестов поддерживать такой сервис было бы невозможно. Каждое изменение в коде могло бы что-то сломать, и мы бы узнавали об этом от пользователей. Тесты - это наша страховка и документация одновременно.

Пирамида тестирования в PingZen

Мы следуем классической пирамиде тестирования (по Майклу Койнусу), но адаптировали её под свой стек:

  1. Юнит-тесты (нижний уровень) - изолированно проверяют функции и классы. Их больше всего (около 60% всех тестов).

  2. Интеграционные тесты - проверяют взаимодействие модулей: работа с БД, Redis, VictoriaMetrics.

  3. API-тесты - тестируют эндпоинты FastAPI.

  4. Компонентные тесты фронтенда - изолированно проверяют React-компоненты.

  5. E2E-тесты (верхушка пирамиды) - проверяют критические пользовательские сценарии от начала до конца. Их меньше всего (около 500), но они самые важные.

Такая структура даёт оптимальное соотношение скорости выполнения и уверенности в качестве. Юнит-тесты выполняются за секунды, E2E - за минуты, но каждый уровень ловит свои классы ошибок.

Бэкенд: 5100 тестов на Python 3.13 + FastAPI

Весь бэкенд написан на современном Python с активным использованием asyncio. Для тестирования мы выбрали pytest - он стал стандартом индустрии благодаря своей мощи и экосистеме. В репозитории вы найдёте папку backend/tests с полным покрытием. Тесты организованы по модулям: test_monitorstest_checkstest_notifications и т.д.

Юнит-тесты: база, на которой всё держится

Юнит-тесты изолируют одну единицу кода - функцию или метод класса. Мы используем моки (unittest.mock или pytest-mock), чтобы отрезать зависимости от внешних сервисов. Это позволяет тестам быть быстрыми и детерминированными.

Пример: тестирование парсера для UDP-мониторинга Minecraft

В модуле pinger есть функция, которая формирует «ping-пакет» для Minecraft сервера и парсит ответ. Такие функции критичны: если парсер сломается, пользователи перестанут получать корректные статусы своих игровых серверов.

# mc_parser.py
async def parse_minecraft_response(raw_packet: bytes) -> dict:
    # ... парсинг пакета по протоколу (см. репозиторий)
    return {"players_online": players, "version": version, "motd": motd}

Тест для неё выглядит так:

import pytest
from mc_parser import parse_minecraft_response

def test_parse_valid_mc_response():
    # Подготовка: байтовый пакет, записанный с реального сервера
    raw = b'\x00\xa4\x00...'  # реальный дамп из репозитория
    result = parse_minecraft_response(raw)
    assert result["players_online"] == 42
    assert "Paper 1.20" in result["version"]
    assert "My Server" in result["motd"]

Обратите внимание: мы не ходим в сеть, не поднимаем настоящий Minecraft сервер. Мы используем заранее сохранённый дамп ответа. Это делает тест быстрым и воспроизводимым. Если кто-то случайно изменит парсер, тест упадёт, и мы сразу увидим проблему.

Интеграционные тесты: проверяем связку компонентов

Интеграционные тесты поднимают реальные зависимости: PostgreSQL (в тестовом контейнере), Redis и VictoriaMetrics. Они проверяют, что наш код правильно взаимодействует с этими сервисами.

Пример: тестирование логики подтверждения сбоя

Логика confirmation_threshold и recovery_threshold реализована в ядре pinger и использует Redis для хранения счётчиков неудач. Нам нужно убедиться, что пороги работают именно так, как задумано.

# test_failure_detection.py
import pytest
from unittest.mock import patch

@pytest.mark.asyncio
async def test_confirmation_threshold_works(db_session, redis_client):
    # Создаём монитор с порогом 3
    monitor = await create_test_monitor(db_session, confirmation=3, recovery=2)
    
    # Эмулируем 2 неудачные проверки
    for _ in range(2):
        await run_check(monitor.id, success=False)
    
    # Проверяем, что статус ещё "UP"
    status = await get_monitor_status(monitor.id)
    assert status == "UP"
    
    # Третья неудача
    await run_check(monitor.id, success=False)
    status = await get_monitor_status(monitor.id)
    assert status == "DOWN"  # Порог сработал
    
    # Теперь две успешные проверки
    await run_check(monitor.id, success=True)
    await run_check(monitor.id, success=True)
    status = await get_monitor_status(monitor.id)
    assert status == "UP"  # Восстановился

Этот тест поднимает настоящие БД и Redis, но использует изолированные таблицы (транзакции откатываются). Он гарантирует, что наша бизнес-логика не сломается при изменениях. Без таких тестов мы бы не решились менять что-то в этом механизме.

Тестирование записи метрик в VictoriaMetrics

Метрики по каждому монитору (время ответа, статус, код ошибки) пишутся в VictoriaMetrics. Мы тестируем этот процесс с помощью специального тестового инстанса VictoriaMetrics, поднятого в контейнере.

# test_metrics_writer.py
async def test_metrics_are_written(victoria_client, test_monitor):
    # Запускаем проверку и записываем метрику
    await record_metric(test_monitor.id, response_time=0.42, status="UP")
    
    # Читаем последние метрики через VictoriaMetrics API
    metrics = await victoria_client.query(f'response_time{{monitor="{test_monitor.id}"}}')
    assert len(metrics) > 0
    assert metrics[0]["value"] == 0.42

Так мы уверены, что дашборды и графики не врут. Кстати, для локальной разработки мы используем victoriametrics в docker-compose, что позволяет легко писать и запускать такие тесты.

Тесты API: проверяем эндпоинты

FastAPI предоставляет удобный TestClient на основе Starlette. Мы написали более 500 тестов API, покрывающих все эндпоинты: создание, обновление, удаление мониторов, получение статистики, управление пользователями и т.д.

# test_api_monitors.py
from fastapi.testclient import TestClient

def test_create_monitor(client: TestClient, auth_headers):
    response = client.post(
        "/api/monitors",
        json={
            "name": "My Site",
            "type": "http",
            "target": "https://example.com",
            "interval": 60
        },
        headers=auth_headers
    )
    assert response.status_code == 201
    data = response.json()
    assert data["name"] == "My Site"
    assert "id" in data

Эти тесты не только проверяют корректность ответов, но и валидируют схемы данных (мы используем Pydantic, и тесты помогают убедиться, что сериализация работает правильно). Также они проверяют обработку ошибок: например, что будет, если передать неверный тип протокола.

Тестирование асинхронных воркеров

Самые сложные тесты - для фоновых задач (воркеров), которые запускают проверки и отправляют уведомления. Эти воркеры работают в отдельных процессах и общаются через Redis. Мы используем паттерн «fake-зависимости»: вместо реального SMTP-сервера подставляем тестовый, который сохраняет отправленные письма в память.

# test_notification_worker.py
async def test_telegram_notification_sent():
    # Подготовка: создаём монитор с алертом в Telegram
    monitor = await create_monitor_with_telegram()
    
    # Эмулируем падение монитора
    await trigger_failure(monitor.id)
    
    # Запускаем воркер уведомлений (в тестовом режиме)
    await notification_worker.run_once()
    
    # Проверяем, что в тестовый Telegram-аккаунт пришло сообщение
    sent = await get_fake_telegram_messages()
    assert any("DOWN" in msg.text for msg in sent)

Этот тест проверяет всю цепочку: монитор падает → воркер уведомлений забирает событие → отправляет сообщение в Telegram. Если бы мы не тестировали это, могли бы пропустить ситуацию, когда сообщения не уходят из-за ошибки в форматировании текста.

Отдельный кластер тестов для HCP-сервера

Наш HCP-сервер (тот самый, с 34 инструментами) тоже покрыт тестами. Мы проверяем, что каждая команда (pingtraceroutedigopenssl и даже playwright) возвращает корректный структурированный вывод.

# test_hcp_commands.py
async def test_hcp_ping_parsing(hcp_client):
    result = await hcp_client.execute("ping", ["-c", "3", "8.8.8.8"])
    assert result["parsed"]["loss_percent"] == 0
    assert result["parsed"]["rtt"]["avg"] > 0

Фронтенд: 5812 тестов на React и TypeScript

На фронтенде у нас React + TypeScript. Папка frontend/src/__tests__ содержит тысячи тестов. Мы используем современный стек: JestReact Testing LibraryPlaywright для E2E. Фронтенд-тестирование имеет свою специфику: нужно проверять не только логику, но и отображение, доступность, взаимодействие с пользователем.

Компонентные тесты (Jest + React Testing Library)

Мы тестируем каждый UI-компонент изолированно. Это позволяет быстро выявлять ошибки в вёрстке, пропсах и обработчиках событий. Особое внимание уделяем доступности (accessibility) - у нас реализовано более 20 критериев WCAG 3/4, и каждый проверяется автоматически с помощью jest-axe.

// StatusBadge.test.tsx
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { StatusBadge } from './StatusBadge';

expect.extend(toHaveNoViolations);

test('displays "UP" status with green color', () => {
  render(<StatusBadge status="UP" />);
  const badge = screen.getByText('UP');
  expect(badge).toHaveClass('bg-green-500');
});

test('should have no accessibility violations', async () => {
  const { container } = render(<StatusBadge status="DOWN" />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Первый тест проверяет визуальное оформление (через CSS-класс), второй - что компонент доступен для людей с ограниченными возможностями (например, что у него правильный контраст и ARIA-атрибуты). Мы запускаем такие тесты для всех компонентов, и это помогает держать планку доступности.

Хуки и утилиты

Сложная логика, например форматирование времени аптайма или фильтрация мониторов, вынесена в кастомные хуки и утилиты. Их мы тоже тестируем изолированно.

// useMonitorFilter.test.ts
import { renderHook, act } from '@testing-library/react';
import { useMonitorFilter } from './useMonitorFilter';

test('filters monitors by name', () => {
  const monitors = [{ name: 'Alpha' }, { name: 'Beta' }];
  const { result } = renderHook(() => useMonitorFilter(monitors));
  
  act(() => {
    result.current.setFilter('alp');
  });
  
  expect(result.current.filteredMonitors).toEqual([{ name: 'Alpha' }]);
});

Здесь мы проверяем, что хук правильно обновляет состояние при изменении фильтра. Это особенно важно, потому что такие хуки используются в разных частях приложения, и ошибка в них привела бы к некорректной работе поиска или сортировки.

E2E-тесты с Playwright

Мы не ограничиваемся юнитами. Около 500 E2E-тестов на Playwright проверяют критические пользовательские сценарии: логин (через Telegram, Google, Yandex, Discord - у нас 5 методов авторизации), создание монитора, просмотр статус-страницы, настройку уведомлений.

// create-monitor.spec.ts
import { test, expect } from '@playwright/test';

test('user can create an HTTP monitor', async ({ page }) => {
  await page.goto('/dashboard');
  await page.click('text=Create Monitor');
  await page.fill('[name=name]', 'Habr Test');
  await page.fill('[name=target]', 'https://habr.com');
  await page.selectOption('[name=type]', 'http');
  await page.click('button:has-text("Create")');
  
  await expect(page.locator('text=Monitor created')).toBeVisible();
  await expect(page.locator('text=Habr Test')).toBeVisible();
});

Playwright запускается в CI на трёх браузерах (Chromium, Firefox, WebKit), что гарантирует кроссплатформенность. Отдельные тесты проверяют локализацию (у нас полная поддержка EN/RU). Например, мы проверяем, что при смене языка интерфейса все надписи корректно переводятся.

Инфраструктура тестирования: GitLab CI

Все тесты автоматически запускаются в GitLab CI при каждом пуше в репозиторий. Наш pipeline настроен так:

  1. Линтеры (ruff, eslint, prettier) - быстрая проверка стиля кода.

  2. Юнит-тесты бэкенда - запуск с pytest, параллельно на 4 потока.

  3. Юнит-тесты фронтенда - Jest с --maxWorkers=2.

  4. Интеграционные тесты - требуют PostgreSQL, Redis и VictoriaMetrics, поднимаются в контейнерах (см. docker-compose.test.yml в репозитории).

  5. E2E-тесты - самый долгий этап, но он критичен.

Если любой этап падает, merge request блокируется. У нас есть правило: «ни одного мержа с падающими тестами». Это дисциплинирует и заставляет писать тесты на новые фичи сразу, а не откладывать на потом.

Кстати, мы используем GitLab CI cache для ускорения: кэшируем зависимости Python (pip) и Node.js (node_modules), а также отчёты о покрытии.

Покрытие кода: насколько это много?

Мы измеряем покрытие с помощью pytest-cov (бэкенд) и Jest --coverage (фронтенд). Сейчас показатели такие:

  • Бэкенд: 87% строк покрыто тестами.

  • Фронтенд: 82% (JSX/TSX сложнее покрывать, но мы стараемся).

10 900 тестов - это не самоцель, а следствие подхода «тестируй всё, что может сломаться». Иногда один тест покрывает сотни строк, иногда на одну сложную функцию приходится 10 тестов. Мы не гонимся за 100% покрытием, потому что это часто приводит к тестированию тривиальных геттеров и сеттеров, но стараемся держать планку выше 80%.

Как тесты помогают пользователям PingZen

  1. Никаких регрессий. Мы можем смело обновлять версии Python, FastAPI, React, не боясь, что что-то отвалится. Например, недавно мы обновили FastAPI с 0.68 до 0.115 - тесты помогли найти пару мест, где изменилось поведение валидации.

  2. Новые фичи без страха. Добавить поддержку нового протокола (например, MQTT) - значит сначала написать тесты, потом код. Так мы уверены, что новая фича не сломает старую.

  3. Мгновенная обратная связь разработчикам. Если тесты зелёные, можно деплоить в прод. Мы деплоим несколько раз в день, и это безопасно благодаря тестам.

  4. Документация в действии. Тесты - это лучшая документация: они показывают, как должен работать код. Новый разработчик может заглянуть в тесты и понять ожидаемое поведение функции.

Заключение: приходите убедиться сами

10 900 тестов - это не просто цифра для галочки. Это наша гарантия, что PingZen будет работать стабильно и предсказуемо. Мы вложили тысячи часов в написание этих тестов, чтобы вы могли спать спокойно, зная, что ваш мониторинг не подведёт.

Хотите проверить надёжность на практике? Зарегистрируйтесь на PingZen.dev (это бесплатно для небольших проектов - 55 мониторов, 60-секундный интервал, 6 каналов уведомлений) и создайте свой первый монитор за минуту. А если найдёте баг - напишите нам, мы добавим тест, который его предотвратит 😉