Test-Driven Development в Python для начинающих, часть первая

Alexander Shepetko11 марта 2016, 12:00 661 0

Базовые концепции TDD и примеры на Python с использованием пакета nose.

Разработка через тестирование (Test-Driven Development, TDD) -- это методология, которая за последние годы удостоилась довольно пристального внимания и хорошо описана во многих источниках. Методология, которая предписывает сперва делать тесты, а потом писать код, вместо "традиционного" подхода написания тестов задним числом, должна бы стать нормой жизни, а не чем-то вроде того, о чём все слышали, но никто не видел.

Сам по себе процесс TDD принципиально прост для понимания и я не буду тратить слишком много времени на рассказы, вместо того, чтобы попробовать всё это на практике. Существует огромное количество бонусов, которые вы получите, внедрив TDD в ваш процесс разработки. В первую очередь, конечно, качество кода подскочит в разы. К этому присоединится ясность и более чёткое понимание того, что вы делаете и как вы делаете это. Кроме того, TDD отлично встраивается в Agile-процессы и, как вы увидите дальше, очень хорошо помогает при парном программировании.

В этой статье я познакомлю вас с базовыми концепциями TDD и приведу примеры на Python, используя пакет для юнит-тестирования nose. Также я расскажу об альтернативах этому пакету, которые можно найти внутри Python.

Что такое разработка через тестирование?

Если коротко, то это методология, которая позволит вам не попасть в ловушку, в которой оказывается большинство разработчиков. TDD, если описать его коротко, представляет собой процесс написания кода, в котором сперва пишутся тесты, которые будут завершаться с ошибкой, а после этого пишется программный код, который будет будет проходить написанные ранее тесты без ошибок. Далее вы можете дополнять, изменять тесты с учётом новых требований к функциональности кода, после чего снова писать код, чтобы тесты успешно выполнялись.

Думаю, вы заметили, что разработка с использованием TDD является цикличной: ваш код проходит через множество итераций тестирования и написания кода до тех пор, пока необходимый фрагмент функциональности не доведён до завершения. Используя подобный подход, вы просто вынуждены естественным образом сперва размышлять о проблеме, которую вы собираетесь решать. Если вы пишете тесты до того, как будет написана хоть строчка рабочего кода, вы непременно будете задумываться том, как выстраивать ваш код. Что будет возвращать тот или иной метод? Что будет, если вот здесь мы получим исключение? И так далее.

Подразумевается, что если вы ведёте разработку таким образом, вы рассматриваете все возможные варианты работы кода, покрывая их тестами. Такой подход позволит вам избежать ловушки, в которую попадается очень много разработчиков (автор этого текста в том числе): закапывание исключительно в одну отдельно взятую проблему и написание кода только для того, чтобы её решить.

Таким образом, методологию TDD можно описать примерно как:

  • пишем юнит-тест;
  • пишем код, который будет успешно проходить тест;
  • вносим изменения;
  • повторяем первые три пункта для каждого элемента функциональности, до полной готовности.

Agile-методология и TDD

Test-Driven Development отлично вписывается в парадигму методологии гибкой разработки программного обеспечения, которая нацелена на разработку продукта кроткими итерациями, поставляя качественный продукт вовремя. Зная, что ваши юнит-тесты отрабатывают без ошибок, вы можете быть уверены, что в конце итерации разработки вы получите работоспособный продукт, который будет без ошибок работать в продакшне.

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

TDD также может стать неотъемлемой частью применения методологии Behaviour Driven Development, которая также подразумевает первичное создание тестов, только не юнит-тестов, а так называемых приёмочных (acceptance) тестов. Такие тесты проверяют корректность "поведения" какой-либо части функциональности. Подробнее об этом вы можете почитать в будущих статьях.

Установка и использование Nose

Перед тем, как мы приступим к упражнениям, приведённым ниже, потребуется установить пакет nose. Используя pip, сделать это не составляет особых проблем. Также не забывайте, что использование virtualenv также довольно ощутимо облегчает жизнь python-разработчику. Итак, чтобы установить nose, достаточно одной команды:

 pip install nose

После того, как установка будет завершена, вы сразу же можете запускать выполнение тестов из файла:

 nosetests example_unit_test.py

или запустить набор тестов из каталога:

 nosetests /path/to/tests

Единственное, о чём нужно помнить, это начинать имя каждого тестового метода с test_, чтобы nose смог отыскать ваши тесты в файлах.

Опции запуска nosetests

Некоторые полезные опции управления nosetests, которые стоит запомнить:

  • -v: включает многословный режим работы, при котором среди прочего можно видеть имена выполняемых в процессе работы тестов.
  • -s или --nocapture: отключает скрытие вывода print'ов, что в ряде случаев упрощает отладку.
  • --nologcapture: отключает скрытие вывода логгирования, также облегчая отладку.
  • --rednose: включает плагин rednose (устанавливается отдельно), который добавляет цвет в выводимые сообщения.
  • --tags=TAGS: вы можете помечать ваши тесты тегами, используя которые вместе с этой опцией, вы можете определять отдельные тесты для запуска, чтобы не запускать весь набор.

Пример проблемы и её решения с использованием методики TDD

Что ж, давайте рассмотрим небольшой пример с целью испытать на практике, что же это за зверь такой -- TDD, также принципы юнит-тестирования в Python. Мы создадим простой калькулятор, который будет иметь несколько соответственных методов.

Следуя концепциям TDD, давайте представим, что нам нужен метод add(), который будет возвращать результат сложения двух аргументов. Давайте первым делом напишем тест. Создайте два отдельных пакета app и test, а в пакете test создайте файл test_calculator.py со следующим содержимым:

 import unittest
 
class TddInPythonExample(unittest.TestCase):

   def test_calculator_add_method_returns_correct_result(self):
       calc = Calculator()
       result = calc.add(2,2)
       self.assertEqual(4, result)

Что мы сделали:

  • импортировали модуль unittest из стандартной библиотеки;
  • создали класс, который будет содержать тесты;
  • создали метод, который будет содержать код теста, не забывая о том, что имена таких методов должны начинаться с test_ для того, чтобы их смог отыскать nosetests.

Используя получившуюся структуру, можно создавать наш первый тест. Сначала мы создаём экземпляр нашего калькулятора, затем вызываем его метод add(), который намереваемся тестировать. После того, как получаем результат выполнения add() в переменную result, мы вызываем метод assertEqual() модуля unittest, чтобы убедиться в том, что полученный результат соответствует ожидаемому.

Теперь можно запустить nosetest, чтобы выполнить созданный нами тест. Вы, само-собой, можете использовать unittest из стандартной библиотеки Python, добавив в конец файла с тестом следующий код:

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

После этого вы сможете выполнить тесты простым выполнением python-файла в оболочке:

 $ python test_calculator.py

Однако в этой статье мы всё-таки будем использовать nosetest, который имеет ряд интересных возможностей, вроде запуска всех тестов проекта или тестов из отдельно взятого каталога.

 $ nosetests test_calculator.py
E
======================================================================
ERROR: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 6, in test_calculator_add_method_returns_correct_result
    calc = Calculator()
NameError: global name 'Calculator' is not defined
 
----------------------------------------------------------------------
Ran 1 test in 0.001s
 
FAILED (errors=1)

Поглядев на вывод, предоставленный нам nosetest, можно понять, что произошло исключение, вызванное отсутствием в области видимости класса Calculator, что логично, ведь он ещё не создан! Отлично, давайте создадим его. Создайте файл calculator.py в пакете app со следующим содержимым:

 class Calculator(object):

    def add(self, x, y):
        pass

После чего, импортируйте класс Calculator в сценарий теста:

 import unittest
from app.calculator import Calculator
 
class TddInPythonExample(unittest.TestCase):
 
    def test_calculator_add_method_returns_correct_result(self):
        calc = Calculator()
        result = calc.add(2,2)
        self.assertEqual(4, result)
 
 
if __name__ == '__main__':
    unittest.main()

Теперь, когда наш Calculator существует и доступен, давайте посмотрим, что теперь нам сообщит nosetest.

 $ nosetests test_calculator.py
F
======================================================================
FAIL: test_calculator_add_method_returns_correct_result (test.test_calculator.TddInPythonExample)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 9, in test_calculator_add_method_returns_correct_result
    self.assertEqual(4, result)
AssertionError: 4 != None
 
----------------------------------------------------------------------
Ran 1 test in 0.001s
 
FAILED (failures=1)

Ага, очевидно, что наш метод add() возвращает совсем не то, что от него ожидается. Это понятно, поскольку он пока что не возвращает вообще ничего. В выводе nosetest мы можем увидеть номер строки теста, которая приводит к ошибке, что позволяет нам быстренько отыскать причину. Давайте исправим на Calculator, чтобы тест успешно завершался:

 class Calculator(object):
 
    def add(self, x, y):
        return x + y
 $ nosetests test_calculator.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK

Ура! Наш метод add() создан и работает, как ожидается. Однако, это ещё далеко не всё. Нам понадобиться сделать ещё ряд тестов прежде, чем мы сможем сказать, что мы в полной мере тестируем метод add().

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

Что будет, если какой-нибудь разработчик передаст нашему методу что-нибудь, отличное от числа? Python позволяет выполнять операцию сложения не только с числами, а и с другими объектами, например со строками, но это не будет иметь смысла в контексте нашего класса, который предназначен для выполнения операций исключительно с числами. Давайте добавим проверку на этот случай, задействовав метод assertRaises для проверки срабатывания исключения, если вдруг методу передан аргумент не того типа.

 import unittest
from app.calculator import Calculator
 
 
class TddInPythonExample(unittest.TestCase):
 
    def setUp(self):
        self.calc = Calculator()
 
    def test_calculator_add_method_returns_correct_result(self):
        result = self.calc.add(2, 2)
        self.assertEqual(4, result)
 
    def test_calculator_returns_error_message_if_both_args_not_numbers(self):
        self.assertRaises(ValueError, self.calc.add, 'two', 'three')
 
 
if __name__ == '__main__':
    unittest.main()

Как видите, теперь тест проверяет, чтобы в случае передачи строк в аргументах, метод выбрасывал исключение ValueError. Мы также можем добавить проверку и других типов, но давайте не будем усложнять наш пример. Также обратите внимание на появившийся метод setUp(), который позволяет выполнять какие-то действия пере выполнением каждого тест-кейса. Поскольку в каждом из тест-кейсов нам потребуется экземпляр класса Calculator, есть смысл создавать его в отдельном месте, чтобы избежать дублирования кода. Итак, давайте теперь посмотрим на вывод nosetest.

 $ nosetests test_calculator.py
.F
======================================================================
FAIL: test_calculator_returns_error_message_if_both_args_not_numbers (test.test_calculator.TddInPythonExample)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/user/PycharmProjects/tdd_in_python/test/test_calculator.py", line 15, in test_calculator_returns_error_message_if_both_args_not_numbers
    self.assertRaises(ValueError, self.calc.add, 'two', 'three')
AssertionError: ValueError not raised
 
----------------------------------------------------------------------
Ran 2 tests in 0.001s
 
FAILED (failures=1)

Очевидно, что исключение ValueError не выбрасывается там, где ожидается. Следующим шагом необходимо написать код, который заставит тест завершаться успешно.

 class Calculator(object):

    def add(self, x, y):
        number_types = (int, long, float, complex)
 
        if isinstance(x, number_types) and isinstance(y, number_types):
            return x + y
        else:
            raise ValueError

Мы добавили проверку типа и выброс исключения ValueError в случае, если один из аргументов имеет неподходящий тип. Как мы знаем, Python запросто позволяет применять операцию сложения к строкам, а не только к числам. Однако в нашем случае такое поведение не годится и мы при помощи isinstance() выполняем проверку типов аргументов, прежде, чем двигаться дальше, выбрасывая исключение в случае обнаружения проблем.

Давайте дополним наш тест ещё парой кейсов. Поскольку метод add() получает два аргумента, то возможны варианты, когда он получит не сразу два аргумента с не числовыми значениями, а лишь один из них. Конечно же, такую ситуацию тоже необходимо предусмотреть.

 import unittest
from app.calculator import Calculator
 
 
class TddInPythonExample(unittest.TestCase):

    def setUp(self):
        self.calc = Calculator()
 
    def test_calculator_add_method_returns_correct_result(self):
        result = self.calc.add(2, 2)
        self.assertEqual(4, result)
 
    def test_calculator_returns_error_message_if_both_args_not_numbers(self):
        self.assertRaises(ValueError, self.calc.add, 'two', 'three')
 
    def test_calculator_returns_error_message_if_x_arg_not_number(self):
        self.assertRaises(ValueError, self.calc.add, 'two', 3)
 
    def test_calculator_returns_error_message_if_y_arg_not_number(self):
        self.assertRaises(ValueError, self.calc.add, 2, 'three')
 
 
if __name__ == '__main__':
    unittest.main()

Ещё раз запустим тест и убедимся, что всё работает:

 $ nosetests test_calculator.py
....
---------------------------------------------------------------------- 
Ran 4 tests in 0.001s 

OK