Введение в мокинг в Python
Опубликовано: 2022-03-11Как запускать модульные тесты в Python, не испытывая терпения
Чаще всего программное обеспечение, которое мы пишем, напрямую взаимодействует с тем, что мы бы назвали «грязными» сервисами. С точки зрения непрофессионала: службы, которые имеют решающее значение для нашего приложения, но взаимодействие которых имеет намеренные, но нежелательные побочные эффекты, то есть нежелательные в контексте автономного запуска теста.
Например: возможно, мы пишем социальное приложение и хотим протестировать нашу новую функцию «Опубликовать в Facebook», но на самом деле не хотим публиковать сообщения в Facebook каждый раз, когда запускаем наш набор тестов.
Библиотека модульных тестов Python включает в себя подпакет с именем unittest
— или, если вы объявите его как зависимость, просто mock
— который предоставляет чрезвычайно мощные и полезные средства для имитации и unittest.mock
этих нежелательных побочных эффектов.
Примечание. mock
недавно включен в стандартную библиотеку Python 3.3; предыдущие дистрибутивы должны будут использовать библиотеку Mock, загружаемую через PyPI.
Системные вызовы против насмешек Python
Чтобы дать вам еще один пример, который мы будем использовать до конца статьи, рассмотрим системные вызовы . Несложно заметить, что это главные кандидаты для насмешек: пишете ли вы скрипт для извлечения дисковода компакт-дисков, веб-сервер, который удаляет устаревшие файлы кеша из /tmp
, или сервер сокетов, который привязывается к TCP-порту, эти вызывает нежелательные побочные эффекты всех функций в контексте ваших модульных тестов.
Как разработчик, вы больше заботитесь о том, чтобы ваша библиотека успешно вызывала системную функцию для извлечения компакт-диска (с правильными аргументами и т. д.), а не открывала лоток компакт-диска каждый раз при запуске теста. (Или, что еще хуже, несколько раз, поскольку несколько тестов ссылаются на код извлечения во время одного запуска модульного теста!)
Точно так же поддержание эффективности и производительности ваших модульных тестов означает удаление из автоматических тестовых прогонов как можно большего количества «медленного кода», а именно файловой системы и доступа к сети.
В нашем первом примере мы рефакторим стандартный тестовый пример Python из исходной формы в форму с использованием mock
. Мы продемонстрируем, как написание тестового примера с помощью макетов сделает наши тесты умнее, быстрее и позволит больше узнать о том, как работает программное обеспечение.
Простая функция удаления
Нам всем нужно время от времени удалять файлы из нашей файловой системы, поэтому давайте напишем функцию на Python, которая облегчит нашим скриптам это.
#!/usr/bin/env python # -*- coding: utf-8 -*- import os def rm(filename): os.remove(filename)
Очевидно, что наш метод rm
на данный момент предоставляет не намного больше, чем лежащий в основе метод os.remove
, но наша кодовая база улучшится, что позволит нам добавить здесь больше функциональности.
Напишем традиционный тест-кейс, т.е. без моков:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import os.path import tempfile import unittest class RmTestCase(unittest.TestCase): tmpfilepath = os.path.join(tempfile.gettempdir(), "tmp-testfile") def setUp(self): with open(self.tmpfilepath, "wb") as f: f.write("Delete me!") def test_rm(self): # remove the file rm(self.tmpfilepath) # test that it was actually removed self.assertFalse(os.path.isfile(self.tmpfilepath), "Failed to remove the file.")
Наш тестовый пример довольно прост, но каждый раз при его запуске создается временный файл, а затем удаляется. Кроме того, у нас нет возможности проверить, правильно ли наш метод rm
передает аргумент вызову os.remove
. Мы можем предположить , что это так, основываясь на тесте выше, но многое оставляет желать лучшего.
Рефакторинг с Python Mocks
Давайте рефакторим наш тестовый пример, используя mock
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os') def test_rm(self, mock_os): rm("any path") # test that rm called os.remove with the right parameters mock_os.remove.assert_called_with("any path")
Благодаря этим рефакторингам мы коренным образом изменили способ работы теста. Теперь у нас есть инсайдер — объект, который мы можем использовать для проверки функциональности другого объекта.
Потенциальные подводные камни Python
Одна из первых вещей, которая должна выделиться, это то, что мы используем декоратор метода mock.patch
для имитации объекта, расположенного в mymodule.os
, и внедряем этот макет в наш метод тестового примера. Разве не было бы более разумно просто издеваться над самой mymodule.os
os
Что ж, Python довольно хитрая змея, когда дело доходит до импорта и управления модулями. Во время выполнения модуль mymodule
имеет собственную os
, которая импортируется в свою локальную область видимости в модуле. Таким образом, если мы имитируем os
, мы не увидим эффектов мока в модуле mymodule
.
Мантра, которую нужно постоянно повторять, такова:
Издевайтесь над предметом там, где он используется, а не там, где он появился.
Если вам нужно имитировать модуль tempfile
для myproject.app.MyElaborateClass
, вам, вероятно, потребуется применить макет к myproject.app.tempfile
, так как каждый модуль сохраняет свой собственный импорт.
Убрав эту ловушку, давайте продолжим издеваться.
Добавление проверки к «rm»
Определенный ранее метод rm
весьма упрощен. Мы хотели бы, чтобы он проверял, что путь существует и является файлом, прежде чем просто пытаться удалить его вслепую. Давайте рефакторим rm
, чтобы он был немного умнее:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path def rm(filename): if os.path.isfile(filename): os.remove(filename)
Здорово. Теперь давайте изменим наш тестовый пример, чтобы сохранить охват.
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import rm import mock import unittest class RmTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # set up the mock mock_path.isfile.return_value = False rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True rm("any path") mock_os.remove.assert_called_with("any path")
Наша парадигма тестирования полностью изменилась. Теперь мы можем проверять и подтверждать внутреннюю функциональность методов без каких -либо побочных эффектов.
Удаление файлов как услуга с фиктивным патчем
До сих пор мы работали только с предоставлением моков для функций, но не для методов объектов или случаев, когда моки необходимы для отправки параметров. Давайте сначала рассмотрим методы объекта.
Мы начнем с рефакторинга метода rm
в сервисный класс. На самом деле нет оправданной необходимости инкапсулировать такую простую функцию в объект, но это, по крайней мере, поможет нам продемонстрировать ключевые концепции в mock
. Проведем рефакторинг:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(filename): if os.path.isfile(filename): os.remove(filename)
Вы заметите, что в нашем тестовом случае мало что изменилось:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path")
Отлично, теперь мы знаем, что RemovalService
работает, как и планировалось. Давайте создадим еще один сервис, который объявляет его как зависимость:
#!/usr/bin/env python # -*- coding: utf-8 -*- import os import os.path class RemovalService(object): """A service for removing objects from the filesystem.""" def rm(self, filename): if os.path.isfile(filename): os.remove(filename) class UploadService(object): def __init__(self, removal_service): self.removal_service = removal_service def upload_complete(self, filename): self.removal_service.rm(filename)
Поскольку у нас уже есть тестовое покрытие RemovalService
, мы не собираемся проверять внутреннюю функциональность метода rm
в наших тестах UploadService
. Скорее, мы просто проверим (конечно, без побочных эффектов), что UploadService
вызывает метод RemovalService.rm
, который, как мы знаем, «просто работает» из нашего предыдущего теста.

Есть два способа сделать это:
- Макет самого метода
RemovalService.rm
. - Предоставьте имитируемый экземпляр в конструкторе
UploadService
.
Поскольку оба метода часто важны при модульном тестировании, мы рассмотрим оба.
Вариант 1: фиктивные методы экземпляра
В mock
-библиотеке есть специальный декоратор методов для имитации методов и свойств экземпляра объекта, декоратор @mock.patch.object
:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): @mock.patch.object(RemovalService, 'rm') def test_upload_complete(self, mock_rm): # build our dependencies removal_service = RemovalService() reference = UploadService(removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # check that it called the rm method of any RemovalService mock_rm.assert_called_with("my uploaded file") # check that it called the rm method of _our_ removal_service removal_service.rm.assert_called_with("my uploaded file")
Здорово! Мы убедились, что UploadService
успешно вызывает метод rm
нашего экземпляра. Заметили там что-нибудь интересное? Механизм исправления фактически заменил метод rm
всех экземпляров RemovalService
в нашем тестовом методе. Это означает, что мы можем проверять сами экземпляры. Если вы хотите увидеть больше, попробуйте добавить точку останова в свой фиктивный код, чтобы получить представление о том, как работает механизм исправления.
Ловушка с имитацией патча: заказ декоратора
При использовании нескольких декораторов в методах тестирования важен порядок , и это немного сбивает с толку. По сути, при сопоставлении декораторов с параметрами метода работайте в обратном порядке. Рассмотрим этот пример:
@mock.patch('mymodule.sys') @mock.patch('mymodule.os') @mock.patch('mymodule.os.path') def test_something(self, mock_os_path, mock_os, mock_sys): pass
Заметили, как наши параметры сопоставляются с обратным порядком декораторов? Отчасти это связано с тем, как работает Python. С несколькими декораторами методов вот порядок выполнения в псевдокоде:
patch_sys(patch_os(patch_os_path(test_something)))
Поскольку патч для sys
является самым внешним патчем, он будет выполняться последним, что делает его последним параметром в фактических аргументах метода тестирования. Обратите на это внимание и используйте отладчик при запуске тестов, чтобы убедиться, что правильные параметры вводятся в правильном порядке.
Вариант 2. Создание фиктивных экземпляров
Вместо того, чтобы имитировать конкретный метод экземпляра, мы могли бы просто предоставить имитируемый экземпляр в UploadService
с его конструктором. Я предпочитаю вариант 1 выше, так как он намного точнее, но во многих случаях вариант 2 может быть эффективным или необходимым. Давайте снова рефакторим наш тест:
#!/usr/bin/env python # -*- coding: utf-8 -*- from mymodule import RemovalService, UploadService import mock import unittest class RemovalServiceTestCase(unittest.TestCase): @mock.patch('mymodule.os.path') @mock.patch('mymodule.os') def test_rm(self, mock_os, mock_path): # instantiate our service reference = RemovalService() # set up the mock mock_path.isfile.return_value = False reference.rm("any path") # test that the remove call was NOT called. self.assertFalse(mock_os.remove.called, "Failed to not remove the file if not present.") # make the file 'exist' mock_path.isfile.return_value = True reference.rm("any path") mock_os.remove.assert_called_with("any path") class UploadServiceTestCase(unittest.TestCase): def test_upload_complete(self, mock_rm): # build our dependencies mock_removal_service = mock.create_autospec(RemovalService) reference = UploadService(mock_removal_service) # call upload_complete, which should, in turn, call `rm`: reference.upload_complete("my uploaded file") # test that it called the rm method mock_removal_service.rm.assert_called_with("my uploaded file")
В этом примере нам даже не нужно было исправлять какую-либо функциональность, мы просто создаем автоматическую спецификацию для класса RemovalService
, а затем внедряем этот экземпляр в нашу UploadService
для проверки функциональности.
Метод mock.create_autospec
создает функционально эквивалентный экземпляр предоставленному классу. На практике это означает, что при взаимодействии с возвращенным экземпляром будут возникать исключения, если они используются незаконным образом. В частности, если метод вызывается с неправильным количеством аргументов, будет возбуждено исключение. Это чрезвычайно важно, так как происходят рефакторинги. По мере изменения библиотеки тесты ломаются, и это ожидаемо. Без использования автоматической спецификации наши тесты все равно будут проходить, даже если базовая реализация не работает.
Подводный камень: классы mock.Mock
и mock.MagicMock
Библиотека mock
также включает два важных класса, на которых построена большая часть внутренней функциональности: mock.Mock
и mock.MagicMock
. Если у вас есть возможность использовать экземпляр mock.Mock
, экземпляр mock.MagicMock
или автоматическую спецификацию, всегда предпочтительнее использовать автоматическую спецификацию, поскольку она помогает сохранить ваши тесты в нормальном состоянии для будущих изменений. Это связано с тем, что mock.Mock
и mock.MagicMock
принимают все вызовы методов и присвоения свойств независимо от базового API. Рассмотрим следующий вариант использования:
class Target(object): def apply(value): return value def method(target, value): return target.apply(value)
Мы можем проверить это с помощью экземпляра mock.Mock
следующим образом:
class MethodTestCase(unittest.TestCase): def test_method(self): target = mock.Mock() method(target, "value") target.apply.assert_called_with("value")
Эта логика кажется разумной, но давайте изменим метод Target.apply
, чтобы он принимал больше параметров:
class Target(object): def apply(value, are_you_sure): if are_you_sure: return value else: return None
Повторите свой тест, и вы обнаружите, что он все еще проходит. Это потому, что он не создан для вашего фактического API. Вот почему вы всегда должны использовать метод create_autospec
и параметр autospec
с декораторами @patch
и @patch.object
.
Пример имитации Python: имитация вызова API Facebook
Чтобы закончить, давайте напишем более применимый пример имитации Python из реального мира, тот, который мы упоминали во введении: отправка сообщения в Facebook. Мы напишем хороший класс-оболочку и соответствующий тестовый пример.
import facebook class SimpleFacebook(object): def __init__(self, oauth_token): self.graph = facebook.GraphAPI(oauth_token) def post_message(self, message): """Posts a message to the Facebook wall.""" self.graph.put_object("me", "feed", message=message)
Вот наш тестовый пример, который проверяет, публикуем ли мы сообщение, фактически не публикуя сообщение:
import facebook import simple_facebook import mock import unittest class SimpleFacebookTestCase(unittest.TestCase): @mock.patch.object(facebook.GraphAPI, 'put_object', autospec=True) def test_post_message(self, mock_put_object): sf = simple_facebook.SimpleFacebook("fake oauth token") sf.post_message("Hello World!") # verify mock_put_object.assert_called_with(message="Hello World!")
Как мы уже видели, очень просто начать писать более умные тесты с mock
на Python.
Заключение
Библиотека mock
Python, хотя и немного запутанная в работе, меняет правила игры для модульного тестирования. Мы продемонстрировали распространенные варианты использования mock
в модульном тестировании, и мы надеемся, что эта статья поможет разработчикам Python преодолеть начальные препятствия и написать отличный проверенный код.