Home » Python unittest — пример юнит-теста и руководство
Python unittest — пример юнит-теста и руководство

Python unittest — пример юнит-теста и руководство

Когда твой сервер начинает ломаться от собственных скриптов, а багфиксы превращаются в игру “поймай меня, если сможешь”, пора задуматься о тестировании. Сегодня разберём Python unittest — стандартный инструмент для написания юнит-тестов, который поможет тебе ловить баги раньше, чем они доберутся до продакшена. Особенно это актуально для серверных скриптов, автоматизации и API, где каждый сбой может стоить дорого.

Unittest — это не просто “хорошая практика”, это твоя страховка от того момента, когда в 3 утра тебе звонят с вопросом “а почему сервер не отвечает?”. Мы пройдёмся по основам, посмотрим на практические примеры и разберём, как интегрировать тесты в твой workflow.

Как работает unittest — основы и принципы

Python unittest работает по принципу xUnit — паттерна, который используется в большинстве языков программирования. Основная идея: создаёшь класс, наследующийся от unittest.TestCase, пишешь методы, начинающиеся с test_, и фреймворк автоматически их находит и запускает.

Основные компоненты unittest:

  • TestCase — базовый класс для всех тестов
  • TestSuite — группа тестов
  • TestRunner — запускает тесты и собирает результаты
  • Test fixtures — setUp() и tearDown() для подготовки и очистки

Вот простейший пример:

import unittest

def add_numbers(a, b):
    return a + b

class TestAddNumbers(unittest.TestCase):
    def test_add_positive_numbers(self):
        result = add_numbers(2, 3)
        self.assertEqual(result, 5)
    
    def test_add_negative_numbers(self):
        result = add_numbers(-1, -1)
        self.assertEqual(result, -2)
    
    def test_add_zero(self):
        result = add_numbers(5, 0)
        self.assertEqual(result, 5)

if __name__ == '__main__':
    unittest.main()

Быстрая настройка и первые шаги

Unittest идёт в комплекте с Python, поэтому дополнительных установок не требуется. Но для серверных задач лучше создать отдельную директорию для тестов:

# Создаём структуру проекта
mkdir my_server_project
cd my_server_project
mkdir tests
touch tests/__init__.py

# Создаём основной модуль
touch server_utils.py

# Создаём тест
touch tests/test_server_utils.py

Пример серверного модуля с функциями для тестирования:

# server_utils.py
import subprocess
import psutil
import requests

def check_port_open(port):
    """Проверяет, открыт ли порт"""
    try:
        result = subprocess.run(['netstat', '-tuln'], 
                              capture_output=True, text=True)
        return f":{port}" in result.stdout
    except Exception:
        return False

def get_cpu_usage():
    """Возвращает использование CPU"""
    return psutil.cpu_percent(interval=1)

def ping_service(url, timeout=5):
    """Проверяет доступность сервиса"""
    try:
        response = requests.get(url, timeout=timeout)
        return response.status_code == 200
    except:
        return False

def parse_log_line(line):
    """Парсит строку лога"""
    parts = line.split(' - ')
    if len(parts) >= 3:
        return {
            'timestamp': parts[0],
            'level': parts[1],
            'message': parts[2]
        }
    return None

Теперь создаём тесты:

# tests/test_server_utils.py
import unittest
from unittest.mock import patch, MagicMock
import sys
import os

# Добавляем родительскую директорию в path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from server_utils import check_port_open, get_cpu_usage, ping_service, parse_log_line

class TestServerUtils(unittest.TestCase):
    
    def setUp(self):
        """Выполняется перед каждым тестом"""
        self.sample_log_line = "2024-01-15 10:30:45 - INFO - Server started"
        self.invalid_log_line = "Invalid log format"
    
    def tearDown(self):
        """Выполняется после каждого теста"""
        pass
    
    @patch('subprocess.run')
    def test_check_port_open_success(self, mock_run):
        """Тест проверки открытого порта"""
        mock_run.return_value.stdout = "tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN"
        result = check_port_open(80)
        self.assertTrue(result)
    
    @patch('subprocess.run')
    def test_check_port_open_closed(self, mock_run):
        """Тест проверки закрытого порта"""
        mock_run.return_value.stdout = "tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN"
        result = check_port_open(80)
        self.assertFalse(result)
    
    @patch('psutil.cpu_percent')
    def test_get_cpu_usage(self, mock_cpu):
        """Тест получения CPU usage"""
        mock_cpu.return_value = 25.5
        result = get_cpu_usage()
        self.assertEqual(result, 25.5)
        mock_cpu.assert_called_once_with(interval=1)
    
    @patch('requests.get')
    def test_ping_service_success(self, mock_get):
        """Тест успешного пинга сервиса"""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_get.return_value = mock_response
        
        result = ping_service("http://example.com")
        self.assertTrue(result)
    
    @patch('requests.get')
    def test_ping_service_failure(self, mock_get):
        """Тест неуспешного пинга сервиса"""
        mock_get.side_effect = Exception("Connection error")
        result = ping_service("http://nonexistent.com")
        self.assertFalse(result)
    
    def test_parse_log_line_valid(self):
        """Тест парсинга корректной строки лога"""
        result = parse_log_line(self.sample_log_line)
        expected = {
            'timestamp': '2024-01-15 10:30:45',
            'level': 'INFO',
            'message': 'Server started'
        }
        self.assertEqual(result, expected)
    
    def test_parse_log_line_invalid(self):
        """Тест парсинга некорректной строки лога"""
        result = parse_log_line(self.invalid_log_line)
        self.assertIsNone(result)

if __name__ == '__main__':
    unittest.main()

Запуск тестов и интерпретация результатов

Есть несколько способов запустить тесты:

# Запуск конкретного файла
python -m unittest tests.test_server_utils

# Запуск всех тестов в директории
python -m unittest discover tests

# Запуск с подробным выводом
python -m unittest -v tests.test_server_utils

# Запуск конкретного теста
python -m unittest tests.test_server_utils.TestServerUtils.test_parse_log_line_valid

Для автоматизации на сервере создай скрипт:

# run_tests.sh
#!/bin/bash

echo "Running unit tests..."
python -m unittest discover tests -v

if [ $? -eq 0 ]; then
    echo "All tests passed!"
    exit 0
else
    echo "Tests failed!"
    exit 1
fi

Продвинутые возможности и практические кейсы

Для серверных задач часто нужны более сложные сценарии. Вот несколько полезных паттернов:

Тестирование с базами данных

import unittest
import sqlite3
import tempfile
import os

class TestDatabaseOperations(unittest.TestCase):
    def setUp(self):
        """Создаём временную БД для каждого теста"""
        self.db_fd, self.db_path = tempfile.mkstemp()
        self.conn = sqlite3.connect(self.db_path)
        self.conn.execute('''
            CREATE TABLE users (
                id INTEGER PRIMARY KEY,
                username TEXT NOT NULL,
                email TEXT NOT NULL
            )
        ''')
        self.conn.commit()
    
    def tearDown(self):
        """Удаляем временную БД"""
        self.conn.close()
        os.close(self.db_fd)
        os.unlink(self.db_path)
    
    def test_user_creation(self):
        cursor = self.conn.cursor()
        cursor.execute("INSERT INTO users (username, email) VALUES (?, ?)",
                      ("testuser", "test@example.com"))
        self.conn.commit()
        
        cursor.execute("SELECT * FROM users WHERE username = ?", ("testuser",))
        user = cursor.fetchone()
        self.assertIsNotNone(user)
        self.assertEqual(user[1], "testuser")

Тестирование API эндпоинтов

import unittest
from unittest.mock import patch, MagicMock
import json

class TestAPIEndpoints(unittest.TestCase):
    
    @patch('requests.post')
    def test_api_authentication(self, mock_post):
        """Тест аутентификации API"""
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {"token": "abc123", "expires_in": 3600}
        mock_post.return_value = mock_response
        
        # Здесь был бы вызов твоей функции аутентификации
        # result = authenticate_api("user", "pass")
        # self.assertEqual(result["token"], "abc123")
    
    def test_validate_config(self):
        """Тест валидации конфигурации"""
        valid_config = {
            "server_port": 8080,
            "debug": False,
            "database_url": "sqlite:///test.db"
        }
        
        # Здесь была бы твоя функция валидации
        # self.assertTrue(validate_server_config(valid_config))
        
        invalid_config = {
            "server_port": "invalid",
            "debug": "not_boolean"
        }
        
        # self.assertFalse(validate_server_config(invalid_config))

Assertion методы и их использование

Unittest предоставляет множество assertion методов. Вот самые полезные для серверных задач:

Метод Описание Пример использования
assertEqual(a, b) Проверяет равенство self.assertEqual(status_code, 200)
assertNotEqual(a, b) Проверяет неравенство self.assertNotEqual(error_count, 0)
assertTrue(x) Проверяет истинность self.assertTrue(is_service_running())
assertFalse(x) Проверяет ложность self.assertFalse(has_errors)
assertIsNone(x) Проверяет на None self.assertIsNone(get_user_by_id(999))
assertIn(a, b) Проверяет вхождение self.assertIn(“ERROR”, log_content)
assertRaises(exception) Проверяет исключение self.assertRaises(ValueError, invalid_func)

Интеграция с CI/CD и автоматизация

Для полноценной автоматизации создай конфигурацию для GitHub Actions:

# .github/workflows/tests.yml
name: Run Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: [3.8, 3.9, '3.10']
    
    steps:
    - uses: actions/checkout@v2
    
    - name: Set up Python ${{ matrix.python-version }}
      uses: actions/setup-python@v2
      with:
        python-version: ${{ matrix.python-version }}
    
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        pip install -r requirements.txt
    
    - name: Run tests
      run: |
        python -m unittest discover tests -v
    
    - name: Generate coverage report
      run: |
        pip install coverage
        coverage run -m unittest discover tests
        coverage report -m

Для серверов можно настроить pre-commit hook:

# .git/hooks/pre-commit
#!/bin/bash

echo "Running tests before commit..."
python -m unittest discover tests

if [ $? -ne 0 ]; then
    echo "Tests failed. Commit aborted."
    exit 1
fi

echo "All tests passed. Proceeding with commit."

Сравнение с альтернативными решениями

Фреймворк Преимущества Недостатки Когда использовать
unittest Встроенный, стандартный, богатый API Многословный, сложный синтаксис Корпоративные проекты, стандартизация
pytest Простой синтаксис, мощные fixtures Дополнительная зависимость Быстрая разработка, современные проекты
nose2 Расширяемый, совместим с unittest Меньше сообщество Миграция с nose
doctest Документация + тесты Ограниченные возможности Простые функции, примеры в доках

Мониторинг и отчётность

Для серверных задач важно собирать метрики тестов. Создай скрипт для генерации отчётов:

# test_reporter.py
import unittest
import sys
import json
import time
from io import StringIO

class TestReporter:
    def __init__(self):
        self.results = []
    
    def run_tests_with_report(self, test_module):
        """Запускает тесты и генерирует отчёт"""
        # Перехватываем stdout
        old_stdout = sys.stdout
        sys.stdout = captured_output = StringIO()
        
        # Запускаем тесты
        loader = unittest.TestLoader()
        suite = loader.loadTestsFromModule(test_module)
        
        start_time = time.time()
        runner = unittest.TextTestRunner(stream=captured_output, verbosity=2)
        result = runner.run(suite)
        end_time = time.time()
        
        # Восстанавливаем stdout
        sys.stdout = old_stdout
        
        # Формируем отчёт
        report = {
            "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
            "total_tests": result.testsRun,
            "failures": len(result.failures),
            "errors": len(result.errors),
            "duration": round(end_time - start_time, 2),
            "success_rate": round((result.testsRun - len(result.failures) - len(result.errors)) / result.testsRun * 100, 2) if result.testsRun > 0 else 0
        }
        
        return report, captured_output.getvalue()

# Использование
if __name__ == '__main__':
    reporter = TestReporter()
    # report, output = reporter.run_tests_with_report(test_module)
    # print(json.dumps(report, indent=2))

Оптимизация производительности тестов

Для больших серверных проектов производительность тестов критична. Несколько советов:

  • Используй setUp/tearDown осторожно — они выполняются для каждого теста
  • setUpClass/tearDownClass — для инициализации всего класса
  • Мокай внешние зависимости — база данных, API, файловая система
  • Группируй тесты логически — быстрые unit-тесты отдельно от медленных интеграционных
class TestServerPerformance(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """Выполняется один раз для всего класса"""
        cls.test_server = start_test_server()
    
    @classmethod
    def tearDownClass(cls):
        """Выполняется один раз после всех тестов класса"""
        cls.test_server.stop()
    
    def setUp(self):
        """Выполняется перед каждым тестом"""
        self.start_time = time.time()
    
    def tearDown(self):
        """Выполняется после каждого теста"""
        duration = time.time() - self.start_time
        if duration > 1.0:  # Предупреждение о медленных тестах
            print(f"Warning: {self._testMethodName} took {duration:.2f}s")

Интеграция с системами мониторинга

Для серверных сред полезно интегрировать результаты тестов с системами мониторинга:

# monitoring_integration.py
import unittest
import requests
import os

class MonitoringTestResult(unittest.TestResult):
    def __init__(self):
        super().__init__()
        self.webhook_url = os.getenv('SLACK_WEBHOOK_URL')
    
    def addError(self, test, err):
        super().addError(test, err)
        self.notify_error(test, err)
    
    def addFailure(self, test, err):
        super().addFailure(test, err)
        self.notify_failure(test, err)
    
    def notify_error(self, test, err):
        if self.webhook_url:
            message = f"🔴 Test ERROR: {test._testMethodName}\n```{err[1]}```"
            self.send_notification(message)
    
    def notify_failure(self, test, err):
        if self.webhook_url:
            message = f"🟡 Test FAILURE: {test._testMethodName}\n```{err[1]}```"
            self.send_notification(message)
    
    def send_notification(self, message):
        try:
            requests.post(self.webhook_url, json={"text": message})
        except:
            pass  # Не ломаем тесты из-за проблем с уведомлениями

Нестандартные способы использования

Unittest можно использовать не только для тестирования кода, но и для:

Мониторинг инфраструктуры

class TestInfrastructure(unittest.TestCase):
    def test_disk_space(self):
        """Проверяем свободное место на диске"""
        import shutil
        free_bytes = shutil.disk_usage('/').free
        free_gb = free_bytes / (1024**3)
        self.assertGreater(free_gb, 10, "Мало свободного места на диске")
    
    def test_memory_usage(self):
        """Проверяем использование памяти"""
        import psutil
        memory = psutil.virtual_memory()
        self.assertLess(memory.percent, 90, "Высокое использование памяти")
    
    def test_critical_services(self):
        """Проверяем критические сервисы"""
        critical_services = ['nginx', 'postgresql', 'redis']
        for service in critical_services:
            with self.subTest(service=service):
                result = os.system(f'systemctl is-active {service}')
                self.assertEqual(result, 0, f"Сервис {service} не запущен")

Валидация конфигурации

class TestConfiguration(unittest.TestCase):
    def setUp(self):
        with open('/etc/myapp/config.json') as f:
            self.config = json.load(f)
    
    def test_required_settings(self):
        """Проверяем обязательные настройки"""
        required = ['database_url', 'secret_key', 'allowed_hosts']
        for setting in required:
            with self.subTest(setting=setting):
                self.assertIn(setting, self.config)
                self.assertNotEqual(self.config[setting], "")
    
    def test_security_settings(self):
        """Проверяем настройки безопасности"""
        self.assertFalse(self.config.get('debug', False))
        self.assertTrue(self.config.get('use_https', False))
        self.assertGreater(len(self.config.get('secret_key', '')), 32)

Развёртывание и хостинг

Для запуска тестов на production-серверах нужна правильная инфраструктура. Если ты ещё не определился с хостингом, рекомендую посмотреть на VPS решения для разработки и тестирования, или выделенные серверы для больших проектов с высокой нагрузкой.

Для автоматизации тестов на сервере создай systemd service:

# /etc/systemd/system/app-tests.service
[Unit]
Description=Application Tests
After=network.target

[Service]
Type=oneshot
User=www-data
WorkingDirectory=/var/www/myapp
ExecStart=/usr/bin/python -m unittest discover tests -v
Environment=PYTHONPATH=/var/www/myapp

[Install]
WantedBy=multi-user.target

И timer для регулярного запуска:

# /etc/systemd/system/app-tests.timer
[Unit]
Description=Run Application Tests
Requires=app-tests.service

[Timer]
OnCalendar=*:0/30  # Каждые 30 минут
Persistent=true

[Install]
WantedBy=timers.target

Полезные ссылки и ресурсы

Для углублённого изучения рекомендую:

Заключение и рекомендации

Unittest — это мощный инструмент, который должен быть в арсенале каждого, кто работает с серверным Python-кодом. Да, он может показаться многословным по сравнению с pytest, но его стандартность и богатый функционал делают его отличным выбором для корпоративных проектов и долгосрочной поддержки.

Основные рекомендации:

  • Начни с простого — не пытайся покрыть тестами весь код сразу
  • Тестируй критический функционал — аутентификация, обработка данных, API
  • Используй моки для внешних зависимостей — не тестируй чужой код
  • Автоматизируй запуск тестов — в CI/CD, pre-commit hooks, cron
  • Следи за производительностью — медленные тесты никто не будет запускать
  • Интегрируй с мониторингом — падающие тесты должны быть видны

Unittest особенно эффективен для серверных задач: тестирования API, валидации конфигурации, проверки инфраструктуры и мониторинга сервисов. Правильно настроенные тесты сэкономят тебе часы отладки и помогут спать спокойно, зная, что твой код работает как надо.

Помни: тесты — это не overhead, это инвестиция в стабильность и скорость разработки. Каждый найденный в тестах баг — это потенциальный аутейдж, которого удалось избежать.


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

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

Leave a reply

Your email address will not be published. Required fields are marked