Flask: Setting Up Tests and Logging.

Hello, welcome back to the 8th post of my Flask series, this post would be a continuation of my previous post, please click on any link below to view any post you missed

OUTLINE OF FLASK SERIES

At the end of this post, you will learn the following

  • Set up unit tests.
  • Run unit tests and check testing coverage
  • Logging errors to a file and sending error logs via email.

We would install new dependencies for our new post

  • coverage:  It measures code coverage, typically during test execution. It uses the code analysis tools and tracing hooks provided in the Python standard library to determine which lines are executable, and which have been executed.

Create a new database for testing

mysql -u root
mysql> CREATE DATABASE test_db;
Query OK, 1 row affected (0.00 sec)
mysql> GRANT ALL PRIVILEGES ON test_db . * TO 'diary_admin'@'localhost';
Query OK, 0 rows affected (0.00 sec)

Update your env file and add the database URL for testing

TEST_DATABASE_URL='mysql+pymysql://*******'

Our tests would be written using the standard unittest package from the Python standard library.

 

We would add the following code to our main.py in order to execute custom commands for our tests and to have our coverage metrics integrate into the flask test .

import os
import subprocess
import sys
import unittest

import click
import coverage
from dotenv import load_dotenv

from api.app import create_app

dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
if os.path.exists(dotenv_path):
    load_dotenv(dotenv_path)

app = create_app(os.getenv("FLASK_ENV"))

@app.cli.command()
def test():
    """Run the unit tests."""    
    tests = unittest.TestLoader().discover('tests')   
    unittest.TextTestRunner(verbosity=2).run(tests)

COV = None
if os.environ.get('FLASK_COVERAGE'):
    COV = coverage.coverage(branch=True, include=('resources/*','api/*'))
    COV.start()


@app.cli.command()
@click.option('--coverage/--no-coverage', default=False,
              help='Run tests under code coverage.')
@click.argument('test_names', nargs=-1)
def test(coverage, test_names):
    """Run the unit tests."""
    if coverage and not os.environ.get('FLASK_COVERAGE'):
        os.environ['FLASK_COVERAGE'] = '1'
        sys.exit(subprocess.call(sys.argv))
    if test_names:
        tests = unittest.TestLoader().loadTestsFromNames(test_names)
    else:
        tests = unittest.TestLoader().discover('tests')
    unittest.TextTestRunner(verbosity=2).run(tests)
    if COV:
        COV.stop()
        COV.save()
        print('Coverage Summary  of our app: ')
        COV.report()
        basedir = os.path.abspath(os.path.dirname(__file__))
        covdir = os.path.join(basedir, 'tmp/coverage')
        COV.html_report(directory=covdir)
        print('HTML version: file://%s/index.html' % covdir)
        COV.erase()

Update your config.py file too

class TestingConfig(Config):
    TESTING =  True
    # Disable CSRF protection in the testing configuration
    WTF_CSRF_ENABLED = False
    SQLALCHEMY_DATABASE_URI = os.environ.get(
    "TEST_DATABASE_URL"
    )

SET UP TESTS

mkdir tests
touch __init__.py test_user.py  test_note.py

Add the following to test our user resource, add the code snippets below to test_user.py

import datetime
import json
import os
import unittest
from unittest import TestCase
import io

from flask import url_for
from flask_jwt_extended import create_access_token

from api.app import create_app
from api.models import User, db
from utils.email_token import confirm_token, generate_confirmation_token

# registration data
payload = {
    "email": "oluchi@gmail.com",
    "password": "#hdyuER456*&",
    "username": "oluchidfg",
}
# login details
login_details = {
    "email": "oluchi@gmail.com",
    "password": "#hdyuER456*&",
}


class UserTestCase(TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        self.client = self.app.test_client(use_cookies=True)

    def tearDown(self):
        """
        Will be called after every test
        """
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    def test_user_registration_login(self):

        # test user registration
        response = self.client.post(
            "/v1/user/register/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(payload),
        )

        self.assertEqual(response.status_code, 201)
        self.assertEqual(User.query.count(), 1)
        user = User.query.filter_by(username="oluchidfg").first()
        self.assertEqual(user.confirmed, False)
        self.assertEqual(user.confirmed_on, None)

        # Try to login as a user before confirmation

        response = self.client.post(
            "/v1/user/login/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(login_details),
        )
        self.assertTrue(
            "The user account is not activated yet" in response.get_data(as_text=True)
        )

        # now confirm the user
        user = User.query.filter_by(email="oluchi@gmail.com").first()
        token = generate_confirmation_token(user.email)
        response = self.client.get(
            "/users/activate/{}".format(token), follow_redirects=True
        )
        email = confirm_token(token)
        self.assertEqual(response.status_code, 200)
        self.assertTrue(
            "The user account has been activated" in response.get_data(as_text=True)
        )

        # test user after email confirmation
        response = self.client.post(
            "/v1/user/login/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(login_details),
        )
        res_data = response.json
        self.assertEqual(res_data["status"], "success")
        self.assertEqual(res_data["data"]["email"], "oluchi@gmail.com")
        self.assertEqual(res_data["data"]["user_id"], 1)
        self.assertEqual(response.status_code, 200)

        # test_login_with_invalid_password
        login_details["email"] = "oolluuu#@gmail.com"
        response = self.client.post(
            "/v1/user/login/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(login_details),
        )
        self.assertTrue(
            "username or password is incorrect" in response.get_data(as_text=True)
        )
        # try to register user with existing email
        data = {
            "email": "oluchi@gmail.com",
            "password": "#hdyuER456*&",
            "username": "oluchidfggggg",
        }
        response = self.client.post(
            "/v1/user/register/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(data),
        )
        res = response.json
        self.assertEqual(res["message"], "email already exist")
        self.assertEqual(response.status_code, 400)

        # try to register user with invalid email
        data = {
            "email": "oluchiiiigmail.com",
            "password": "#hdyuER456*&",
            "username": "oluchidfggggg",
        }
        response = self.client.post(
            "/v1/user/register/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(data),
        )
        res = response.json
        self.assertEqual(res["errors"]["email"], ["Not a valid email address."])
        self.assertEqual(response.status_code, 422)

        # recover password after forgetting login details
        email = payload["email"]

        token = generate_confirmation_token(email)

        password_reset = {
            "password": "hgRT45&*efg&",
            "confirmation_password": "hgRT45&*efg&",
        }
        email = confirm_token(token)
        response = self.client.patch(
            "/users/reset_password/{}".format(token),
            headers={"Content-Type": "application/json"},
            data=json.dumps(password_reset),
        )

        res = response.json
        self.assertEqual(res["data"]["msg"], "New password was successfully set")
        self.assertEqual(response.status_code, 200)

        # try to reset password with 2 umatching passwords
        email = payload["email"]

        token = generate_confirmation_token(email)

        password_reset = {
            "password": "hgRT45&*efg&",
            "confirmation_password": "hgRT45&fff*efg&",
        }
        email = confirm_token(token)
        response = self.client.patch(
            "/users/reset_password/{}".format(token),
            headers={"Content-Type": "application/json"},
            data=json.dumps(password_reset),
        )

        res = response.json
        self.assertEqual(res["message"], "The two password do not match")
        self.assertEqual(response.status_code, 400)

    def test_display_image_upload(self):
        user = User(
            username="oluchiiii",
            email="oluchi@gmail.com",
            password="xy%^FD345Yyuu*9",
            confirmed=True,
            confirmed_on=datetime.datetime.now(),
        )

        db.session.add(user)
        db.session.commit()
        
        

        access_token = create_access_token(identity=user.id, fresh=True)
        
        #test empty file upload
        data = {}
        response = self.client.put(
        '/v1/user/display_image/', 
        headers={
                "Content-Type": "multipart/form-data",
                "Authorization": f"Bearer {access_token}",
            },data=data,follow_redirects=True,

        )
        
        self.assertEqual(response.status_code, 400)

        #test  file upload
        filedata = {'file' : (io.BytesIO(b"image"), 'style3.jpg')}
        response = self.client.put(
        '/v1/user/display_image/', 
        headers={
                "Content-Type": "multipart/form-data",
                "Authorization": f"Bearer {access_token}",
            },data=filedata,follow_redirects=True,

        )
        res=response.json
        self.assertEqual(res["msg"], "uploaded display image successfully")
        self.assertEqual(response.status_code, 200)



Add the following to test our note resource, add the code snippets below to test_note.py

import datetime
import json
import os
import unittest
from unittest import TestCase

from api.app import create_app
from flask import url_for
from flask_jwt_extended import create_access_token
from api.models import User, db

# registration data


note_payload = {
    "title": "My Flask Testing",
    "notes": "I used Unittest LIbrary",
}


class NoteTestCase(TestCase):
    def setUp(self):
        self.app = create_app("testing")
        self.app_context = self.app.app_context()
        self.app_context.push()
        db.create_all()
        self.client = self.app.test_client(use_cookies=True)

    def tearDown(self):
        """
        Will be called after every test
        """
        db.session.remove()
        db.drop_all()
        self.app_context.pop()

    def test_notes(self):

        user = User(
            username="oluchiiii",
            email="oluchi@gmail.com",
            password="xy%^FD345Yyuu*9",
            confirmed=True,
            confirmed_on=datetime.datetime.now(),
        )

        db.session.add(user)
        db.session.commit()

        access_token = create_access_token(identity=user.id, fresh=True)
        res = self.client.post(
            "/v1/notes/",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
            data=json.dumps(note_payload),
        )

        results = res.json
        self.assertEqual(results["msg"], "successfully created notes")
        self.assertEqual(results["data"]["title_of_post"],  

        note_payload["title"])
        self.assertEqual(results["data"]["contents_of_post"], 

        note_payload["notes"])
        self.assertEqual(res.status_code, 201)

        # get draft notes
        res = self.client.get(
            "/v1/notes/draft/",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
        )

        self.assertEqual(res.status_code, 200)

        # #let's publish the note
        res = self.client.put(
            "/v1/publish_note/1",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
        )

        results = res.json
        self.assertEqual(results["msg"], "your note has been published succesfully")
        self.assertEqual(res.status_code, 200)

        # update note
        note_payload["title"] = "Going to try Pytest"
        res = self.client.put(
            "/v1/notes/1",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
            data=json.dumps(note_payload),
        )

        results = res.json
        self.assertEqual(results["msg"], "records updated successfully")
        self.assertNotEqual(results["data"]["title"], "My Flask Testing")
        self.assertEqual(res.status_code, 200)
        # delete  a note
        res = self.client.delete(
            "/v1/notes/1",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {access_token}",
            },
        )
        results = res.json
        self.assertEqual(results["msg"], "action completed, note has been deleted")
        # write to create a note without authorization
        res = self.client.post(
            "/v1/notes/",
            headers={"Content-Type": "application/json"},
            data=json.dumps(note_payload),
        )

        results = res.json

        self.assertEqual(results["message"], "Missing Authorization Header")
        self.assertEqual(res.status_code, 401)

       



    

Run this on your terminal

flask test
test_notes (test_note.NoteTestCase) ... ok
test_display_image_upload (test_user.UserTestCase) ... ok
test_user_registration_login (test_user.UserTestCase) ... ok

----------------------------------------------------------------------
Ran 3 tests in 6.074s

OK

checking testing coverage

Test coverage is an important indicator of software quality and an essential part of software maintenance. It helps in evaluating the effectiveness of testing by providing data on different coverage items.

Run this on your terminal

flask flask test --coverage
test_notes (test_note.NoteTestCase) ... ok
test_display_image_upload (test_user.UserTestCase) ... ok
test_user_registration_login (test_user.UserTestCase) ... ok

----------------------------------------------------------------------
Ran 3 tests in 7.498s

OK
Coverage Summary  of our app: 
Name                 Stmts   Miss Branch BrPart  Cover
------------------------------------------------------
api/app.py              58     33      0      0    43%
api/models.py           59     47      2      0    20%
api/schemas.py          17     14      0      0    18%
resources/notes.py      74     42     20      7    44%
resources/user.py      147     87     38     10    43%
------------------------------------------------------
TOTAL                  355    223     60     17    39%
HTML version: file:///home/oluchi/Desktop/blog_Notes/diary_app/tmp/coverage/index.html

You notice a new folder tmp was created in your root directory, open the index.html to view on your browser.

We have a coverage of 38% which is not too bad but we can improve the result by writing more tests.

LOGGING A FLASK APP

Logging is a means of tracking events that happen when software runs. The software’s developer adds logging calls to their code to indicate that certain events have occurred. An event is described by a descriptive message which can optionally contain variable data (i.e. data that is potentially different for each occurrence of the event).

Logging is used to collect and store valuable information about the application’s execution, that can be used for troubleshooting (debugging) purposes. Logging includes either storing data to log files, sending it to monitoring services,  event logs, Unix Syslog, etc..

Logging provides a set of convenience functions for simple logging usage. These are debug(), info(), warning(), error() and critical()

The logging functions are named after the level or severity of the events they are used to track. The standard levels and their applicability are described below and arranged in increasing order of severity:

DEBUG Detailed information, typically of interest only when diagnosing problems.
INFO Confirmation that things are working as expected.
WARNING An indication that something unexpected happened, or indicative of some problem in the near future (e.g. ‘disk space low’) but the software is still working as expected.
ERROR Due to a more serious problem, the software has not been able to perform some functions.
CRITICAL A serious error, indicating that the program itself may be unable to continue running.

Handlers

Handler objects are responsible for dispatching the appropriate log messages (based on the log messages’ severity) to the handler’s specified destination.

  • StreamHandler instances send messages to streams (file-like objects).
  • FileHandler instances send messages to disk files.
  • BaseRotatingHandler is the base class for handlers that rotate log files at a certain point. It is not meant to be instantiated directly.
  • RotatingFileHandler instances send messages to disk files, with support for maximum log file sizes and log file rotation.
  • TimedRotatingFileHandler instances send messages to disk files, rotating the log file at certain timed intervals.
  • SocketHandler instances send messages to TCP/IP sockets.
  • DatagramHandler instances send messages to UDP sockets.
  • SMTPHandler instances send messages to a designated email address.

 

There are very few methods in a handler for application developers such as

  • The setLevel() method, specifies the lowest severity that will be dispatched to the appropriate destination.
  • setFormatter() selects a Formatter object for this handler to use and configures the final order, structure, and contents of the log message.                                                                                                      Example :
    logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
    THE TABLE BELOW EXPLAINS  SOME IMPORTANT FORMATTER ATTRIBUTES
Attribute Format Description
asctime %(asctime)s  Time when the log was recorded
created %(created)f Time when the log was created (as returned by time.time()).
name %(name)s Name of the logger used to log the call.
filename %(filename)s Filename portion of pathname.
funcName %(funcName)s Name of function containing the logging call.
levelname %(levelname)s Text logging level for the message ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL').
levelno %(levelno)s Numeric logging level for the message (DEBUG, INFO, WARNING, ERROR, CRITICAL).
lineno %(lineno)d Source line number where the logging call was issued (if available).
message %(message)s The logged message, computed as msg %
args
. This is set when Formatter.format() is invoked.
module %(module)s Module (name portion of filename).
msecs %(msecs)d Millisecond portion of the time when the LogRecord was created.
pathname %(pathname)s Full pathname of the source file where the logging call was issued (if available).

Let’s get into action.

Email Errors to Admins

When running the application on a remote server for production, you probably won’t be looking at the log messages very often. The WSGI server will probably send log messages to a file, and you’ll only check that file if a user tells you something went wrong.

To be proactive about discovering and fixing bugs, you can configure a logging.handlers.SMTPHandler to send an email when errors are logged.

Now update your Config file and add the following

import logging
from logging.handlers import SMTPHandler

from flask import current_app




#================existing code base

class ProductionConfig(Config):
    DEBUG = False
    SQLALCHEMY_DATABASE_URI = os.environ.get(
        "DATABASE_URL"
    ) or "sqlite:///" + os.path.join(basedir, "prod.sqlite")

    @classmethod
    def init_app(cls, app):
        Config.init_app(app)
        # email errors to the administrators

        credentials = (
            current_app.config["MAIL_USERNAME"],
            current_app.config["MAIL_PASSWORD"],
        )
        mailhost = (current_app.config["MAIL_SERVER"], current_app.config["MAIL_PORT"])
        secure = current_app.config["MAIL_USE_TLS"]
        fromaddr = current_app.config["MAIL_DEFAULT_SENDER"]
        ADMINS = ["oluchiexample@com"]
        subject = "Your Application Failed"

        mail_handler = SMTPHandler(
            mailhost=mailhost,
            fromaddr=fromaddr,
            toaddrs=ADMINS,
            subject=subject,
            credentials=credentials,
            secure=secure,
        )
        mail_handler.setLevel(logging.ERROR)
        mail_handler.setFormatter(
            logging.Formatter("[%(asctime)s] %(levelname)s in %(module)s: %(message)s")
        )
        app.logger.addHandler(mail_handler)



 

Logging to a File

Update the ProductionConfig class in your config.py file.

import logging
import os
from logging.handlers import SMTPHandler, RotatingFileHandler

from flask import current_app



class ProductionConfig(Config):
   #======================EXISTING CODE

        #log to a file
        if not os.path.exists('logs'):
            os.mkdir('logs')
        #limit the log size to 102MB
        file_handler = RotatingFileHandler('logs/diary_app.log', maxBytes=102400,
                                        backupCount=10)
        file_handler.setFormatter(logging.Formatter(
            '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'))
        file_handler.setLevel(logging.INFO)
        app.logger.addHandler(file_handler)

        app.logger.setLevel(logging.INFO)
        app.logger.info('Starting our diary API')


 

 

Congratulations

I hope this has been as fun and interesting for you as it has for me! I’m looking forward to hearing your comments below.

 

References

 

Leave a Reply

Your email address will not be published. Required fields are marked *