python

Мега-Учебник Flask, Часть 7: Unit-тестирование

  • суббота, 24 мая 2014 г. в 03:10:28
http://habrahabr.ru/post/223783/

Это седьмая статья в серии, где я описываю свой опыт написания веб-приложения на Python с использованием микрофреймворка Flask.

Цель данного руководства — разработать довольно функциональное приложение-микроблог, которое я за полным отсутствием оригинальности решил назвать microblog.

Оглавление
Часть 1: Привет, Мир!
Часть 2: Шаблоны
Часть 3: Формы
Часть 4: База данных
Часть 5: Вход пользователей
Часть 6: Страница профиля и аватары
Часть 7: Unit-тестирование (данная статья)
Часть 8: Подписчики, контакты и друзья
Часть 9: Пагинация
Часть 10: Полнотекстовый поиск
Часть 11: Поддержка e-mail
Часть 12: Реконструкция
Часть 13: Дата и время
Часть 14: I18n and L10n
Часть 15: Ajax
Часть 16: Отладка, тестирование и профилирование
Часть 17: Развертывание на Linux (даже на Raspberry Pi!)
Часть 18: Развертывание на Heroku Cloud

Краткое повторение


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

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


Поиски бага


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

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

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

Отладка во Flask


Прежде всего создадим новую базу данных. Если у вас Linux:
rm app.db
./db_create.py

Windows:
del app.db
flask/Scripts/python db_create.py

Вам понадобятся два OpenID аккаунта для воспроизведения бага, лучше, если эти аккаунты будут от разных провайдеров, иначе могут быть некоторые сложности с cookies. Итак, порядок действий таков:
  • Залогиньтесь, используя первый аккаунт.
  • На странице редактирования профиля измените ник на 'dup'
  • Выйдите из системы
  • Зайдите с помощью второго аккаунта
  • На странице редактирования профиля измените ник на 'dup'

Упс! Мы получили исключение, вызванное sqlalchemy. Текст ошибки гласит:
sqlalchemy.exc.IntegrityError
IntegrityError: (IntegrityError) UNIQUE constraint failed: user.nickname u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?' (u'dup', u'', 2)

Ниже находится трассировка стека этой ошибки, в которой можно не только посмотреть исходный код каждого фрейма, но даже выполнять свой код прямо в браузере!

Описание ошибки весьма однозначно. Ник, который мы пытались присвоить пользователю, уже содержится в базе данных. Обратите внимание, что в модели User поле nickname объявлено как unique=True, именно поэтому возникла такая ситуация.

В дополнение к этой ошибке у нас есть еще одна проблема. Если действия пользователя приводят к ошибке (этой или любой другой), он увидит описание ошибки и весь трейсбэк. Это очень удобно во время разработки, но мы точно не хотели бы, чтобы кто-нибудь кроме нас это видел.

Всё это время наше приложение работало в режиме отдалки. Этот режим активируется при запуске, с помощью аргумента debug=True, переданного методу run. Когда мы будем запускать приложение на боевом сервере, необходимо будет убедиться, что режим отладки выключен. Для этого нам пригодится еще один скрипт (файл runp.py):

#!flask/bin/python
from app import app
app.run(debug = False)


Теперь запустим приложение с помощью этого скрипта
./runp.py


Попробуйте еще раз сменить ник второго аккаунта на 'dup'. В этот раз мы не увидим подробного сообщения об ошибке. Вместо этого мы получим HTTP ошибку с кодом 500 Internal Server Error. Эту страницу Flask генерирует, когда режим отладки выключен и при этом происходит исключительная ситуация. Страница выглядит так себе, но мы хотя бы не раскрываем лишних подробностей о приложении.

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

Собственные обработчики ошибок HTTP


Flask позволяет приложениям использовать свои собственные страницы для отображения ошибок. В качестве примера реализуем свои страницы для ошибок 404 и 500, так как они наиболее часто встречаются. Для других ошибок процесс создания собственных страниц будет точно таким же.

Чтобы объявить собственный обработчик ошибок, нам понадобится декоратор errorhandler (файл app/views.py):
@app.errorhandler(404)
def not_found_error(error):
    return render_template('404.html'), 404

@app.errorhandler(500)
def internal_error(error):
    db.session.rollback()
    return render_template('500.html'), 500


Не думаю, что этот код нуждается в разъяснениях. Единственное выражение, заслуживающее внимания здесь — db.session.rollback(). Эта функция будет вызываться в результате исключения. Если исключение было вызвано ошибкой взаимодействия с базой данных, нам необходимо откатить текущую сессию.

Шаблон для ошибки 404:
<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
<h1>File Not Found</h1>
<p><a href="{{url_for('index')}}">Back</a></p>
{% endblock %}


А также шаблон для ошибки 500:
<!-- extend base layout -->
{% extends "base.html" %}

{% block content %}
<h1>An unexpected error has occurred</h1>
<p>The administrator has been notified. Sorry for the inconvenience!</p>
<p><a href="{{url_for('index')}}">Back</a></p>
{% endblock %}


В обоих случаях мы по прежнему используем в качестве родительского шаблона base.html, в результате чего оба сообщения о HTTP ошибках выглядят в одном стиле с остальными страницами нашего микроблога.

Отправка сообщений об ошибках на почту


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

Прежде всего нам необходимо настроить почтовый сервер и список администраторов нашего приложения. (файл config.py):

# mail server settings
MAIL_SERVER = 'localhost'
MAIL_PORT = 25
MAIL_USERNAME = None
MAIL_PASSWORD = None

# administrator list
ADMINS = ['you@example.com']


Само собой, вам нужно изменить эти значения.

Flask использует модуль logging из стандартной библиотеки Python, поэтому настроить отправку на почту сообщений об ошибках будет довольно просто (файл app/__init__.py):

from config import basedir, ADMINS, MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD

if not app.debug:
    import logging
    from logging.handlers import SMTPHandler
    credentials = None
    if MAIL_USERNAME or MAIL_PASSWORD:
        credentials = (MAIL_USERNAME, MAIL_PASSWORD)
    mail_handler = SMTPHandler((MAIL_SERVER, MAIL_PORT), 'no-reply@' + MAIL_SERVER, ADMINS, 'microblog failure', credentials)
    mail_handler.setLevel(logging.ERROR)
    app.logger.addHandler(mail_handler)


Мы будем отправлять эти письма только в том случае, если выключен режим отладки. Ничего страшного, если у вас нет настроенного почтового сервера. Для наших целей вполне подойдёт отладочный сервер SMTP, который нам предоставляет Python. Чтобы запустить его, введите в консоли (или в командной строке, если вы пользователь Windows):
python -m smtpd -n -c DebuggingServer localhost:25

После этого, все письма, отправленные приложением будут перехватываться и отображаться прямо в консоли. (Прим.пер. Вы также можете воспользоваться крайне удобным SMTP сервером, не требующим специальных знаний для настройки — Mailcatcher. Этот способ значительно удобнее, но при этом требует установки Ruby.)

Запись лога в файл


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

Процесс настройки очень похож на то, что мы только что делали для почты (файл app/__init__.py):

if not app.debug:
    import logging
    from logging.handlers import RotatingFileHandler
    file_handler = RotatingFileHandler('tmp/microblog.log', 'a', 1 * 1024 * 1024, 10)
    file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
    app.logger.setLevel(logging.INFO)
    file_handler.setLevel(logging.INFO)
    app.logger.addHandler(file_handler)
    app.logger.info('microblog startup')


Лог будет сохраняться в папке tmp под именем microblog.log. Мы использовали RotatingFileHandler, что позволяет установить лимит на количество хранимых данных. В нашем случае размер файла ограничен одним мегабайтом, при этом сохраняются последние десять файлов.

Класс logging.Formatter предоставляет возможность задавать произвольный формат записей в логе. Так как мы хотим получать как можно более подробную информацию, мы будем сохранять само сообщение, timestamp, статус записи, а также имя файла и номер строки, откуда была инициирована запись.

Чтобы сделать лог более полезным, мы снижаем уровень логгирования как в app.logger, так и в file_handler, это позволит нам записывать не только ошибки, но и другую информацию, которая может оказаться полезной. К примеру, мы будем записывать время запуска приложения. Теперь каждый раз, когда микроблог будет запущен без режима отладки, в лог будет сохраняться это событие.

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

Исправление ошибок


Итак, давайте наконец исправим баг с одинаковыми никнеймами.

Как было сказано ранее, у нас есть два проблемных места, где отсутствует проверка дубликатов. Первое — в обработчике after_login, который вызывается когда пользователь авторизуется в системе и нам нужно создать новый объект User. Вот, что мы можем сделать, чтобы избавиться от проблемы: (файл app/views.py):

if user is None:
        nickname = resp.nickname
        if nickname is None or nickname == "":
            nickname = resp.email.split('@')[0]
        nickname = User.make_unique_nickname(nickname)
        user = User(nickname = nickname, email = resp.email, role = ROLE_USER)
        db.session.add(user)
        db.session.commit()


Наше решение заключается в том, чтобы поручить классу User создание уникального ника. Вот как это реализуется (файл app/models.py):
class User(db.Model):
    # ...
    @staticmethod
    def make_unique_nickname(nickname):
        if User.query.filter_by(nickname = nickname).first() == None:
            return nickname
        version = 2
        while True:
            new_nickname = nickname + str(version)
            if User.query.filter_by(nickname = new_nickname).first() == None:
                break
            version += 1
        return new_nickname
    # ...


Этот метод просто добавляет счетчик к нику, пока он не станет уникальным. Например, если пользователь «miguel» уже существует, в методе будет предложен вариант «miguel2», затем, если и такой пользователь есть, «miguel3» и так далее. Обратите внимание на декоратор staticmethod, мы применили его, так как эта операция не привязана к конкретному инстансу класса.

Второе место, где проблема дубликатов по прежнему актуальна — страница редактирования профиля. В этом случае всё несколько усложняется тем, что пользователь сам выбирает свой ник. Лучшим решением в данном случае будет проверка на уникальность и в случае неудачи предложение выбрать другой ник. Для этого нам понадобится добавить еще один валидатор для соответствующего поля. Если пользователь введёт существующий ник, форма просто не пройдет валидацию. Чтобы добавить свой валидатор, необходимо перегрузить метод validate (файл app/forms.py):
from app.models import User

class EditForm(Form):
    nickname = TextField('nickname', validators = [Required()])
    about_me = TextAreaField('about_me', validators = [Length(min = 0, max = 140)])

    def __init__(self, original_nickname, *args, **kwargs):
        Form.__init__(self, *args, **kwargs)
        self.original_nickname = original_nickname

    def validate(self):
        if not Form.validate(self):
            return False
        if self.nickname.data == self.original_nickname:
            return True
        user = User.query.filter_by(nickname = self.nickname.data).first()
        if user != None:
            self.nickname.errors.append('This nickname is already in use. Please choose another one.')
            return False
        return True


Конструктор формы теперь принимает новый аргумент — original_nickname. Метод validate использует его для того, чтобы определить — изменился ник или нет. Если изменился, проводим проверку на уникальность.

Проинициализируем форму новым аргументом:
@app.route('/edit', methods = ['GET', 'POST'])
@login_required
def edit():
    form = EditForm(g.user.nickname)
    # ...


Также нам нужно выводить все ошибки, возникающие при валидации формы, рядом с полем, в котором обнаружена ошибка. (файл app/templates/edit.html):

<td>Your nickname:</td>
<td>
    {{form.nickname(size = 24)}}
    {% for error in form.errors.nickname %}
    <br><span style="color: red;">[{{error}}]</span>
    {% endfor %}
</td>


Вот и всё, проблема с дубликатами решена! Правда, у нас всё еще есть потенциальная проблема с одновременным доступом к базе данных из нескольких потоков или процессов, но эта тема будет освещена одной из будущих статей.

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

Фреймворк для тестирования


С развитием приложения становится всё сложнее проверять, что изменения в коде не сломают существующий функционал. Именно поэтому так важно автоматизировать процесс тестирования.

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

Давайте напишем небольшой фреймворк для тестирования с помощью модуля unittest (файл app/templates/edit.html):

#!flask/bin/python
import os
import unittest

from config import basedir
from app import app, db
from app.models import User

class TestCase(unittest.TestCase):
    def setUp(self):
        app.config['TESTING'] = True
        app.config['CSRF_ENABLED'] = False
        app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + os.path.join(basedir, 'test.db')
        self.app = app.test_client()
        db.create_all()

    def tearDown(self):
        db.session.remove()
        db.drop_all()

    def test_avatar(self):
        u = User(nickname = 'john', email = 'john@example.com')
        avatar = u.avatar(128)
        expected = 'http://www.gravatar.com/avatar/d4c74594d841139328695756648b6bd6'
        assert avatar[0:len(expected)] == expected

    def test_make_unique_nickname(self):
        u = User(nickname = 'john', email = 'john@example.com')
        db.session.add(u)
        db.session.commit()
        nickname = User.make_unique_nickname('john')
        assert nickname != 'john'
        u = User(nickname = nickname, email = 'susan@example.com')
        db.session.add(u)
        db.session.commit()
        nickname2 = User.make_unique_nickname('john')
        assert nickname2 != 'john'
        assert nickname2 != nickname

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


Обсуждение модуля unittest лежит за пределами этой статьи. Если вы не знакомы с этим модулем, можете пока просто считать, что в классе TestCase находятся наши тесты. Методы setUp и tearDown имеют особое значение, они выполняются до и после каждого теста соответственно. В более сложных случаях может быть определено несколько групп тестов, где каждая группа является подклассом unittest.TestCase, тогда каждая группа будет иметь собственные методы setUp и tearDown.

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

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

Итак, у нас есть два теста в нашем фреймворке. Первый проверяет URL аватара, который мы реализовали в прошлой главе. Второй тест проверяет метод make_unique_nickname, который мы только что написали для класса User. Сперва создаётся пользователь с именем 'john'. После того, как он сохранён в базе данных, тестируемый метод должен возвращать ник, отличный от 'john'. Затем мы создаём и сохраняем второго пользователя с предложенным ником и проверяем, что еще один вызов make_unique_nickname вернёт имя, отличное от имён обоих созданных пользователей.

Запустим тестирование:

./tests.py


Если при этом будут возникнут какие-либо ошибки, они будут выведены в консоль.

Заключительные слова


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

Актуальный код микроблога можно скачать по следующей ссылке:
Скачать microblog-0.7.zip

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

До новых встреч!

Мигель