Юнит-тесты

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

В Python поставляется модуль unittest, который облегчает написание тестов:

  • Обнаружение и автоматическое исполнение тестов
  • Настройка теста и его завершение
  • Группирование тестов
  • Статистика тестирования

Чтобы создать тестовый случай, нужно создать класс, отнаследованный от unittest.TestCase. А внутри этого класса можно добавить несколько методов, начинающихся со слова test. Каждый из этих методов должен тестировать какой-то из аспектов кода. Для примера мы тестируем свойства строк Python: сложение строк (test_sum) и преобразование к нижнему регистру (test_lower):

import unittest

class StringTestCase(unittest.TestCase):
    def test_sum(self):
        self.assertEqual("" + "", "")
        self.assertEqual("foo" + "bar", "foobar")

    def test_lower(self):
        self.assertEqual("FOO".lower(), "foo")
        self.assertTrue("foo".islower())
        self.assertFalse("Bar".islower())

Самые распространенные проверки:

self.assertEqual – непосредственно проверяет, чтобы первый аргумент равнялся второму. Если это будет не так, то тест будет провален, и появится сообщение о том, что и где пошло не так.

self.assertTrue – ожидает, что аргумент будет эквивалентен правде (True), а self.assertFalse – проверяет на ложь (False).

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

if name == '__main__':
    unittest.main()

А можно из консоли:

python -m unittest my_test.py

Модуль unittest сам найдет все тестовые случаи и выполнит в них все тестовые функции.

Скрин результата запуска тестов

Фикстуры

Тестовые фикстуры (test fixtures) – особые условия, которые создаются для выполнения тестов. Сюда могут входить такие вещи:

  • Подготовка тестовых данных
  • Создание подключений к БД, сервисам и т.п.
  • Создание заглушек (mock) для имитации компонентов программы
  • Другие действия по поддержке рабочего окружения для проведения теста

Пример: у вас программа, которая вычисляет вычисляет число π до n-знака, и вам нужно протестировать, как будет выведен на экран миллионнный знак. Вы же не будете в тесте ждать вычисления всех предыдущих 999,999 знаков часами, а просто загрузите какие-то данные в память, чтобы создать условия, как будто мы уже на миллионом знаке.

В unittest фикстуры можно создавать на уровне модуля с тестами, отдельного класса (от unittest.TestCase) и каждого метода в классе теста. 

Метод setUp() вызывается перед каждым вызовом метода test* в классе тестового случая.

Классовый метод setUpClass() вызывается один раз перед запуском тестов в классе тестового случая.

Функция setUpModule() вызывается перед выполнением тестовых случаев в этом модуле.

У них есть пары, предназначенные для освобождения ресурсов (закрытия соединений, удаления временных файлов и т.п.):

tearDown() – после каждого метода-теста в классе.
tearDownClass() – после всех тестов в классе.
tearDownModule() – после всех классов в модуле.

📎 В примере изучим порядок вызовов этих функций:

import unittest

class StringTestCase(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        print(' - set up class')

    def setUp(self):
        print(' - - set up method')
        self.foo = "foo"
        self.bar = "bar"

    def test_sum(self):
        self.assertEqual(self.foo + self.bar, "foobar")

    def test_lower(self):
        self.assertTrue(self.foo.islower())

    def tearDown(self):
        print(' - - tear down method')

    @classmethod
    def tearDownClass(cls):
        print(' - tear down class')

def setUpModule():
    print('set up module')

def tearDownModule():
    print('tear down module')

if name == '__main__':
    unittest.main()

Даст такую схему вызовов:

set up module
 - set up class 
 - - set up method
 - - tear down method
 - - set up method
 - - tear down method
 - tear down class
tear down module

Даже если в одной из этих или тестовых функций произошло исключение, то прочие методы tearDown*() будут все равно запущены, чтобы освобождение ресурсов произошло корректно.

Пропуск тестов

Модуль unittest поддерживает пропуск отдельных тестовых методов и целых тестовых классов. Пропускают тесты, если нет нужного ресурса для теста, тест предназначен только для отдельных платформ или версий библиотек и т.п. Способы пропустить тест:

  • @unittest.skip("причина") – всегда пропускать тест.
  • @unittest.skipIf(условие, "причина") – пропускать тест, если условие сработало (True).
  • @unittest.skipUnless(условие, "причина") – пропускать тест, если условие НЕ сработало (False).
  • self.skipTest("причина") – если нужно остановить выполнение метода, выйти из него и не учитывать его в результатах. Так же может быть вызван в методе setUp(), который вызывается перед каждым тестовым методом.

📎 Пример:

class MyTestCase(unittest.TestCase):
    @unittest.skip("всегда пропустить")
    def test_nothing(self):
        self.fail("не случится")

    @unittest.skipIf(mylib.__version__ < (1, 3),
                     "эта версия библиотеки не поддерживается")
    def test_format(self):
        # этот тест работает только для определенных версий 
        pass

    @unittest.skipUnless(sys.platform.startswith("win"), "надо Windows")
    def test_windows_support(self):
        # тест работает только на Windows
        pass

    def test_maybe_skipped(self):
        if not external_resource_available():
            self.skipTest("ресурс недоступен")
        # код дальше будет тестировать, если ресурс доступен
        pass

📎 Пример пропуска класса:

@unittest.skip("как пропустить класс")
class MySkippedTestCase(unittest.TestCase):
    def test_not_run(self):
        pass

Вы можете написать свой декоратор. Например, данный декоратор пропускает тест, если объект obj не имеет атрибут attr:

def skipUnlessHasattr(obj, attr):
    if hasattr(obj, attr):
        return lambda func: func
    return unittest.skip("{!r} не имеет {!r}".format(obj, attr))

class SkipAttrTestCase(unittest.TestCase):
    @skipUnlessHasattr(mylib, "foofunc")
    def test_with_foofunc():
        # у mylib нет атрибута foofunc, тест будет пропущен
        pass

Еще один декоратор @unittest.expectedFailure говорит системе тестирования, что следующий метод должен провалиться (один из self.assert должен не сработать). Таким образом, разработчик говорит, что он осведомлен, что данный тест пока проваливается, и в будущем к этому примут меры.

class ExpectedFailureTestCase(unittest.TestCase):
    @unittest.expectedFailure
    def test_fail(self):
        self.assertEqual(1, 0, "сломано")

В конце выполнения будут счетчики пропусков и ожидаемых провалов тестов:

OK (skipped=5, expected failures=1)

Код примеров онлайн.

Проверки

В первой части мы обсудили методы проверки assertEqual, assertTrue и assertFalse, так как они самые распространенные на практике. Вообще достаточно одного assertTrue. Действительно, одно и тоже:

assertNotIn(item, list)
assertTrue(item not in list)

Поэтому не буду особо акцентировать внимание на каждом из методов, а прикреплю общую таблицу. Помимо данных для проверки, все методы могут быть дополнены сообщением, которое будет выведено в отчет, если проверка провалилась:

self.assertEqual(2 + 2, 5, "я не учил математику")

Однако, стоит упомянуть метод self.assertRaises(SomeException), который проверяет, возбуждает ли код нужное исключение. Обычно он применяется как контекст-менеджер (с with). 

📎 Пример: деление на 0 должно бросать исключение ZeroDivisionError:

import unittest

def my_div(a, b):
    return a // b

class MyDivTestCase(unittest.TestCase):
    def test_1(self):
        self.assertEqual(my_div(10, 2), 5)

        # при делении на 0 ждем исключение:
        with self.assertRaises(ZeroDivisionError):
            my_div(7, 0)

        # или так: исключение, ф-ция, аргументы
        self.assertRaises(ZeroDivisionError, my_div, 5, 0)

unittest.main()

Если из исключения нужно извлечь данные (к примеру, код ошибки), то делают так:

with self.assertRaises(SomeException) as cm:
    do_something()

self.assertEqual(cm.exception.error_code, 3)
Картинка с таблицей проверок и что они проверяют

PyTest

Ранее мы обсуждали тестирование средствами встроенного модуля unittest. Естественно, есть и сторонние библиотеки для тестирования. Например, библиотека PyTest предоставляет более лаконичный и удобный инструментарий для написания тестов. Однако, ее нужно установить:

pip install pytest

Преимущества PyTest:

  • Краткий и красивый код
  • Только один стандартный assert
  • Подробный отчет
  • Разнообразие фикстур на всех уровнях
  • Плагин и интеграции с другими системами

Сравните этот код с кодом из предыдущих постов про unittest:

import pytest

def setup_module(module):
    #init_something()
    pass

def teardown_module(module):
    #teardown_something()
    pass

def test_upper():
    assert 'foo'.upper() == 'FOO'
    
def test_isupper():
    assert 'FOO'.isupper()
    
def test_failed_upper():
    assert 'foo'.upper() == 'FOo'

Для тестов можно применять и классы (как в unittest), так и отдельные функции.

Запускать тесты тоже просто. В окружении, где установлен pytest, появится команда py.test. Из терминала пишем:

py.test my_test_cases.py  

py.test обнаружит и выполнит тесты из этого файла.

Есть очень хорошая статья на Хабре про PyTest на русском, не вижу смысла дублировать ее сюда, а просто оставлю ссылку.

😈 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Игра понг ASCII на Python

В продолжение последней темы написал сегодня с утра игру «Понг» для терминала. Обошелся только встроенными модулями. Для графики и ввода использовал модуль curses (обертка над ncurses). Исходный код доступен здесь. Благодаря современным чудо-технологиям в игру можно поиграть прямо в браузере, хоть она и работает не очень стабильно (зависит от вашего интернет соединения). Управление: W — вверх, S — вниз (только английская раскладка).

Скриншот текстовой игры ПОНГ

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

bin, oct, hex – системы исчисления

Вспомним основы информатики и поговорим о системах исчисления. В жизни мы привыкли к десятичной системе (base-10 или decimal), железо компьютеров оперирует в системе двоичной (base-2 или binary) – нулями и единицами. Часто приходится иметь дело с системой шестнадцатиричной (base-16 или hexadecimal), она позволяет записывать данные в 8 раз короче, чем двоичная. Реже встречается система восьмеричная (base-8 или octal). Система исчисления – это всего лишь средство представления числа, т.е. то, как мы его в строчку запишем или считаем, само число остается самим собой, независимо от системы.

Чтобы получить целое число из строки, записанной в какой-то системе исчисления, используем функцию int (второй параметр – база системы):

>>> int('42')  # по умолчанию 10 
42
>>> int('42', 10)  # тоже самое
42
>>> int('101010', 2)
42
>>> int('BEEF', 16)
48879
>>> int('7654', 8)
4012

Наоборот сделать из числа строку в какой-то системе – встроенные функции bin, oct и hex:

>>> bin(42)
'0b101010'
>>> hex(48879)
'0xbeef'
>>> oct(4012)
'0o7654'

В строке появились префиксы 0b, 0x и 0o. Как и в Си, в Python можно использовать эти префиксы для записи чисел в коде помимо обычного десятичного варианта, если это требуется:

>>> 0b11011, 0xDEAD, 0o777
(27, 57005, 511)

Однако, часто приходится иметь дело с чистыми HEX-данными без префиксов. Многие (да и я) делали вот так:

>>> hex(12345)[2:]
'3039'

Т.е. просто отрезали первые два символа. Это не очень интуитивно, да и может привести к неправильному поведению, если передать отрицательное число:

>>> hex(-12345)
'-0x3039'
>>> hex(-12345)[2:]
'x3039'

К счастью, есть встроенная функция format(value, format_spec) (не путать с str.format), которая форматирует значение, согласно спецификатору форматирования:

>>> format(3039, 'x'), format(3039, 'X'), format(3039, '#x')
('bdf', 'BDF', '0xbdf')
>>> format(120, 'b')
'1111000'
>>> format(79, 'o')
'117'
  • x — 16-ричное без префикса, маленькие букв
  • X — 16-ричное без префикса, заглавные буквы
  • #x — 16-ричное с префиксом, маленькие буквы
  • b — двоичное число без префикса и т.д.

format(value, format_spec) эквивалентная вызову type(value).__format__(value, format_spec).

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Quine на Python

Программисты тоже умеют развлекаться, так что давайте сегодня развлечемся и напишем quine (квайн). Квайн – это такая программа, которая выводит на экран свой же код, ни больше, ни меньше. Сразу договоримся, что пустая программа на Python, которая ничего не выводит, не считается квайном; это не интересно.

В Python у нас есть чудо-переменная, которая хранит путь к текущему интерпретируемому файлу, поэтому можно сделать так:

print(open(__file__).read())

Эта программа открывает свой же файл, читает и печатает его целиком. Но это жульничество, потому что в квайнах не принято читать файлы. Хорошо, а что если назвать файл print(__file__), записать в него print(__file__) и выполнить python "print(__file__)". Будет работать, но можешь вот без этих трюков, чисто кодом? Да без проблем!

Нам нужно что-то печатать, значит берем print:

>>> print('?')
?

Программа начинается с print…, значит и печатать будем тоже самое:

>>> print('print()')
print()

Не получается, потому что у нас тут уже два print да кавычки, а печатается только один. Так можно плодить print до бесконечности, но все равно не будет хватать одного в выводе. Будем решать поэтапно. Давайте заведем переменную s с кодом нашей программы.

>>> s='print()';print(s)
print()

Но код теперь начинается с s=, исправим:

>>> s='s=?;print(s)';print(s)
s=?;print(s)

Смотрите, уже похоже, осталось только на место знака вопроса воткнуть содержимое строки s из оригинального кода. Это самый важный момент. Используем format, а точнее s.format(s), который в определенном месте строки s вставит саму же строку s, таким образом, мы «разрываем рекурсию»:

>>> s='s={};print(s)';print(s.format(s))
s=s={};print(s);print(s)

Отлично! Но тут два недостатка: во-первых, не забыть добавить s.format(s) в саму строку s:

>>> s='s={};print(s.format(s))';print(s.format(s))
s=s={};print(s.format(s));print(s.format(s))

Во-вторых, нужно вернуть на место кавычки. Не зря я недавно рассказывал о флагах преобразования строк. Используем флаг {!r} в формате, чтобы вывести repr(s), который для строк содержит одинарные кавычки:

>>> s='s={!r};print(s.format(s))';print(s.format(s))
s='s={!r};print(s.format(s))';print(s.format(s))

Ура! Квайн готов и работает!

Вы можете сделать квайн короче, используя другой стиль форматирования строк через процент: {!r} заменяется на %r, s.format(s) на s%s, плюс экранируется процент внутри самой строки s%%s (%% понимается как сам знак процента, а не как место для подстановки):

>>> s='s=%r;print(s%%s)';print(s%s)
s='s=%r;print(s%%s)';print(s%s)

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈 

Флаги преобразования

При форматировании строк доступны 3 флага преобразования объекта в строку: !r, !s и !a.

>>> x = "дом"
>>> f'{x}'
'дом'
>>> f'{x!r}'
"'дом'"
>>> f'{x!s}'
'дом'
>>> f'{x!a}'
"'\\u0434\\u043e\\u043c'"

Для фанатов format:

>>> '{0!r}'.format(x)
"'дом'"
>>> '{!r}'.format(x)
"'дом'"

Флаг !r вызывает repr(x), а флаг !s вызывает str(x). Флаг !a вызывает ascii(repr(x)). Функция ascii превращает все символы за пределами набора ASCII (включая русские буквы в юникоде) в их коды. Если флаг не указан, то по умолчанию считается, что он !s.

Для классов __repr__ и __str__ могут иметь различное определение:

class Foo:
    def __repr__(self):
        return "репр"
    def __str__(self):
        return "строка"
x = Foo()
print(f'{x!r}')  # репр
print(f'{x!s}')  # строка

Если __str__ нет, то будет вызван __repr__.

Рекомендации: __str__ должен давать нам человеко-читаемое описание объекта, а __repr__ – уникальное представление объекта, по которому можно частично или полностью восстановить состояние этого объекта или хотя бы помочь с отладкой. __str__ – для пользователей, __repr__ — для питонистов.

📎 Отличный пример для наглядности – datetime:

>>> import datetime
>>> dt = datetime.datetime(2019, 7, 27)
>>> repr(dt)
'datetime.datetime(2019, 7, 27, 0, 0)'
>>> str(dt)
'2019-07-27 00:00:00'
>>> eval(repr(dt)) == dt
True

str от datetime просто покажет нам дату и время в удобном формате; repr от datetime вернет строку, в которой будет вызов описан конструктора конкретно этого объекта, да так, что при исполнении этой строки как кода на Python функцией eval – мы получим объект datetime для той же даты. Впрочем, никто нас не обязывает делать этот трюк для каждого объекта.

🐉 Специально для канала @pyway. Подписывайтесь на мой канал в Телеграм @pyway 👈