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

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

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

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

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



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

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

    $ pip install mock
    или
    $ easy_install mock

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

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



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

    from 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 наружу, и в функцию передавать её вывод, но можно сделать по-другому.

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

    from mock import patch
    import itertools  # важно: мы импортируем модуль, не сам метод
    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, который запишет всё, что происходит.

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

    БТС-002

    У объекта 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), где нужно гибкие, и патчи не оставляют за собой следов.

    Ссылки




    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама
    Комментарии 19
    • +1
      Интересная штука, только головоломная очень :-)
      • 0
        Не больше, чем тестирование вообще. Автору спасибо, и отдельное — за макет Бурана)
      • +1
        Очень полезный инструмент. В Python 3.3 будет в стандартной библиотеке как unittest.mock.
        • 0
          > При этом тестируемые функции не нужно адаптировать для тестов

          С каких пор код адаптируют для тестов? Тесты могут указать тебе на архитектурные ошибки и их надо исправлять, но надо же понимать что код модифицируется не для тестов!
          • +1
            В Яве, ПХП и некоторых других языках, если нужно подменить объект внутри функции, придётся сделать так, чтобы в функцию передавались параметры. В Питоне monkey patching позволяет не делать этого.
            • 0
              Мое имхо, это не правильно и не надо этого хотеть. Кстати, не только передавать можно, а еще есть куча способов.
              • +1
                Прекрасно, пишите свою статью.
                • 0
                  Мне кажется это было бы глупо. Я понимаю что Ваша статья не о полезности монкей патчинга, но всё же…
                  • +1
                    Архитектурные вопросы широки и необъятны, можно рассуждать много. :) А почему глупо? Если на статью материал найдётся, она будет умной.
                    • 0
                      Я не про статью :) А про то что вести дискуссию статьями нецелесообразно скажем так)
          • +1
            «В воздухе летал Ту-154 в обвесе, повторяющем форму Бурана»

            А на фото почему-то изображён БТС-002 :)
            • 0
              Да, действительно, это рабочий прототип. Читал на сайте про Буран что был Ту-154, но, возможно, уже плохо помню.
              • +2
                Ту-154ЛЛ («летающая лаборатория») там был, но не как макет «Бурана», а в «натуральном» виде. Хотя и несколько доработанном, чтобы аэродинамика была похожа на бурановскую. Использовался для отработки посадки «Бурана», как ручной, так и автоматической.
          • +1
            У Mock синтаксис бесчеловечный. ╰_╯ Сложилось впечатление, что моки это один и тех редких случаев, где fluent интересы оказываются уместным. У нас прижился лишь flexmock.
            • +1
              Судя по приведенным в документации Flexmock примерам, он сильно смахивает на мой любимый Mox, но уступает последнему в функционале и удобстве использования.
              • +1
                мы раньше использовали Mox, пока не наткнулись на flexmock. :)
                Mox заставляет делать в тестах избыточные шаги .ReplayAll(), .VerifyAll(), что утомляет. если один из них не был вызван (например из-за того, что тестируемый код бросил непредвиденное исключение), то установленные моки могут протекать в последующие тесты, что вызывает трудно диагностируемые глюки. возможно, сейчас это всё уже не актуально. что касается функциональности, то функциональности flexmock мне хватает для повседневных нужд. не хватает, пожалуй только record'а.
            • 0
              >>> [i for i in xrange(3)]
              [0, 1, 2]


              значит функция real() с «пропатченной» функцией permutations напечатает 0 1 3

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