Код
#статьи

Mock-тестирование: что это, для чего нужно и как проводится

Создаём полезные подделки на Python.

Иллюстрация: Оля Ежак для Skillbox Media

Mock-тестирование — это почти то же самое, что и автомобильный краш-тест, только вместо антропоморфных болванчиков инженеры используют тестовые двойники — моки. В этой статье мы расскажем, что такое моки, как их создают и почему они иной раз могут навредить. А заодно проведём mock test — потренируемся в написании собственных программных двойников.

Содержание:


Что такое Mock-тестирование

Mock-тестирование — это испытание программы, при котором реальные её компоненты заменяются «дублёрами» — тестовыми объектами. Ими могут быть фейковые базы данных, почтовые серверы и другие сложные системы. Тестовые объекты лишь подражают настоящим, но не содержат реальной логики или данных.

Существует два основных вида тестовых объектов — моки и стабы, у которых тоже есть несколько разновидностей. Такая система может показаться запутанной, но не переживайте — сейчас во всём разберёмся.

Мок — это тестовый объект, который помогает имитировать исходящие зависимости (команды). «Исходящие» означает, что программа обращается к другим системам, чтобы получить или изменить какие-то данные.

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

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

Стаб — это тестовый объект, который имитирует внешние воздействия (запросы). Допустим, мы написали класс, который получает данные о погоде из другого сервиса и возвращает их в приложение. Чтобы во время тестов не отправлять реальные запросы сервису, можно написать стаб — заглушку, которая будет формировать запрос и возвращать фиксированное значение температуры.

У самих моков и стабов, в свою очередь, тоже есть свои разновидности — но различия между ними совсем незначительные и касаются в основном нюансов реализации. Например, spy — это вид мока, написанный вручную, без помощи готовых инструментов (о которых мы поговорим чуть дальше).

Перечислим и основные виды стабов:

  • Dummy — упрощённая версия стаба, которая используется как заполнитель. Поведение Dummy игнорируется, он нужен, чтобы удовлетворить требованиям компилятора. Простой пример dummy — популярная новичковская программа «Hello, world».
  • Fake — объект посложнее. Он не просто заполняет место в коде, но и умеет возвращать разные значения в зависимости от сценария. Пример фейка — тестовая коллекция, которая имитирует работу базы данных.

Независимо от того, какой вид тестового объекта вы используете: mock, spy, stub, fake или dummy, в процессе тестирования их всё равно будут называть моками — так уж сложилось. Но вы теперь знаете, что под этим термином могут скрываться самые разные виды тестовых болванчиков.

Когда стоит использовать Mock

В мире разработки сложилось общее мнение, что заменять на моки стоит только такие системы, которые не являются частью программы, но с которыми она может проводить манипуляции — например, базы данных, почтовые серверы, файловые системы, шины сообщений и так далее. По-научному такие системы зовутся довольно негуманно: «изменяемые внепроцессорные зависимости».

Действительно, нет никакого смысла заменять на моки зависимости, которые и так находятся внутри приложения. Тесты, написанные для испытания таких объектов, получатся хрупкими — то есть нацеленными на детали исполнения, а не на конечный результат. Гораздо лучше написать готовый объект и протестировать неповторимый оригинал вместо жалкого подобия.

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

Более совершенную модель использования моков предлагает Владимир Хориков в своей статье When to Mock (есть перевод на «Хабре»). В ней он приходит к выводу, что мокать стоит только неуправляемые внепроцессорные зависимости — то есть те, над которыми у вас нет непосредственного контроля.

Допустим, мы тестируем работу класса PaymentProcessor, который обрабатывает входящие платежи. Для обработки платежей он использует внешний платёжный шлюз. Шлюз описан в классе PaymentGateway, к которому у нас нет доступа.

Платёжный шлюз PaymentGateway — это неуправляемая зависимость, потому что он взаимодействует с внешними платёжными системами, к которым у нас нет доступа. А так как мы не хотим тратить настоящие деньги, чтобы протестировать платёжный шлюз, имеет смысл создать для его имитации мок-объект.

Тут есть сразу две выгоды:

  • Мы изолируем свой класс от внешних систем. Наша цель — проверить, как PaymentProcessor обрабатывает платежи, а не как работают внешние платёжные системы. Мокирование PaymentGateway изолирует тестирование PaymentProcessor от реальных платёжных систем.
  • Мы сможем реализовать любые тестовые сценарии. Например, настроить мок PaymentGateway так, чтобы он эмулировал ответы платёжных систем для успешных и неуспешных платежей.

Стоит оговориться, что мокать внутренние компоненты тоже иногда допустимо. Например, вы собираетесь добавить компонент Б, чтобы протестировать компонент А, но это почему-то невозможно сделать сейчас — например, тимлид ещё не успел определиться с библиотекой или нужно долго ковыряться в JSON. В этом случае допустимо мокнуть Б, чтобы работа над А не простаивала. Но лучше таким подходом не злоупотреблять.

Инструменты для mock-тестирования

С теорией разобрались, настало время перейти к практике. Для начала соберём базовый набор инструментов для mock-тестирования на Python.

Unittest

Unittest — это модуль стандартной библиотеки Python. Внутри есть фреймворк для создания и запуска тестов. С его помощью можно создавать мок-объекты, которые имитируют поведение зависимых компонентов и помогают изолировать тестируемый код. Нельзя лишь имитировать внешние сервисы.

Плюсы: простой синтаксис для создания моков.

Минусы: ограниченная имитация внешних серверов, так как Unittest не предоставляет удобных средств для создания мок-серверов и эмуляции внешних HTTP-сервисов.

Pytest.mock

Pytest.mock — это модуль библиотеки Pytest. В ней гораздо больше возможностей для создания мок-объектов, чем в Unittest, но и пользоваться ей сложнее.

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

Минусы: синтаксис сложноват для новичков.

Postman

Postman — это готовый инструмент для тестирования API и создания HTTP-запросов. Умеет создавать мок-серверы для эмуляции поведения API — удобно тестировать взаимодействие с внешними сервисами.

Плюсы:

  • Интуитивно понятный интерфейс для создания HTTP-запросов и тестирования API.
  • Возможность создания мок-серверов для эмуляции поведения API.
  • Хорошая интеграция с различными сервисами и инструментами для тестирования.
  • Независимость от языка программирования.

Минусы:

  • Не подходит, если нужно мокать что-то, кроме API и HTTP-запросов.
  • В бесплатной версии доступно только 1000 моков в месяц.

Готовимся к работе

В следующих разделах мы попробуем создать несколько моков для имитации двух видов зависимостей — внешней и внутренней. Но для начала нам необходимо настроить всю необходимую инфраструктуру.

В первую очередь мы предполагаем, что у вас уже установлен Python. Если нет, то установите — скачайте инсталлятор с официального сайта. Установить его не сложнее, чем «Яндекс Браузер», — просто следуйте инструкциям. Чтобы проверить, что всё установилось как надо, откройте командную строку (на Windows это делается с помощью комбинации Win + R) и введите:

py --version

Если Pyton установлен, вы увидите его версию:

Скриншот: Skillbox Media

В противном случае cmd просто не поймёт, чего вы хотите:

Скриншот: Skillbox Media

Мы настоятельно рекомендуем воспользоваться интегрированной средой разработки (IDE) — это избавит вас от головной боли прописывания системных путей и импортов. У нас это PyCharm, установить её легко — запустите установочный файл с официального сайта и следуйте инструкциям.

Ещё мы покажем, как создавать моки в Postman. Если вы больше тестировщик, чем разработчик, то вы рано или поздно с ним столкнётесь. Установить Postman тоже проще простого — скачайте установочный файл с официального сайта, а дальше он вас сориентирует.

Mock-тестирование с внутренними зависимостями

Идея такая — нам надо протестировать обработчик заказов.

Создайте проект test_example — при работе в PyCharm так же будет называться его корневая папка. Внутри создайте файл order.py — то, что мы будем тестировать:

class NotificationService:
    def send_notification(self, user_id, message):
        # Код для отправки уведомления
        pass

class Order:
    def __init__(self, user_id, product_name):
        self.user_id = user_id
        self.product_name = product_name
        self.notification_service = NotificationService()
    
    def process_order(self):
        # Обработка заказа
        message = f"Your order for {self.product_name} has been processed."
        self.notification_service.send_notification(self.user_id, message)

Здесь у нас описаны два класса — NotificationService и Order, первый отвечает за уведомления, а второй — непосредственно за обработку заказа. Видим, что один из методов Order использует NotificationService. Но представим, что прописать NotificationService мы сейчас не можем, а протестировать Order очень нужно. Поэтому напишем мок-объект для класса NotificationService. Для этого создайте в папке test example папку tests, а в ней — файл test_order.py:

import unittest
from unittest.mock import Mock
from order import Order, NotificationService

class TestOrder(unittest.TestCase):
    def test_process_order(self):
        mock_notification_service = Mock(spec_set=NotificationService)
        order = Order(user_id=123, product_name="Product")
        order.notification_service = mock_notification_service

        order.process_order()

        mock_notification_service.send_notification.assert_called_once_with(
            123, "Your order for Product has been processed."
        )

        print("Test passed successfully!")


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

В примере выше мы создали мок-объект mock_notification_service. Для этого мы использовали spec_set — список методов и атрибутов, которые мы хотели бы видеть у мока. Мы явно указали, что мок будет иметь только те методы и атрибуты, которые определены в классе NotificationService. То есть буквально одной строчкой мы создали мок со сложной логикой работы.

Далее в тесте проверяем, что метод send_notification вызывается с правильными аргументами. Чтобы запустить test_order.py, нажмите кнопку Run:

Скриншот: Skillbox Media

Тест пройден, если метод send_notification у мок-объекта mock_notification_service вызывается ровно один раз с указанными аргументами. А мы в этом случае получаем такое сообщение:

Скриншот: Skillbox Media

Проведём ещё пару тестов с нашим Order. Добавьте следующие тестовые функции в класс TestOrder:

def test_process_order_different_product(self):
    mock_notification_service = Mock(spec_set=NotificationService)
    order = Order(user_id=456, product_name="Another Product")
    order.notification_service = mock_notification_service

    order.process_order()

    mock_notification_service.send_notification.assert_called_once_with(
        456, "Your order for Another Product has been processed."
    )

    print("Test passed successfully!")

def test_process_order_empty_product_name(self):
    mock_notification_service = Mock(spec_set=NotificationService)
    order = Order(user_id=789, product_name="")
    order.notification_service = mock_notification_service

    order.process_order()

    mock_notification_service.send_notification.assert_called_once_with(
        789, "Your order for has been processed."
    )

    print("Test passed successfully!")

Вывод должен быть таким:

Но что, если наш тестовый класс просто сообщает хорошие новости вне зависимости от фактического успеха? Давайте проверим. Добавьте в TestOrder намеренно провальную тестовую функцию:

def test_process_order_failure(self):
    mock_notification_service = Mock(spec_set=NotificationService)
    order = Order(user_id=123, product_name="Product")
    order.notification_service = mock_notification_service

    order.process_order()

    # Намеренно указываем неправильный user_id
    mock_notification_service.send_notification.assert_called_once_with(
        456, "Your order for Product has been processed."
    )

    print("Test passed successfully!")

Если вы всё сделали правильно, то вывод будет выглядеть так:

Скриншот: Skillbox Media

Mock-тестирование с внешними зависимостями

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

Создайте новый проект MockServerTest. В корневой папке создайте два файла — weather_app и mock_server. Также создайте папку tests. Но сначала напишем класс WeatherServise в файле weather_app:

import requests

class WeatherService:
    def get_temperature(self, city_name):
        response = requests.get(f"адрес мок-сервера")
        data = response.json()
        temperature = data.get("temperature")
        return temperature

У нашего класса всего один метод get_temperature (), который отправляет GET-запрос и получает от сервера ответ. Для наших целей этих возможностей достаточно.

Теперь создадим мок-сервер. Откройте файл mock_server и добавьте туда следующий код:

from http.server import BaseHTTPRequestHandler, HTTPServer
import threading

class MockServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header('Content-type', 'application/json')
        self.end_headers()
        response = '{"temperature": 20}'
        self.wfile.write(response.encode())

class MockServer:
    def __init__(self):
        self.server = None
        self.stop_event = threading.Event()

    def start(self):
        server_address = ('адрес мок-сервера', 8000)
        self.server = HTTPServer(server_address, MockServerHandler)
        print('Mock server started on port 8000')
        while not self.stop_event.is_set():
            self.server.handle_request()

def run_mock_server():
    global mock_server
    mock_server = MockServer()
    mock_server.start()

if __name__ == '__main__':
    run_mock_server()

Далее пишем тест. Создайте в папке tests файл test_weather_app_with_mock_server:

from weather_app import WeatherService
from mock_server import run_mock_server

def test_get_temperature_valid_city(mock_server):
    weather_service = WeatherService()
    temperature = weather_service.get_temperature("city_name")

    expected_temperature = 20
    assert temperature == expected_temperature, f"Expected temperature: {expected_temperature}, Actual temperature: {temperature}"

Осталось сделать так, чтобы наш сервер запустился до теста. Для этого создадим файл с преднастройками. В папке tests создайте файл conftest:

import pytest
from mock_server import run_mock_server

@pytest.fixture
def mock_server():
    import threading
    server_thread = threading.Thread(target=run_mock_server)
    server_thread.start()
    print("Mock server started")
    yield
    print("Mock server stopped")
    server_thread.join()
    print("Test passed")

Конструкция с @ в начале — это фикстура, заранее подготовленный фрагмент данных для теста. Наша фикстура содержит функцию, которая берёт метод run_mock_server, который мы импортировали из нашего MockServerHandler, и запускает его.

Функция test_get_temperature_with_mock создаёт экземпляр класса WeatherService, импортированного уже из weather_app, и сравнивает заранее подготовленную expected_temperature с тем, что нам ответит сервер.

Теперь откройте Postman и выберите слева вкладку Mock Servers и нажмите Create Mock Server.

Скриншот: Skillbox Media

На следующем экране оставьте метод без изменений (GET), в поле url укажите weather, код оставьте без изменений (200), в response body вставьте JSON-подобную конструкцию. У нас будет просто {"temperature»: 20}. Нажмите Next.

Скриншот: Skillbox Media

Укажите имя сервера. Остальные поля не трогайте. Нажмите Create Mock Server.

Скриншот: Skillbox Media

Сервер готов — его имя отображается слева. Теперь нам нужен его URL. Чтобы получить его, нажмите Copy URL.

Скриншот: Skillbox Media

Этот URL мы вставим вместо localhost в наши mock_server и weather_app вместо строки адрес мок-сервера.

Запустите weather_app_test_with_mock_server. Вывод должен выглядеть так:

Скриншот: Skillbox Media

Сервер остановится автоматически после теста.

Напишем ещё тест. Добавьте следующую функцию в тестовый файл:

def test_get_temperature_another_valid_city(mock_server):
    weather_service = WeatherService()
    temperature = weather_service.get_temperature("another_city")

    expected_temperature = 20
    assert temperature == expected_temperature, f"Expected temperature: {expected_temperature}, Actual temperature: {temperature}"

Запускаем:

Скриншот: Skillbox Media

Отлично! Теперь напишем провальный тест. Добавьте в тестовый файл следующую функцию:

def test_get_temperature_error_response(mock_server):
    weather_service = WeatherService()
    temperature = weather_service.get_temperature("error_city")

    assert temperature is None, f"Expected temperature: None, Actual temperature: {temperature}"

Добавьте в код сервера (mock_server) возможность вернуть ошибку. Для этого поменяйте метод do_GET в классе MockServerHandler:

class MockServerHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/weather":
            self.send_response(500)
            self.send_header('Content-type', 'application/json')
            self.end_headers()
            response = '{"error": "Something went wrong"}'
            self.wfile.write(response.encode())
        else:
            self.send_response(200)
            self.send_header('Content-type', 'application/json')
            self.end_headers()
            response = '{"temperature": 20}'
            self.wfile.write(response.encode())

Добавьте в начало файла код импорта: from http.server import BaseHTTPRequestHandler.

Проверяем:

Скриншот: Skillbox Media

Всё получилось: то, что не должно работать, — не работает.

Итоги

Поздравляем! Вы научились создавать и использовать моки. Конечно, в реальных тест-кейсах фантазия тестировщика позволяет интереснее и разнообразнее задействовать разные виды тестовых двойников. Мы лишь скромно надеемся, что при следующей встрече с моками вы не растеряетесь и будете понимать, что к чему. А если вам понравилось писать тесты на Python, то рекомендуем подробный и бесплатный курс по Pytest.

Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!

Проверьте свой английский. Бесплатно ➞
Нескучные задания: small talk, поиск выдуманных слов — и не только. Подробный фидбэк от преподавателя + персональный план по повышению уровня.
Пройти тест
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована