专注细节
努力进步

Flask-Unit Testing

unit Testing

What the bug is ?

The problem in the application is that there is no effort to keep the nicknames of our users unique. The initial nickname of a user is chosen automatically by the OpenID provider, if not provided, we will use the username part of the email address as nickname. If we get two users with the same nickname then the second one will not be able to register.

Flask debugging support

You need two OpenID accounts to reproduce this bug:

  • Login with your first account
  • go to the edit profile page and change the nickname to ‘dup’
  • logout
  • login with your second account
  • go to the edit profile page and change the nickname to ‘dup’

Then we will get an exception from sqlalchemy:

IntegrityError: (IntegrityError) column nickname is not unique u'UPDATE user SET nickname=?, about_me=? WHERE user.id = ?' (u'dup12', u'', 2)

The database model had a unique constrain on the nickname field, so this is an invalid operation.

All this time we have been running our application in debug mode(by passing a debug=True).

If we want to diable the dubug mode, we just pass the debug=False. Then, change the nickname, we would just get a error 500 Internal Server Error.

Custom HTTP error handlers

Flask provides a mechanism for an application to install its own error pages. As an example, let’s define custom error for the HTTP errors 404 and 500, the two most common ones.
To declare a custom error handler the errorhandler decorator is used:

@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

If the exception was triggered by a database error, then the database session is going to arrive in an invalid state, so we have to roll it back in case a working session is needed for rendering of the template for the 500 error.

Sending errors via email

Before we get into this let’s configure an email server and an administrator list in our application.

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

# Administrator list
ADMINS = ['dss_1990@sina.com']

Change the code in 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)

Testing this on a development PC that does not have an email server is easy, thanks to Python’s SMTP debugging server. Just open a new console window (command prompt for Windows users) and run the following to start a fake email server.

python -m smtpd -n -c DebuggingServer localhost:25

When this is running, the emails sent by the application will received and displayed in the console window.
Email notifications In CMD

Logging to a file

Receiving errors via email is nice, but sometimes we need keep track of them in alog in case we need to do some debugging.

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)
    db.session.add(user)
    db.session.commit()

RotatingFileHandler set a limit of the amount of logs, each size is limit to be 1m, and we will keep last 10 log files as backup.

logging.Formatter set the format for the log messages, including the timestamp, levelname, message, the file’s pathname and lineno.

Make the logging file more useful, we lower the logging as ‘INFO’.

The bug fix

Now, still remember our nickname bug? Let’s fix it!

    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)
        db.session.add(user)
        db.session.commit()

Now, we define the make_unique_nickname method:

    class User(db.Model):
    # ...
    @staticmethod
    def make_unique_nickname(nickname):
        if User.query.filter_by(nickname=nickname).first() is None:
            return nickname
        version = 2
        while True:
            new_nickname = nickname + str(version)
            if User.query.filter_by(nickname=new_nickname).first() is None:
                break
            version += 1
        return new_nickname

When the database has a same nickname, we just add the version to be a new nickname. We coded the method as a static method, since this operation does not apply to any particular instance of the class.

If a new user which have a same nickname with a older user, we just modify the new user’s nickname to add version at the end of his nickname.

nickname bug when two user hava the same nickname

The second place we need to modify is the view function for the edit profile page. The correct thing we need to do here is to not accept a duplicated nickname and lest the user enter another one. Here we add a validation to the nickname by using the form’s validate method:

from app.models import User

class EditForm(Form):
    nickname = TextField('nickname', validators=[DataRequired()])
    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

The form construction now take a new argument original_nickname. Thevalidate use it to determine if the nickname has changed or not. If it hasn’t changed then it accepts it. If it has changed, then make sure that the new nickname does not exist in the database.

Next, add the new constructor argument to the view function.

@app.route('/edit', methods=['GET', 'POST'])
@login_required
def edit():
    form = EditForm(g.user.nickname)

To enable this change we have to enable field errors to show in our template file.

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

Here nickname is the id of the StringField. In validate method, we have append the error message into the nickname(StringField) errors list.

Unit testing framework

Now, Let’s talk about automated testing!
We will now build a very simple testing framework using unittest module.

#!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['WTF_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()

The setUp and tearDown methods are special, these are run before and after each test respectively. A more complex setup could include several groups of tests each represented by a unittest.TestCase subclass, and each group then would have independent setUp and tearDown methods.

分享到:更多 ()