Мега-Підручник Flask, Частина 7: Unit-тестування

      Це сьома стаття у серії, де я описую свій досвід написання веб-додатки на 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
 
Як завжди, віртуальне оточення і база даних відсутні. Процес їх створення описаний в попередніх статтях.
 
До нових зустрічей!
 
Мігель
  
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.