Python → Юнит-тесты на Python на примере функции находящей n-ное число Фибоначчи

Юнит-тестирование (англ. unit testing) — процесс в программировании, позволяющий проверить на корректность отдельные модули программы.

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

В python для написания unit тестов рекомендуется использовать библиотеку unittest. О нем мы и сейчас поговорим. 

Чтобы начать писать тесты, мы должны знать что мы будем тестировать и какие результаты мы ожидаем для конкретных входных данных. 

Для примера мы будем тестировать функцию, которая находит n-ное число Фибоначчи. А на этом сайте тоже вычислить числа Фибоначчи.

Создадим два пустых .py файла:

1. fibonacci.py
2. tests.py

В первом у нас будет находиться функция, которая находит числа Фибоначчи, а на втором мы напишем для нее тесты.

TDD

Есть способ разработки, которая называется TDD(Test Driven Development - Разработка через тестирование), который разбивает разработку на три повторяющихся этапа (Красный-Зеленый-Рефакторинг):
1. 🔴 Написание тестов
2. ✅ Написание самого кода, который успешно проходит все тесты
3. 🔵 Рефакторинг кода

Мы тоже следуя TDD сначала будем писать тесты. Для этого, откроем файл tests.py и напишем следующее.

from fibonacci import find_fibonacci

Мы подключили Функцию find_fibonacci с файла fibonacci. Но пока этот файл пустой, поэтому этот код даже не запустится. Так и задумано в TDD: вначале у нас либо код красный, либо тесты красные. 

Теперь подключим класс TestCase с пакета unittest:

from unittest import TestCase

При использовании пакета unittest все наши тесты будут находиться в классах наследующих от класса TestCase. Мы назовем свой класс для тестирования FibonacciTestCase:

class FibonacciTestCase(TestCase):
    def test_first_fibonacci_number_is_1(self):
        pass

Сами тесты пишутся в методах этого класса и методы с тестами должна начинаться со слова test. И желательно чтобы название метода сразу говорило о том, что оно тестирует. Мы свой первый метода назвали test_first_fibonacci_number_is_1 - что в переводе означает "Проверить что первое число фибоначчи это 1".

В классе TestCase есть очень много методов, которые помогают нам делать разные проверки. Все такие методы начинаются со слова assert. Например:

assertEqual() # проверяет что два значения совпадают
assertTrue() # проверяет что значение равно True
assertFalse() # проверяет что значение равно False
assertIsNone() # проверяет что значение None
assertIsNotNone() # проверяет что значение не None
assertIsInstance() # проверяет тип значения
assertListEqual() # проверяет что два списка совпадают
... и много других

но нам много не пригодится. Все эти методы принимают параметры, которые проверяются этими методами. Если метод решил что значание не правильное, то он выбрасывает исключение AssertError и тест проваливается. 

Давайте теперь допишем наш первый тест и попробуем его запустить. 

class FibonacciTestCase(TestCase):
    def test_first_fibonacci_number_is_1(self):
        x = find_fibonacci(1)
        self.assertEqual(1, x)

В тесте мы вычисляем первое число Фибоначчи с помощью функции find_fibonacci и затем проверяем(assertEqual) равняется ли это число 1. 

 

Запуск тестов

Теперь попробуем запустить тесты. Вы должны находиться в папке с тестами. Команда запуска выглядит так:

python -m unittest tests

tests - это название нашего файла в данном случае. Если у вас файл в котором есть тесты, называется по другому, то замените tests.

Запускаем и что? Ошибка! 🔴

...  
File "tests.py", line 1, in <module>
    from fibonacci import find_fibonacci
ImportError: cannot import name find_fibonacci

У вас должна быть примерно такая ошибка. Она нам говорит что программа не смогла импортировать функцию find_fibonacci 😱 . Ну и правильно, ее у нас еще нет. Так давайте создадим эту функцию.

Открываем файл fibonacci.py и добавляем:

def find_fibonacci(d):
    return d

И снова запускаем тесты:

python -m unittest tests

И что мы видим? Тест прошел! ✅

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

OK - значит все тесты успешно прошли. Количество точек - это количество тестов, которые были проведены. 

Стоп. Мы же написали просто функцию, которая всегда возвращает то, что ей передали. Это же неправильная функция Фибоначчи? 

В этом и прикол TDD, она прошла по нашим минимальным требованиям. Мы написали тесты только на первое число фибоначчи. И эти условия выполняются.

Давайте для галочки немного отрефакторим нашу функцию: 🔵

def find_fibonacci(n):
    return n

Назовем параметр n - так лучше, потому мы ищем n-ное число Фибоначчи. После изменения кода нужно обязательно еще раз запустить тесты, чтобы проверить что все также работает.

 

Добавляем тесты

Давайте добавим еще несколько тестов? Чтобы наши тесты получше проверяли функцию? Добавим тесты на  2 и на 5 число Фибоначчи:

from fibonacci import find_fibonacci
from unittest import TestCase

class FibonacciTestCase(TestCase):
    def test_first_fibonacci_number_is_1(self):
        x = find_fibonacci(1)
        self.assertEqual(1, x)

    def test_second_fibonacci_number_is_1(self):
        x = find_fibonacci(2)
        self.assertEqual(1, x)

    def test_5th_fibonacci_number_is_5(self):
        x = find_fibonacci(5)
        self.assertEqual(5, x)

2-е число Фибоначчи тоже 1, а 5 число Фибоначчи 5. Запускаем !
 

Опять ошибка! 🔴 

..F
======================================================================
FAIL: test_second_fibonacci_number_is_1 (tests.FibonacciTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 11, in test_second_fibonacci_number_is_1
    self.assertEqual(1, x)
AssertionError: 1 != 2

----------------------------------------------------------------------
Ran 3 tests in 0.000s

FAILED (failures=1)

Тут мы видим вердиет FAILED (failures=1) - т.е. ПРОВАЛ (провалов = 1). Один тест из трех не прошел. И сверху написано какой. Не прошел тест test_second_fibonacci_number_is_1. Мы ожидали получить 1, а функция вернула 2. 

Штош, исправим функцию find_fibonacci:

def find_fibonacci(n):
    if n == 1 or n == 2:
        return 1
    elif n == 3:
        return 2
    elif n == 4:
        return 3
    elif n == 5:
        return 5

 Написали все варианты от 1 до 5 числа Фибоначчи. Запускаем тесты:

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

И снова все тесты проходят! ✅

Прекрасно! Но код очень длинный, попробуем немного его улучшить:

def find_fibonacci(n):
    fibo_numbers = [1, 1, 2, 3, 5]
    return fibo_numbers[n - 1]

Теперь, мы просто будем брать из списка n - ное число. Изза того что индексация в списке начинается с 0, мы отнимаем 1 от n. Проверим ничего мы не сломали этими изменениями. 

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK

Тесты проходят после изменений! 🔵
 

Тест наборы и сообщения об ошибке

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

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


class FibonacciTestCase(TestCase):
    def setUp(self):
        self.fibonacci_test_data = [
            [1, 1], [2, 1], [3, 2], [4, 3],
            [5, 5], [6, 8], [7, 13], [8, 21],
            [9, 34], [10, 55], [11, 89], [12, 144]
        ]

Тестовый набор у нас список из списков. Внутренные списки состоят из двух значений, первый это номер числа Фибоначчи, а второе само ожидаемое число Фибоначчи. 

Теперь добавим еще один метод теста:

def test_fibonacci_test_data(self):
    for n, expected_number in self.fibonacci_test_data:
        actual_number = find_fibonacci(n)
        self.assertEqual(expected_number, actual_number)

В этом тесте мы обходим циклом весь набор тестовых данных.

В Python можно указать две переменные через запятую, и если справа находится список(или кортеж) из двух значений, то эти значения присвоятся этим двум переменным. Поэтому n у нас будет номер числа, а expected_number - ожидаемое число Фибоначчи. 

В цикле, для каждой пары значений, мы сначала вычисляем с помощью проверяемой функции число Фибоначчи и полученное число сохраняем в переменную actual_number (реальное значение которое мы получили). Потом проверяем, совпадает ли ожиданое и реальное числа. (self.assertEqual)

В итоге весь наш тест выглядит так:

from fibonacci import find_fibonacci
from unittest import TestCase


class FibonacciTestCase(TestCase):
    def setUp(self):
        self.fibonacci_test_data = [
            [1, 1], [2, 1], [3, 2], [4, 3],
            [5, 5], [6, 8], [7, 13], [8, 21],
            [9, 34], [10, 55], [11, 89], [12, 144]
        ]

    def test_fibonacci_test_data(self):
        for n, expected_number in self.fibonacci_test_data:
            actual_number = find_fibonacci(n)
            self.assertEqual(expected_number, actual_number)

    def test_first_fibonacci_number_is_1(self):
        x = find_fibonacci(1)
        self.assertEqual(1, x)

    def test_second_fibonacci_number_is_1(self):
        x = find_fibonacci(2)
        self.assertEqual(1, x)

    def test_5th_fibonacci_number_is_5(self):
        x = find_fibonacci(5)
        self.assertEqual(5, x)

Попробуем запустить тесты:

python -m unittest tests

И получаем ошибку:

.E..
======================================================================
ERROR: test_fibonacci_test_data (tests.FibonacciTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 15, in test_fibonacci_test_data
    actual_number = find_fibonacci(n)
  File "fibonacci.py", line 3, in find_fibonacci
    return fibo_numbers[n - 1]
IndexError: list index out of range

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (errors=1)

Один из тестов провалился.  🔴

Ошибка индекса. Мы же в функции find_fibonacci указали всего 5 значений, а уже начинаем требовать от нее большие значения. Надо срочно исправить. 

Давайте напишем функцию, которая рекурсивно вычисляет число Фибоначчи. В Википедии можно найти Рекуррентное соотношение для чисел Фибоначчи:

Это значит: 
0 число Фибоначчи это 0.
1 число Фибоначчи это 1.
А начиная со второго числа вычисляются как сумма двух предыдущих. 

Так и напишем нашу рекурсивную Функцию. 

def find_fibonacci(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return find_fibonacci(n-2) + find_fibonacci(n-1)

Попробуем запустить тесты:

....
----------------------------------------------------------------------
Ran 4 tests in 0.001s

OK

Снова тесты проходят!

Функция работает превосходно. Мы заметили что если n меньше 2, то ответ совпадает с n. Уменьшим строки кода в функции:

def find_fibonacci(n):
    if n < 2:
        return n
    return find_fibonacci(n-2) + find_fibonacci(n-1)

Проверяем. И снова тесты проходят. 🔵

 

Свои сообщения при ошибках

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

def find_fibonacci(n):
    if n < 2:
        return n
    elif n == 10:
        return 125
    return find_fibonacci(n-2) + find_fibonacci(n-1)

В сам тест добавляем текст ошибки. В методе assertEqual, как и в других подобных методах, последний параметр это сообщение при ошибке. Будем его использовать:

 def test_fibonacci_test_data(self):
        for n, expected_number in self.fibonacci_test_data:
            actual_number = find_fibonacci(n)
            message = "%d-число Фибоначчи должно быть %d, а получили %d" % (n, expected_number, actual_number)
            self.assertEqual(expected_number, actual_number, message)

%d - это способ форматирования целых чисел. Запускаем тесты и видим понятные ошибки теперь:

.F..
======================================================================
FAIL: test_fibonacci_test_data (tests.FibonacciTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/alisher/projects/sandbox/ma/pycources/recursion/tests.py", line 17, in test_fibonacci_test_data
    self.assertEqual(expected_number, actual_number, message)
AssertionError: 55 != 125 : 10-число Фибоначчи должно быть 55, а получили 125

----------------------------------------------------------------------
Ran 4 tests in 0.001s

FAILED (failures=1)

Теперь вернем функцию на правильный путь обратно:

def find_fibonacci(n):
    if n < 2:
        return n
    return find_fibonacci(n-2) + find_fibonacci(n-1)

 

Цветные тесты! 

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

В Python для этого можно использовать библиотеку pytest. Устанавливается как обычно:

pip install pytest

После этого можно запускать тесты. А запускаются они с указанием полного имени файла:

python -m pytest tests.py

Теперь вы будете видеть примерно вот так:

Пока на этом все! Зеленых всем тестов! 

 

370 0
Alisher Alikulov