Friday, April 20, 2012

Automated python unit testing, code coverage and code quality analysis with Jenkins - part 2

This is the second posting in a series.  Check here for the first posting.

Where we gonna do this?

First let's create a directory and repository to house the source code.   Enter the following commands:

mkdir -p ~/dev/project1
git init ~/dev/project1
cd ~/dev/project1

It should go something like this:

Next, I always add a .gitignore file to my repository so unrelated files aren't tracked in source control.  Mine contains the following:

*.swp
*.pyc
tags
.coverage

Once I've created this file, I add it to the git repository with the following commands:

git add .gitignore
git commit -m"Initial commit.  Added a .gitignore file."

It should look like this:

Let's get some source code in place

Before we get too ahead of ourselves, let's pause for some quick explanation.  I don't like to mix my application code with my unit test code.  I keep them separate.  My application code will eventually need to be deployed somewhere, but the unit test code doesn't.  To that end, I put the application code in one python package named after the project and the unit test code in another package called "tests".  So let's create the two packages with the following commands:

mkdir project1
touch project1/__init__.py
mkdir tests
touch tests/__init__.py

This might not be a bad place to do a quick commit to source control:

git add .
git commit -m"Added two blank packages: project1 and tests"

Again, should look like this:

You could run the test runner, coverage and code analysis tools now, but the output wouldn't be very interesting.  Instead, let's move on and add a failing test.

The first test

If you're a TDD type developer, this shouldn't come as a surprise.  We'll write a test first.  Then write some code in order to get the test to pass.  In the tests directory, create a file named "authentication_tests.py" and place the following code in it:


#!/usr/bin/env python
"""Unit tests for the project1.authentcation module."""


from unittest import TestCase
from mock import patch
import project1.authentication as auth


class StandAloneTests(TestCase):
    """Test the stand-alone module functions."""


    @patch('__builtin__.open')
    def test_login(self, mock_open):
        """Test the login function."""
        mock_open.return_value.read.return_value = \
            "george|bosco\n"
        self.assertTrue(auth.login('george', 'bosco'))

This is a test the will test a function named login that will reside project1.authentication module.  If you run it now, it should fail because we haven't created an authentication module yet, much less a login function.  To run the tests, simply type "nosetests" from the project root directory.  Nose will scan your whole directory structure looking for unit tests to run.

Let's get the test to pass

Before we can move on to the topics of coverage and quality, we need to get the test to pass.  To do this,  create a file in the project1 directory named "authentication.py" and place the following code in it:

#!/usr/bin/env python
"""This module provides functions for authenticating users."""

def login(username, password):
    try:
        user_file = open('/etc/users.txt')    
        user_buf = user_file.read()
        users = [line.split("|") for line in user_buf.split("\n")]
        if [username, password] in users:
            return True
        else:
            return False
    except Exception, exc:
        print "I can't authenticate you."
        return False

Now when you run the test, everything should be hunky-dory.

Code Coverage

You're probably thinking things look pretty good right about now.  Let's run the the code coverage tool to see where we stand.  To run a coverage test, use this command: "nosetests --with-coverage --cover-package=project1".  You'll find that we only have 67% coverage and that our test doesn't exercise lines 12-15 of our login method. Yikes.

You see...  In our single test, we're only covering the happy path(tm).  That is, we only check to see what the function does when a user enters a valid username/password.  We don't check what happens when they enter an invalid username/password.  Additionally, we don't check to see what the function does when it can't read the user file at all.  We would need to add more tests for that.  Edit the tests/authentication_tests.py file and make it look like this:

#!/usr/bin/env python
"""Unit tests for the project1.authentication module."""

from unittest import TestCase
from mock import patch
import project1.authentication as auth

class StandAloneTests(TestCase):
    """Test the stand-alone module functions."""

    @patch('__builtin__.open')
    def test_login_success(self, mock_open):
        """Test the login function when things go right."""
        mock_open.return_value.read.return_value = \
            "george|bosco"
        self.assertTrue(auth.login('george', 'bosco'))

    @patch('__builtin__.open')
    def test_login_bad_creds(self, mock_open):
        """Test the login function when bad creds are passed."""
        mock_open.return_value.read.return_value = \
            "george|bosco"
        self.assertFalse(auth.login('george', 'marbleRye'))

    @patch('__builtin__.open')
    def test_login_error(self, mock_open):
        """Test the login function when an error happens."""
        mock_open.side_effect = IOError()
        self.assertFalse(auth.login('george', 'bosco'))

Notice I've added two more tests:  one to test invalid creds and one to test an exception occurring.  Now we have 100% coverage.

Code Quality

So we now have tests that pass and 100% coverage.  That's awesome!  Everything's under control now.  What did you say?  "How about the quality of that code?"  Let's check it out and see.  To take a look at code quality use the following command: "pylint -r n project1".  Ah. Well.  We have a few things to clean up.  Pylint is notoriously whiny.  That said, the things it's complaining about in this case are legitimate if you're concerned about being a good python developer.

So let's get ourselves into compliance.  First let's add a docstring to the project1 package.  Edit project1/__init__.py and make it look like this:

#!/usr/bin/env python
"""
Project1 is Steve's example project for the blog.
It contains sample code here and there.
"""

That takes care of the first pylint complaint.  Next let's fix up the project1/authentication.py file.  Edit it and make it look like this:

#!/usr/bin/env python
"""This module provides functions for authenticating users."""

def login(username, password):
    """Log the user in."""
    try:
        user_file = open('/etc/users.txt')    
        user_buf = user_file.read()
        users = [line.split("|") for line in user_buf.split("\n")]
        return [username, password] in users
    except IOError:
        print "I can't authentication you."
        return False

Now when you run pylint, you should have no complaints:

If you want a more detailed report from pylint, try using the command, "pylint -r y project1" instead.  Used that way, it's very much more verbose.


Let's clean up

So here's what we've accomplished so far.  We created a project and source code repository.  We've created a sample piece of code and some tests for it.  We've ensured that we have 100% code coverage and we've analysed our code quality and found it to be in good shape.  Let's pause and make sure that all our work is committed to source control.


That's it for now.  In the next post, I will walk through installing Jenkins and configuring it to monitor your source code repository.  It will notice when you commit new code and automatically run your unit tests, code coverage and code quality analysis.  It can be configured to notify you when things don't look so good.  It can also display nice graphs over time of how your code is progressing.  Stay tuned.



2 comments:

  1. Hi Steve, thanks a lot for this tutorial: I implemented it in Python 3.5; I had to modify just these 2 things:
    - @patch('__builtin__.open') --> @patch('builtins.open')
    - except Exception, exc: --> except Exception as exc:

    ReplyDelete
    Replies
    1. Thanks, had the same issues and this helped.

      Delete