Краткое обоснование Mock на примере

Это внеплановое разъяснение сути и потенциальных свойств объектов, предлагаемых unittest.mock. Возникло оно из необходимости показать, зачем и когда нужны квазиобъекты.

Приложение для теста

Напишем GUI-приложение, иллюстрирующее работу питоновского hash(). В поле ввода будет произвольная строка, а по кнопке хеш этой строки будет вставляться в надпись ниже.

Логику приложения (забрать строку из одного места, вычислить хеш, положить в другое) вынесем в отдельную функцию dohash(). Строго говоря, функцию надо было оформить фронтально: в качестве параметра — строка, возвращается хеш, но тогда примера с тестами не получилось бы ☺. В любом случае dohash() ничего не знает о природе обрабатываемого объекта (например, не пользуется его tkinter-овостью).

   1 import tkinter as tk
   2 
   3 
   4 class App(tk.Frame):
   5 
   6     def __init__(self, hasher):
   7         super().__init__()
   8         self.S = tk.StringVar()
   9         self.E = tk.Entry(self)
  10         self.B = tk.Button(self, text="Hash", command=lambda: hasher(self))
  11         self.L = tk.Label(self, textvariable=self.S)
  12         for obj in self, self.E, self.B, self.L:
  13             obj.grid(sticky="NEWS")
  14 
  15 
  16 def dohash(app):
  17     app.S.set(hex(hash(app.E.get())))
  18 
  19 
  20 if __name__ == "__main__":
  21     app = App(hasher=dohash)
  22     app.mainloop()

Ничего особенного в этой программе нет, для удобства содержимое надписи контролируется управляющей переменной tkinter.

Как протестировать работу функции?

Окно tkinter

Сразу заметим, что название .mainloop(), по-видимому, не до конца соответствует действительности. По крайней мере, в Python 3.9 для Linux соответствующая Tcl/Tk-структура формируется и запускается при создании экземпляра класса App, и прекращает свою работу только после закрытия соответствующего окна.

Проэкспериментируем:

Тесты

Соответственно, первая идея — оттестировать вместе с функцией всё приложение, пока оно работает, используя возможности tkinter. Вторая — изготовить дешёвую пластиковую имитацию GUI безо всякого GUI, но чтобы функция с ней работала. И третья — посмотреть, как в этом нам может помочь unittest.mock.

Сформируем файл с тестами для unittest, назовём его test_hashit.py. Префикс test_… обязателен — он используется unittest-ом при поиске.

Тест вместе с tkinter

Фикстурой нам будет служить само приложение. Не забываем, что tkinter.Entry — это такой маленький текстовый редактор, поэтому для того, чтобы в нём гарантированно оказалась некоторая строка, надо сначала всё оттуда удалить, а потом эту строку вставить.

Первый тест показывает, что это работает именно так. Второй — проверяет, что после нажатия кнопки (.invoke() запускает функцию, приписанную к кнопке с помощью command=) в управляющую переменную приезжает хеш той же строки.

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

   1 import unittest
   2 from hashit import App, dohash
   3 
   4 TESTSTR = "qwer"
   5 
   6 
   7 class TestFixture(unittest.TestCase):
   8 
   9     def setUp(self):
  10         self.app = App(hasher=dohash)
  11 
  12     def test_1(self):
  13         self.app.E.delete(0, 'end')
  14         self.app.E.insert(0, TESTSTR)
  15         self.assertEqual(self.app.E.get(), TESTSTR)
  16 
  17     def test_2(self):
  18         self.app.E.delete(0, 'end')
  19         self.app.E.insert(0, TESTSTR)
  20         self.app.B.invoke()
  21         self.assertEqual(self.app.S.get(), hex(hash(TESTSTR)))
  22 
  23     def tearDown(self):
  24         self.app.destroy()

Скорее всего мы не увидим, как окно приложения вообще открывается. Однако оно открывается, по крайней мере, если оно открыться не может, тесты падают. Под Linux можно попробовать запустить что-то вроде DISPLAY="" python3 -m unittest -v и посмотреть, что получится.

Обмажем тест явным показом Tk-окна и таймаутом:

   1 import unittest
   2 import time
   3 from hashit import App, dohash
   4 
   5 WAIT = 1
   6 TESTSTR = "qwer"
   7 
   8 
   9 class TestFixture(unittest.TestCase):
  10 
  11     def setUp(self):
  12         self.app = App(hasher=dohash)
  13         self.app.update()
  14 
  15     def test_1(self):
  16         self.app.E.delete(0, 'end')
  17         self.app.E.insert(0, TESTSTR)
  18         self.assertEqual(self.app.E.get(), TESTSTR)
  19         self.app.update()
  20 
  21     def test_2(self):
  22         self.app.E.delete(0, 'end')
  23         self.app.E.insert(0, TESTSTR)
  24         self.app.B.invoke()
  25         self.assertEqual(self.app.S.get(), hex(hash(TESTSTR)))
  26         self.app.update()
  27 
  28     def tearDown(self):
  29         time.sleep(WAIT)
  30         self.app.destroy()

Неожиданный эффект проявляется, если в tearDown() не закрывать окно (попробуем закомментировать строку self.app.destroy()). Объяснение: в приложении мы поленились сначала сделать т. н. toplevel-окно (собственно окно Tk), и начали сразу с создания виджета (на основе tkinter.Frame). Toplevel-окно было создано автоматически. Если в tearDown() окно не закрылось, то к моменту запуска второго теста Toplevel-окно уже есть, и очередной наш Frame вписывается в него!

Тест с помощью заглушки

Теперь попробуем протестировать функцию dohash(), вообще не прибегая к tkinter. Такая задача может возникнуть, как мы уже знаем, когда графический интерфейс недоступен (например, при автоматическом тестировании на хостинге проекта).

Нам нужно создать т. н. заглушку (stub) — объект, который обладал бы только нужными для теста свойствами класса App:

Объект с такими свойствами надо создавать в фикстуре.

   1 import unittest
   2 from hashit import App, dohash
   3 
   4 TESTSTR = "qwer"
   5 
   6 class TestStub(unittest.TestCase):
   7 
   8     class Stub:
   9         class _:
  10             src = dst = None
  11 
  12             def get(self):
  13                 return self.src
  14 
  15             def set(self, value):
  16                 self.dst = value
  17 
  18         S, E = _(), _()
  19 
  20     def setUp(self):
  21         self.obj = self.Stub()
  22 
  23     def test_1_dohash(self):
  24         self.obj.E.src = TESTSTR
  25         dohash(self.obj)
  26         self.assertEqual(self.obj.S.dst, hex(hash(TESTSTR)))

Тест с помощью квазиобъекта

Подробнее: тут и тут

Квазиобъект (mock object, mocker) — это объект, весь смысл которого — запомнить, что с ним делали, потом об этом отчитаться тесту.

   1 import unittest
   2 from unittest.mock import MagicMock
   3 from hashit import App, dohash
   4 
   5 TESTSTR = "qwer"
   6 
   7 class TestMock(unittest.TestCase):
   8 
   9     def setUp(self):
  10         self.app = MagicMock()
  11         self.app.E.get = MagicMock(return_value=TESTSTR)
  12         self.app.S.set = MagicMock()
  13 
  14     def test_1_dohash(self):
  15         dohash(self.app)
  16         self.app.E.get.assert_called_once()
  17         self.app.S.set.assert_called_once_with(hex(hash(TESTSTR)))

LecturesCMC/PythonDevelopment2021/10_Testing_Mock (последним исправлял пользователь FrBrGeorge 2023-04-11 11:00:55)