Как покрыть приложение на Django модульными тестами

Illustration of a man with a prominent mustache examining a piece of cheese, with multiple cheese samples lined up on a table, and a "Test Subject" container nearby. Illustration of a man with a prominent mustache examining a piece of cheese, with multiple cheese samples lined up on a table, and a "Test Subject" container nearby.

Примечание: чтобы использовать это руководство, вам понадобятся навыки, приобретенные после прочтения Как создать API с помощью Python и Django, а также код, над которым мы работали тогда. Мы продолжим работу с тем же API для приложения со списком дел, и будем использовать вот эту версию на GitHub.

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

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

Тестирование кода — одна из важнейших частей разработки. Тестирование может быть разным: можно протестировать приложение, всего лишь взглянув на веб-страницу, поиграв в видеоигру или проанализировав логи. Все зависит от типа вашего проекта. Ручное тестирование отнимает много времени и допускает возможность ошибок, поэтому профессиональные разработчики стараются уделять больше внимания автоматизированному тестированию. В этой статье мы рассмотрим общий вид автоматизированного тестирования, модульные тесты, а также поговорим о том, как автоматизированное тестирование может помочь нам в процессе разработки. Мы начнем с написания тестов для моделей и представлений (views) существующего API, затем, добавив новую функцию, попрактикуемся в разработке через тестирование.

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

Модульный тест по отдельности проверяет функциональность компонентов. Это тестирование самого низкого уровня, оно проверяет, что каждый компонент программы правильно работает в одиночку (интеграционные и системные тесты проверяют все компоненты вместе, а также их взаимодействия, но эта тема выходит за рамки данного руководства). При объектно-ориентированном подходе, например, вам пришлось бы писать модульные тесты для каждого объекта, а также для отдельных методов, в зависимости от их сложности. В Django мы используем модульное тестирование для каждой модели и представления.

Как в Django работает модульное тестирование?

Для работы с этим руководством, клонируйте вот этот проект из GitHub. Чтобы создать проект, выполните действия из первых трех параграфов в Как создать API с помощью Python и Django.

Когда вы используете python manage.py startapp appname для создания приложения на Django, один из создаваемых Django файлов папкеappname имеет имяtests.py. Этот файл существует для размещения модульных тестов для моделей и других компонентов внутри приложения. По умолчанию этот файл содержит одну строчку кода: from django.test import TestCase. Test case содержит несколько связанных тестов для одного и того же фрагмента кода. TestCase — это объект Django, который мы будем наследовать для создания собственных модульных тестов. У класса есть два метода: setUp(self) и tearDown(self), которые запускаются до и после отдельных тестовых функций для того, чтобы предоставить и очистить тестовую базу данных. Эта база независима от той базы данных, к которой вы получаете доступ с помощьюpython manage.py runserver. Чтобы взглянуть на код, откройте tests.py в папке todo нашего проекта.

class SigninTest(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test', password='12test12', email='test@example.com')
        self.user.save()
    def tearDown(self):
        self.user.delete()
    def test_correct(self):
        user = authenticate(username='test', password='12test12')
        self.assertTrue((user is not None) and user.is_authenticated)
    def test_wrong_username(self):
        user = authenticate(username='wrong', password='12test12')
        self.assertFalse(user is not None and user.is_authenticated)
    def test_wrong_pssword(self):
        user = authenticate(username='test', password='wrong')
        self.assertFalse(user is not None and user.is_authenticated)

Здесь мы тестируем функцию входа в систему. Поскольку мы используем встроенные в Django методы, все должно работать нормально, если в базе данных нет никаких проблем. Нам нужны только простые тесты: аутентифицируйтесь, если предоставлены верные данные, и не делайте этого, если нет. Благодаря этому примеру мы можем увидеть кое-что еще в модульном тестировании в Django. Прежде всего, все тестовые методы в тестовом случае должны начинаться с test_, чтобы быть выполненными при запуске тестовой команды python manage.py test. Остальные методы в тестовом случае нужно воспринимать как вспомогательные функции. Также необходимо знать, что все тестовые методы должны принимать self в качестве аргумента, где self является ссылкой на объект TestCase. Класс TestCase, который мы наследуем для создания нашего класса, содержит методы утверждений для проверки логических значений. Вызов self.assertSomething() проходит, если переданные в качестве аргументов значения соответствуют утверждению, в противном случае этого не происходит. Тестовый метод проходит, только если каждое утверждение в методе проходит.

class TaskTest(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test', password='12test12', email='test@example.com')
        self.user.save()
        self.timestamp = date.today()
        self.task = Task(user=self.user,
                         description='description',
                         due=self.timestamp + timedelta(days=1))
        self.task.save()
    def tearDown(self):
        self.user.delete()
    def test_read_task(self):
        self.assertEqual(self.task.user, self.user)
        self.assertEqual(self.task.description, 'description')
        self.assertEqual(self.task.due, self.timestamp + timedelta(days=1))
    def test_update_task_description(self):
        self.task.description = 'new description'
        self.task.save()
        self.assertEqual(self.task.description, 'new description')
    def test_update_task_due(self):
        self.task.due = self.timestamp + timedelta(days=2)
        self.task.save()
        self.assertEqual(self.task.due, self.timestamp + timedelta(days=2))

Теперь давайте протестируем нашу модель: объект Task, определенный в models.py. Для тестового случая мы создаем пользователя и задачу (обратите внимание, что из-за того, что пользователь и задача связаны отношениями внешнего ключа, удаление пользователя вtearDown() приведет к удалению задачи). Здесь мы можем увидеть, что любой тестовый метод может иметь несколько утверждений и проходит только в том случае, если все они выполняются успешно. Когда мы обновляем задачу, мы можем записывать данные в базу вне функции setUp. В остальном, этот тест похож на тест функции входа. Большинство тестовых случаев для моделей представляют собой создание, чтение, модифицирование и удаление объектов в базе данных, хотя модели с методами и интереснее тестировать.

class SignInViewTest(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test',
                                                         password='12test12',
                                                         email='test@example.com')
    def tearDown(self):
        self.user.delete()
    def test_correct(self):
        response = self.client.post('/signin/', {'username': 'test', 'password': '12test12'})
        self.assertTrue(response.data['authenticated'])
    def test_wrong_username(self):
        response = self.client.post('/signin/', {'username': 'wrong', 'password': '12test12'})
        self.assertFalse(response.data['authenticated'])
    def test_wrong_pssword(self):
        response = self.client.post('/signin/', {'username': 'test', 'password': 'wrong'})
        self.assertFalse(response.data['authenticated'])

Тестировать представления несколько сложнее, чем модели. Однако поскольку мы пишем API, в отличие от веб-приложения, здесь можно не волноваться по поводу тестирования фронтенда. Большую часть ручных тестов посредством Postman можно заменить на тесты представлений. self.client — HTTP-клиент тестовой библиотеки Django. Мы используем его для создания post-запроса к "/signin/" с учетными данными пользователя. Мы тестируем то же, что и раньше: верные учетные данные, неправильное имя пользователя и неправильный пароль. Это очень полезно, так как мы видим, что если тесты модели не выявляют ошибок, а тесты представлений выявляют — проблема не в модели, что в свою очередь позволяет тратить меньше времени на устранение багов. Мы делаем примерно то же самое для представлений, связанных с задачами.

class AllTasksViewTest(TestCase):
    def setUp(self):
        self.user = get_user_model().objects.create_user(username='test',
                                                         password='12test12',
                                                         email='test@example.com')
        self.user.save()
        self.timestamp = date.today()
        self.client.login(username='test', password='12test12')
    def tearDown(self):
        self.user.delete()
    def test_no_tasks(self):
        response = self.client.get('/all/')
        self.assertEqual(response.data, {'tasks': []})
    def test_one_task(self):
        self.task1 = Task(user=self.user,
                          description='description 1',
                          due=self.timestamp + timedelta(days=1))
        self.task1.save()
        response = self.client.get('/all/')
        self.assertEqual(response.data, {'tasks': [OrderedDict([('id', 1),
                                                                ('description', 'description 1'),
                                                                ('due', str(self.timestamp + timedelta(days=1)))])]})

Этот случай тестирует конечную точку "/all/". На самом деле у этого теста больше методов, но фрагмент выше показывает только новое. Чтобы клиент мог действовать как вошедший в систему пользователь, в setUp мы используем self.client.login(). Затем мы создаем задачи и сравниваем их с ожидаемым отформатированным выводом. Этот пример хорошо иллюстрирует преимущества методов setUp() и tearDown(), так как задачи из одного теста не переносятся в другие. Опять же, этот тест изолирует компонент представления, поскольку базовая модель тестируется отдельно.

Когда разберетесь с тестовым кодом, запустите python manage.py test, чтобы выполнить все тесты. Давайте взглянем на результат:

Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...FF..........
======================================================================
FAIL: test_due_future (todo.tests.DueTodayTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Philip/Code/WFH/mkdev_blog/djangotesting/taskmanager/todo/tests.py", line 155, in test_due_future
    self.assertFalse(self.task.due_today())
AssertionError: True is not false
======================================================================
FAIL: test_due_past (todo.tests.DueTodayTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/Philip/Code/WFH/mkdev_blog/djangotesting/taskmanager/todo/tests.py", line 161, in test_due_past
    self.assertFalse(self.task.due_today())
AssertionError: True is not false
----------------------------------------------------------------------
Ran 15 tests in 2.232s
FAILED (failures=2)
Destroying test database for alias 'default'...

Все тесты, не выявившие ошибок, помечаются ., а тесты, показавшие ошибки — F. Такие тесты также показывают, почему именно утверждения не прошли. Мы еще не говорили с вами о тех тестах, которые выявили ошибки, но мы исправимся чуть ниже. Вы могли заметить, что код теста очень подробен. Конечно же, мы протестировали лишь небольшую часть нашего функционала, но, несмотря на это, уже получили столько же строк кода, сколько и в файле представления. Этого и следовало ожидать. Если вы хотите, чтобы ваши тесты были точными, вы не можете проводить их слишком часто. При изменениях в коде некоторые тесты перестанут работать. Таким образом, вы сможете понять, какие ошибки и в каких тестах нужно будет устранить. Так что будьте готовы к тому, что код тестов будет все расти и расти, а в среднестатистическом приложении будет столько же строк тестового кода, сколько и у кода самого приложения.

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

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

Чтобы тесты оставались актуальными, их нужно обновлять по мере обновления кода. Некоторые разработчики пользуются разработкой на основе тестов, чтобы всегда быть готовыми к любым изменениям в коде. Первое, что вы делаете при разработке функции — определяете что, собственно, эта функция будет делать. Разработка через тестирование формализует этот процесс, поскольку при таком подходе вы, прежде всего, прописываете тесты для этой самой функциональности. Основная идея заключается в том, что вы пишите один или несколько тестов, которые определяют функцию, переписываете код до тех пор, пока тесты не выявят ошибок, а затем снова пишите еще больше тестов. Вернемся к тестам, выявившим ошибки. Нам нужно написать метод due_today()в модели Task. Согласно тесту, этот метод должен возвращать True, если задача должна быть выполнена сегодня и False, если нет. Скопируйте код ниже для замены существующего метода due_today() в модели Task , а затем запустите тесты снова. python def due_today(self): return self.due == date.today()

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

Чтобы протестировать разобрались ли вы, попробуйте написать тесты для остальных представлений или используйте тесты для задания новых функций, а затем напишите эти функции. Одним из простых вариантов будет поле с логическим значением completed в модели task, которому может быть присвоено значение True как только задача будет выполнена. Это позволит нам не удалять выполненные задачи, а оставить их. Затем, подумайте о том, чтобы добавить тесты в ваши личные проекты. Да, вас может напугать перспектива покрытия тестами огромного проекта, который ранее не тестировался. Вместо того, чтобы пытаться протестировать все и сразу, попробуйте добавить тесты в маленькие фрагменты проекта или новые функции непосредственно во время разработки до полного покрытия.

Материалы для ознакомления: