Краткое обоснование 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, и прекращает свою работу только после закрытия соответствующего окна.
Проэкспериментируем:
Закомментируем app.mainloop() и запустим программу с интерактивным интерпретатором: python -i hashit.py (или нажмём F5 в idle, что приведёт ровно к тому же).
В полученной командной строке Python виден объект app, им можно управлять, менять параметры полей-виджетов, при этом само приложение продолжает работать, реагировать на события, обрабатывать ввод и т. д.
Даже удаление app не помогает!
Помогает только функция app.destroy(), которая закрывает окно
Вместо закоментированного app.mainloop() можно вписать app.upate(), после чего окно появится, и ваша программа внезапно распараллелится на код, выполняемый после app.upate() (напишите там хоть print в цикле, или time.sleep(2)) — и работающее приложение.
Тесты
Соответственно, первая идея — оттестировать вместе с функцией всё приложение, пока оно работает, используя возможности 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:
Должен иметь поле app.E с методом app.E.get(), и этот метод должен возвращать заданную строку
Должен иметь поле app.S с методом app.S.set(), и мы должны иметь возможность проверить, какой именно параметр этому .set() был передан.
Объект с такими свойствами надо создавать в фикстуре.
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)))
Мы слегка упростили себе жизнь, сделав .E и .S экземплярами одного и того же класса с двумя методами (можно было бы и два класса, но это ещё длиннее).
- Описание заглушки занимает довольно много места и требует дополнительной логики
Тест с помощью квазиобъекта
Квазиобъект (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)))
- Код всего теста стал очевидно короче
- Код фикстуры стал гораздо более читаемым
- В тесте можно проверить не только значение полей, но и факт вызова метода, количество вызовов и т. п.