Мокинг оленя

Основы работы с мокингом в Python

Мокинг (mocking) – это замена тестируемой части приложения фиктивной версией этой части, называемой мок (англ. mock – “имитация”). Вместо того чтобы вызывать реальную функцию, вы будете вызывать мок, а затем делать утверждения о том, что должно произойти.

Друзья, поддержите нас вступлением в наш телеграм канал QaRocks. Там много туториалов, задач по автоматизации и книг по QA.

Каковы преимущества мокинга?

  • Увеличение скорости. Тесты, которые выполняются быстро, чрезвычайно полезны. Например, если у вас есть очень ресурсоемкая функция, мок этой функции позволит сократить ненужное использование ресурсов во время тестирования, что уменьшит время выполнения теста.
  • Предотвращение нежелательных побочных эффектов во время тестирования. Если вы тестируете функцию, которая выполняет вызовы внешнего API, вы можете не захотеть выполнять реальный вызов API при каждом запуске тестов. Вам придется менять свой код каждый раз, когда API меняется, или могут быть какие-то ограничения по скорости, но мокинг поможет вам избежать этого.

Примечание редакции: также рекомендуем почитать статью “Мокинг данных с Jest”.

Предусловия

Вам потребуется установить Python 3.3 или выше. Скачайте необходимую версию для вашей платформы здесь. В этом уроке будет использоваться версия 3.6.0.

После установки настройте виртуальную среду:

python3 -m venv venv

Активируйте виртуальную среду, выполнив команду:

source venv/bin/activate

После этого добавьте файл main.py, в котором будет находиться наш код, и файл test.py для наших тестов:

touch main.py test.py

Базовое использование

Для примера возьмем простой класс:

class Calculator:
    def sum(self, a, b):
        return a + b

Класс Calculator реализует один метод – sum, принимающий два числа в качестве аргументов. Эти числа метод складывает и возвращает их сумму – a + b.

Простой тест-кейс для этого метода может быть следующим:

from unittest import TestCase
from main import Calculator

class TestCalculator(TestCase):
    def setUp(self):
        self.calc = Calculator()

    def test_sum(self):
        answer = self.calc.sum(2, 4)
        self.assertEqual(answer, 6)

Вы можете запустить этот тест-кейс с помощью команды python -m unittest.

Результаты будут выглядеть примерно так:

.
_____________________________________________________________

Ran 1 test in 0.003s

OK

Довольно быстро, верно?

А теперь представьте, что код выглядит следующим образом:

import time

class Calculator:
    def sum(self, a, b):
        time.sleep(10) # long running process
        return a + b

Поскольку это простой пример, мы используем time.sleep() для имитации длительного процесса. Теперь предыдущий тест-кейс выдает следующий результат:

.
_____________________________________________________________

Ran 1 test in 10.003s

OK

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

Давайте отрефакторим тест-кейс так, чтобы при выполнении теста вместо функции sum  вызывался ее мок с хорошо определенным поведением.

from unittest import TestCase
from unittest.mock import patch

class TestCalculator(TestCase):
    @patch('main.Calculator.sum', return_value=9)
    def test_sum(self, sum):
        self.assertEqual(sum(2,3), 9)

Мы импортируем декоратор patch из unittest.mock. Он заменяет фактическую функцию sum на мок-функцию, которая ведет себя именно так, как мы хотим. В данном случае она всегда возвращает 9. В течение всего времени работы нашего теста функция sum заменяется её мок-версией. Запустив этот тест-кейс, мы получим следующий результат:

.
_____________________________________________________________

Ran 1 test in 0.001s

OK

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

Более сложный пример

В этом примере мы будем использовать библиотеку requests для выполнения вызовов API. Вы можете получить ее через pip install.

pip install requests

Наш тестируемый код в main.py выглядит следующим образом:

import requests

class Blog:
    def __init__(self, name):
        self.name = name

    def posts(self):
        response = requests.get("https://jsonplaceholder.typicode.com/posts")

        return response.json()

    def __repr__(self):
        return '<Blog: {}>'.format(self.name)

Этот код определяет класс Blog с методом posts. Вызов posts повлечет за собой вызов API jsonplaceholder, API-сервис генератора JSON.

В нашем тесте мы хотим произвести мокинг вызовов API и проверить только то, что метод posts объекта Blog возвращает записи в блоге. Для этого нам нужно пропатчить все объекты Blog следующим образом:

from unittest import TestCase
from unittest.mock import patch, Mock


class TestBlog(TestCase):
    @patch('main.Blog')
    def test_blog_posts(self, MockBlog):
        blog = MockBlog()

        blog.posts.return_value = [
            {
                'userId': 1,
                'id': 1,
                'title': 'Test Title',
                'body': 'Far out in the uncharted backwaters of the unfashionable  end  of the  western  spiral  arm  of  the Galaxy\ lies a small unregarded yellow sun.'
            }
        ]

        response = blog.posts()
        self.assertIsNotNone(response)
        self.assertIsInstance(response[0], dict)

Из фрагмента кода видно, что функция test_blog_posts декорирована @patch. Когда функция декорируется с помощью @patch, возвращается мок класса, метода или функции, переданный в качестве целевого объекта @patch, который затем передается в качестве аргумента декорированной функции.

В этом случае  @patch вызывается с целевым объектом main.Blog и возвращает мок, который передается в тестовую функцию как MockBlog. Важно отметить, что целевой объект должен быть импортируемым в том окружении, в котором вызывается @patch. В нашем случае импорт from main import Blog должен быть разрешен без ошибок.

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

Вызов blog.posts() на нашем мок-объекте блога возвращает наш предопределенный JSON. Запуск тестов должен пройти успешно.

.
_____________________________________________________________

Ran 1 test in 0.001s

OK

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

Другие утверждения, которые мы можем сделать для моков

Используя предыдущий пример, мы можем сделать несколько полезных утверждений для нашего объекта Mock blog.

import main

from unittest import TestCase
from unittest.mock import patch


class TestBlog(TestCase):
    @patch('main.Blog')
    def test_blog_posts(self, MockBlog):
        blog = MockBlog()

        blog.posts.return_value = [
            {
                'userId': 1,
                'id': 1,
                'title': 'Test Title',
                'body': 'Far out in the uncharted backwaters of the unfashionable  end  of the  western  spiral  arm  of  the Galaxy\ lies a small unregarded yellow sun.'
            }
        ]

        response = blog.posts()
        self.assertIsNotNone(response)
        self.assertIsInstance(response[0], dict)

        # Additional assertions
        assert MockBlog is main.Blog # The mock is equivalent to the original

        assert MockBlog.called # The mock wasP called

        blog.posts.assert_called_with() # We called the posts method with no arguments

        blog.posts.assert_called_once_with() # We called the posts method once with no arguments

        # blog.posts.assert_called_with(1, 2, 3) - This assertion is False and will fail since we called blog.posts with no arguments

        blog.reset_mock() # Reset the mock object
        
        blog.posts.assert_not_called() # After resetting, posts has not been called.

Как уже упоминалось ранее, мок-объект позволяет нам проверить не только возвращаемое значение, но и то, как он был вызван и какие аргументы были переданы.

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

Побочные эффекты

Это те вещи, которые вы хотите, чтобы происходили при вызове вашей мок-функции. Частыми примерами являются вызов другой функции или появление исключений.

Давайте вернемся к нашей функции sum. Что, если вместо жесткого кодирования возвращаемого значения запустить пользовательскую функцию sum? Наша пользовательская функция избавится от нежелательного длительного вызова time.sleep и останется только с реальной функцией суммирования, которую мы хотим протестировать. Мы можем просто определить side_effect в нашем тесте.

from unittest import TestCase
from unittest.mock import patch

def mock_sum(a, b):
    # mock sum function without the long running time.sleep
    return a + b

class TestCalculator(TestCase):
    @patch('main.Calculator.sum', side_effect=mock_sum)
    def test_sum(self, sum):
        self.assertEqual(sum(2,3), 5)
        self.assertEqual(sum(7,3), 10)

Запуск тестов должен пройти успешно:

.
_____________________________________________________________

Ran 1 test in 0.001s

OK

Заключение

В этой статье мы рассмотрели основы мокинга в Python. Мы разобрали использование декоратора @patch и побочных эффектов для обеспечения альтернативного поведения ваших моков.

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

Для получения более подробной информации следует обратиться к официальным документам.

Перевод статьи «Getting Started with Mocking in Python».

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *