Различия между версиями 1 и 2
Версия 1 от 2021-04-28 19:12:22
Размер: 13303
Редактор: FrBrGeorge
Комментарий:
Версия 2 от 2021-04-28 21:15:00
Размер: 13302
Редактор: FrBrGeorge
Комментарий:
Удаления помечены так. Добавления помечены так.
Строка 45: Строка 45:
 * Закомментируем `app.mainloop()` и запустим программу с интерактивным интерпретатором: `python -i hashit.py` (или нажмём F5 в `idle`, что приведёт ровно к тому же).   * Закомментируем `app.mainloop()` и запустим программу с интерактивным интерпретатором: `python -i hashit.py` (или нажмём F5 в `idle`, что приведёт ровно к тому же).
Строка 180: Строка 180:
  * В частности, в тесте один из методов возвращает не квазиобъект, а заданноезначение    * В частности, в тесте один из методов возвращает не квазиобъект, а заданное значение

Краткое обоснование 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)))
  • Код всего теста стал очевидно короче
  • Код фикстуры стал гораздо более читаемым
  • В тесте можно проверить не только значение полей, но и факт вызова метода, количество вызовов и т. п.

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