Модуль Mock: макеты-пустышки в тестировании

в 7:37, , рубрики: django, django framework, mock, python, tdd, тестирование, метки: , , , , ,

Mock на английском значит «имитация», «подделка». Модуль с таким названием помогает сильно упростить тесты модулей на Питоне.

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

Такое поведение — уже не надувные ракетные установки, а целая надувная земля, вокруг которой могут летать испытуемые ракеты и самолёты.

Российские надувные макеты ракетных и радарных установок

На Хабре пакет Mock упоминали только в одном комментарии. В принципе, подменять объекты в других модулях, что называется monkey patch, нам иногда в работе приходится. Но в отличие от ручных патчей, Mock умеет делать очень сложные подстановки, пачками и цепочками вызова, а также убраться за собой, не оставляя побочных эффектов.

Пакет занимает меньше 1 мегабайта и устанавливается очень просто:

$ pip install mock
или
$ easy_install mock

И теперь им можно пользоваться.

Подмена функции

Скажем, наша функция считает что-то, причём очень долго:

import itertools import permutations

def real(name):
	if len(name) < 10:
		raise ValueError('String too short to calculate statistics.')

	y = 0
	for i in permutations(xrange(len(name)), 10):
		y += i
		print y

Пример надуманный и примитивный, но что-то подобное может встретиться: внутри делаются большие вычисления, которые не хотелось бы повторять в тесте. И в данном примере хочется ввести строку покороче (чтобы число повторений в permutations, len(name), было меньше), но это запрещено. Можно было бы разбить функцию на две, вынести вызов permutations наружу, и в функцию передавать её вывод, но можно сделать по-другому.

Вместо переписывания кода, мы можем просто «пропатчить» функцию premutations на время вызова, задать только определённый вывод и вызвать какой-то код:

from mock import patch
import itemrtools  # важно: мы импортируем модуль, не сам метод
name = 'достаточно длинное имя'

>>> with patch('itertools.permutations') as perm_mock:
...	perm_mock.return_value = xrange(3)
...	real(name)

1
3
6

Заметьте: print вызван вместо 42! / (42 — 10)! раз всего 3, то есть цикл пробежался по xrange(3), который мы подставили.

Кроме того, после выхода из контекста with функция itertools.permutations вернулась в своё нормальное состояние.

Отладочная информация

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

Похожий пример из жизни: когда шли аэродинамические испытания Бурана, весь корабль не продували в трубе (таких не бывает) и не запускали в атмосферу. В воздухе летал Ту-154 в обвесе, повторяющем форму Бурана:

Ту-154 переделанный в аэродинамический макет Бурана

У объекта Mock есть несколько атрибутов с информацией о вызовах:

  • called — вызывался ли объект вообще
  • call_count — количество вызовов
  • call_args — аргументы последнего вызова
  • call_args_list — список всех аргументов
  • method_calls — аргументы обращений к вложенным методам и атрибутам (о них — ниже)
  • mock_calls — то же самое, но вместе и для самого объекта, и для вложенных

В нашем примере выше можно убедиться, что функция real() правильно вызывает permutations. Чтобы ещё точнее проверить, например, в автоматических тестах, можно вызвать один из методов assert_*:

perm_mock.assert_called_with(xrange(len(name)), 10)

Синтаксический сахар

Для юнит-тестов в Джанго пригодится то, что patch работает как декоратор:

@patch('itertools.permutations')
def test(ip):
	ip.return_value = range(5)
	print list(itertools.permutations(xrange(10), 10))

>>> test()
[0, 1, 2, 3, 4]

Макеты атрибутов и цепочек вызова

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

mock_file = Mock()
mock_file.name = 'my_filename.doc'
mock_file.__iter__.return_value = ['строка 1', 'строка 2', 'строка 3']
stat = mock_file.stat.return_value
stat.size, stat.access_time = 1000, datetime(2012, 1, 1)

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

>>> mock_a = Mock(name='макет файла')
>>> mock_a
<Mock name='макет файла' id='169542476'>

Как же эти цепочки атрибутов работают?

>>> m = Mock()
>>> m
<Mock id='167387660'>

>>> m.any_attribute
<Mock name='mock.any_attribute' id='167387436'>

>>> m.any_attribute
<Mock name='mock.any_attribute' id='167387436'>

>>> m.another_attribute
<Mock name='mock.another_attribute' id='167185324'>

Как видите, обращение к атрибуту выдаёт ещё один экземпляр класса Mock, а повторное обращение к тому же атрибуту — снова тот же экземпляр. Атрибут может быть чем угодно, в том числе и функцией. Наконец, любой макет можно вызвать (скажем, вместо класса):

>>> m()
<Mock name='mock()' id='167186284'>

>>> m() is m
False

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

Если мы назначим атрибуту значение, то никаких сюрпризов: при следующем обращении получим именно это значение:

>>> m.any_attribute
<Mock name='mock.any_attribute' id='167387436'>
>>> m.any_attribute = 5
>>> m.any_attribute
5

Полезно прочесть в документации класса, какие атрибуты у него родные, чтобы не случалось коллизии названий.

Как ограничить гибкость макета

Как видите, можно обращаться к любому атрибуту макета, не вызывая ошибки AttributeError. У этого удобства есть обратная сторона: что если мы поменяем API, например, переименуем метод, а функция, работающая с нашим классом, будет обращаться к прежнему методу? Код на самом деле не работает, а тест выполняется без ошибок. Для этого можно задать спецификацию объекта в параметре spec (либо классу Mock, либо patch), и макет будет вызывать ошибку при обращении к несуществующим свойствам:

class Tanchik(object):
	model = 'T80'
	def shoot(self, target):
		print 'Бдыщь!'

def tank_sugar(target):
	print '%s стреляет' % tank.model
	tank.shoot(target)
	return tank

==================
import tanks

@patch('tanks.Tanchik', spec=tanks.Tanchik)  # <<== задано свойство spec
def test_tank(tank_mock):
	assert isinstance(tank_sugar(tank_mock), tanks.Tanchik)

Теперь если мы переименуем model или shoot у танчика, но забудем исправить tank_sugar, тест не выполнится.

Как сделать макет умнее

Хорошо, допустим, Mock умеет заменять нужные объекты на ненужные и подменять вывод. А можно ли подменить функцию на что-то более сложное, чем значение (return_value)? Есть 2 пути:

  • если нужно переопределить много методов у экземпляра или класса, наследуем от класса Mock
  • если нужно заменить только один вызов (метод или сам класс), используем side_effect.
def simple_function(args):
	do_something_useful()

with patch('module.BigHeavyClass', side_effect=simple_function) as mock_class:
	another_class.take(mock_class)

Кстати, не нужно писать в simple_function контрольный вывод, потому что, как сказано выше, в конце кода в объекте mock_class можно считать method_calls.

Подмена встроенных функций

Сам по себе Mock не может заменить встроенные в язык функции (len, iter), но может сделать макет с нужными «волшебными» функциями. Например, вот мы делаем макет файла:

>>> mock = Mock()
>>> mock_bz2module.open.return_value.__enter__ = Mock(return_value='foo')
>>> mock_bz2module.open.return_value.__exit__ = Mock(return_value=False)
>>> with mock_bz2module.open('test.bz2') as m:
...     assert m == 'foo'

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

Где Mock не работает

Сам автор модуля, Майкл Фурд, говорит, что принцип, где нужны макеты, а где нет, простой: если с макетами тестировать проще, их надо использовать, а если с ними труднее, надо отказаться.

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

По моему личному опыту, Mock конфликтовать с отладчиками, например, в PuDB случалась бесконечная рекурсия. IPDB работал нормально, поэтому тесты проекта мы выполняли с IPDB, а просто код — на PuDB.

Выводы

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

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

Настройки макетов где нужно жёсткие (spec), где нужно гибкие, и патчи не оставляют за собой следов.

Ссылки

Автор: siberiano

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js