Как в Python писать тесты: руководство по unittest

В этом руководстве мы подробно рассмотрим, как использовать unittest для написания тестов, с примерами и объяснениями кода.

Что такое unittest?

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

  • Создание и организация тестов.
  • Проверка ожиданий с помощью утверждений (assertions).
  • Инициализация и очистка окружения перед тестами и после их выполнения.
  • Группировка тестов в тестовые наборы (test suites).
  • Генерация отчетов о результатах тестирования.

Установка и импорт

Так как unittest является частью стандартной библиотеки Python, вам не нужно устанавливать его дополнительно. Вы можете просто импортировать его в коде:

import unittest

Базовый пример теста с использованием unittest

Давайте рассмотрим простой пример. Предположим, у нас есть функция, которая складывает два числа:

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

Теперь мы создадим тест для этой функции с помощью unittest:

import unittest

# Тестируемая функция
def add(a, b):
    return a + b

# Создание класса для тестов
class TestMathFunctions(unittest.TestCase):

    # Метод для тестирования функции add
    def test_add(self):
        self.assertEqual(add(2, 3), 5)  # Проверяем, что 2 + 3 = 5
        self.assertEqual(add(-1, 1), 0)  # Проверяем, что -1 + 1 = 0
        self.assertEqual(add(0, 0), 0)  # Проверяем, что 0 + 0 = 0

# Запуск тестов
if __name__ == '__main__':
    unittest.main()

В этом примере:

  • Мы создали класс TestMathFunctions, который наследует unittest.TestCase. Это позволяет нам использовать все встроенные методы для тестирования.
  • Метод test_add содержит несколько проверок с использованием метода assertEqual, который проверяет, равны ли два значения.
  • unittest.main() используется для запуска всех тестов в модуле.

Утверждения (assertions) в unittest

unittest предлагает широкий набор методов утверждений, которые можно использовать для проверки корректности работы кода. Вот некоторые из них:

  • assertEqual(a, b) — проверяет, что a == b.
  • assertNotEqual(a, b) — проверяет, что a != b.
  • assertTrue(x) — проверяет, что x истинно.
  • assertFalse(x) — проверяет, что x ложно.
  • assertIsNone(x) — проверяет, что x равно None.
  • assertIsNotNone(x) — проверяет, что x не равно None.
  • assertRaises(Exception) — проверяет, что вызывается исключение.

Пример использования различных утверждений:

import unittest

def divide(a, b):
    if b == 0:
        raise ValueError("Division by zero!")
    return a / b

class TestDivision(unittest.TestCase):

    def test_divide(self):
        self.assertEqual(divide(10, 2), 5)
        self.assertTrue(divide(9, 3) == 3)
        self.assertRaises(ValueError, divide, 10, 0)

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

В этом примере:

  • Используем assertEqual для проверки результата функции деления.
  • Используем assertTrue для проверки истинности утверждения.
  • Используем assertRaises для проверки, что при делении на 0 возникает исключение ValueError.

Инициализация и завершение тестов: setUp() и tearDown()

Иногда необходимо выполнять определенные действия перед началом каждого теста и после его завершения. Например, вы можете захотеть создать временные данные или очистить состояние системы. Для этого используются методы setUp() и tearDown().

Пример:

import unittest

class TestSetupTearDown(unittest.TestCase):

    def setUp(self):
        # Этот код выполняется перед каждым тестом
        self.test_data = [1, 2, 3, 4]

    def tearDown(self):
        # Этот код выполняется после каждого теста
        self.test_data = None

    def test_sum(self):
        self.assertEqual(sum(self.test_data), 10)

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

Здесь:

  • setUp() инициализирует список test_data перед каждым тестом.
  • tearDown() очищает ресурсы после завершения каждого теста.

Тестовые наборы (Test Suites)

Если у вас много тестов, вы можете организовать их в тестовые наборы. Это позволяет группировать тесты и запускать их вместе.

Пример создания тестового набора:

import unittest

class TestMath(unittest.TestCase):
    def test_add(self):
        self.assertEqual(1 + 1, 2)

    def test_subtract(self):
        self.assertEqual(5 - 3, 2)

class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual('foo'.upper(), 'FOO')

    def test_isupper(self):
        self.assertTrue('FOO'.isupper())

# Создаем тестовый набор
def suite():
    suite = unittest.TestSuite()
    suite.addTest(TestMath('test_add'))
    suite.addTest(TestMath('test_subtract'))
    suite.addTest(TestStringMethods('test_upper'))
    return suite

if __name__ == '__main__':
    runner = unittest.TextTestRunner()
    runner.run(suite())

Здесь:

  • Мы создали две группы тестов TestMath и TestStringMethods.
  • Метод suite() создает набор тестов и добавляет в него отдельные тестовые методы.
  • unittest.TextTestRunner используется для запуска тестового набора.

Организация тестов в проекте

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

my_project/
│
├── my_module.py
├── tests/
│   ├── __init__.py
│   ├── test_my_module.py

В файле test_my_module.py вы можете разместить тесты для модуля my_module.py. Таким образом, все тесты будут организованы и легко управляемы.

Запуск тестов

Вы можете запускать тесты несколькими способами:

  1. Запустить файл тестов напрямую:
python test_my_module.py
  1. Использовать встроенную команду:
python -m unittest discover

Эта команда автоматически найдет и выполнит все тесты в проекте, которые находятся в файлах, начинающихся на test_.

Исключения и работа с ними в unittest

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

Пример:

import unittest

def divide(a, b):
    if b == 0:
        raise ValueError("Деление на ноль!")
    return a / b

class TestDivision(unittest.TestCase):

    def test_divide_by_zero(self):
        # Проверяем, что выбрасывается исключение с определенным сообщением
        with self.assertRaises(ValueError) as context:
            divide(10, 0)
        self.assertEqual(str(context.exception), "Деление на ноль!")

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

Здесь:

  • Мы используем контекстный менеджер with self.assertRaises, который позволяет захватить исключение и проверить его свойства (например, сообщение об ошибке).

Пропуск тестов и условное выполнение

В некоторых случаях вам может потребоваться пропустить тест или выполнить его только при определенных условиях. В unittest есть декораторы, которые позволяют гибко управлять этим поведением:

  1. @unittest.skip(reason) — пропускает тест с указанием причины.
  2. @unittest.skipIf(condition, reason) — пропускает тест, если условие истинно.
  3. @unittest.skipUnless(condition, reason) — пропускает тест, если условие ложно.

Пример:

import unittest
import sys

class TestSkipping(unittest.TestCase):

    @unittest.skip("Тест временно пропущен")
    def test_temporary_skip(self):
        self.assertEqual(1 + 1, 3)

    @unittest.skipIf(sys.version_info < (3, 8), "Требуется Python 3.8 или выше")
    def test_python_version(self):
        self.assertEqual(2 ** 3, 8)

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

Здесь:

  • Первый тест будет пропущен с комментарием "Тест временно пропущен".
  • Второй тест будет выполнен только если версия Python 3.8 или выше.

Параметризация тестов

Иногда требуется протестировать одну и ту же функцию с разными наборами данных. Для этого удобно использовать параметризацию тестов. Хотя в стандартном unittest нет встроенной поддержки параметризации, это можно реализовать с помощью цикла внутри теста или используя внешние библиотеки, такие как parameterized или ddt.

Пример без использования дополнительных библиотек:

import unittest

def multiply(a, b):
    return a * b

class TestMultiply(unittest.TestCase):

    def test_multiply_with_various_data(self):
        test_cases = [
            (2, 3, 6),
            (1, 5, 5),
            (0, 10, 0),
            (-1, 10, -10),
        ]

        for a, b, expected in test_cases:
            with self.subTest(a=a, b=b, expected=expected):
                self.assertEqual(multiply(a, b), expected)

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

В этом примере:

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

Моки и заглушки: использование unittest.mock

Часто при тестировании приходится заменять реальные объекты на фейковые или заглушки. Например, вы можете захотеть замокировать работу с базой данных или сетевое взаимодействие. Библиотека unittest содержит модуль mock, который помогает создавать такие заглушки.

Основные функции:

  • patch() — используется для замены реального объекта мок-объектом.
  • MagicMock — предоставляет более гибкие возможности для создания мок-объектов, которые могут имитировать поведение реальных объектов.

Пример использования мока:

from unittest import TestCase
from unittest.mock import patch

# Функция, которая использует внешнюю зависимость
def get_user_data_from_api(user_id):
    import requests
    response = requests.get(f'https://api.example.com/users/{user_id}')
    return response.json()

class TestUserData(TestCase):

    @patch('requests.get')
    def test_get_user_data_from_api(self, mock_get):
        # Определяем поведение мок-объекта
        mock_get.return_value.json.return_value = {'id': 1, 'name': 'John Doe'}
        
        # Тестируем функцию
        user_data = get_user_data_from_api(1)
        self.assertEqual(user_data['name'], 'John Doe')

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

Здесь:

  • Мы используем patch() для замены функции requests.get на мок-объект.
  • Мок-объект настроен на возвращение предопределенного результата — словаря с данными пользователя.
  • Это позволяет протестировать функцию без фактического обращения к внешнему API.

Создание собственных утверждений (assertions)

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

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

import unittest

class TestCustomAssertions(unittest.TestCase):

    def assertIsEven(self, number):
        if number % 2 != 0:
            self.fail(f'{number} is not an even number')

    def test_even_numbers(self):
        self.assertIsEven(4)
        self.assertIsEven(10)

    def test_odd_number(self):
        with self.assertRaises(AssertionError):
            self.assertIsEven(3)

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

Здесь:

  • Метод assertIsEven проверяет, является ли число четным.
  • В случае ошибки метод вызывает self.fail(), который генерирует исключение AssertionError.

Тестирование времени выполнения кода

Иногда необходимо убедиться, что функция выполняется за определенное время. Хотя unittest не предоставляет встроенных средств для измерения времени выполнения, это можно легко реализовать с помощью модуля time.

Пример:

import unittest
import time

def slow_function():
    time.sleep(2)

class TestPerformance(unittest.TestCase):

    def test_function_execution_time(self):
        start_time = time.time()
        slow_function()
        elapsed_time = time.time() - start_time
        self.assertLess(elapsed_time, 3, "Функция работает слишком медленно")

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

В этом примере:

  • Мы измеряем время выполнения функции с помощью time.time().
  • Метод assertLess проверяет, что время выполнения меньше 3 секунд.

Генерация отчетов и запуск тестов с различными форматами

unittest по умолчанию генерирует текстовые отчеты, но для интеграции с системами CI/CD часто требуются более формализованные отчеты в формате XML или HTML. Для этого существуют дополнительные библиотеки, такие как unittest-xml-reporting или HtmlTestRunner.

Пример использования HtmlTestRunner:

  1. Установите библиотеку:
pip install html-testRunner
  1. Используйте ее для запуска тестов:
import unittest
import HtmlTestRunner

class TestExample(unittest.TestCase):

    def test_example(self):
        self.assertEqual(1 + 1, 2)

if __name__ == '__main__':
    unittest.main(testRunner=HtmlTestRunner.HTMLTestRunner(output='example_dir'))

Этот код сгенерирует HTML-отчет в папке example_dir после выполнения тестов.

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