None

Django Tests with Code Coverage

December 7, 2009

This post covers the mechanism I use for producing code coverage reports from django's test framework. If you run ./manage.py test app_name then it will run the tests in that app name without coverage. If you run ./manage.py test then all the tests will be run and coverage reports will be generated.

You'll need coverage.py on the path. You can get this from http://nedbatchelder.com/code/coverage/.

This method also expects that you've got coverage_color.py on the path. The source for this is shown at the end of this post.

settings.py

settings.py overrides the default test runner, sets the directory to write syntax hightlighted code (as HTML files) to, and defines which source files will get code coverage reports.

TEST_RUNNER='project.tests.test_runner_with_coverage'
COVERAGE_MODULES=['project.bands.models','project.bands.views', 'project.bands.sitemap',
              'project.conductors.models','project.conductors.views', 'project.conductors.sitemap', 
              'project.contests.models','project.contests.views', 'project.contests.sitemap',
             ]
COVERAGE_DIR='/path/to/project-coverage'

tests.py

This test runner overrides the default django test runner to add coverage.

import os, shutil, sys, unittest
import coverage_color

# Look for coverage.py in __file__/lib as well as sys.path
sys.path = [os.path.join(os.path.dirname(__file__), "lib")] + sys.path

import coverage
from django.test.simple import run_tests as django_test_runner

from django.conf import settings

def test_runner_with_coverage(test_labels, verbosity=1, interactive=True, extra_tests=[]):
  """Custom test runner.  Follows the django.test.simple.run_tests() interface."""
  # Start code coverage before anything else if necessary
  if hasattr(settings, 'COVERAGE_MODULES') and not test_labels:
    print 'Testing with Code Coverage'
    coverage.use_cache(0) # Do not cache any of the coverage.py stuff
    coverage.start()

  test_results = django_test_runner(test_labels, verbosity, interactive, extra_tests)

  # Stop code coverage after tests have completed
  if hasattr(settings, 'COVERAGE_MODULES') and not test_labels:
    coverage.stop()

    # Print code metrics header
    print ''
    print '----------------------------------------------------------------------'
    print ' Unit Test Code Coverage Results'
    print '----------------------------------------------------------------------'

  # Report code coverage metrics
  if hasattr(settings, 'COVERAGE_MODULES') and not test_labels:
    coverage_modules = []
    for module in settings.COVERAGE_MODULES:
      coverage_modules.append(__import__(module, globals(), locals(), ['']))

    coverage.report(coverage_modules, show_missing=1)

    if not os.path.exists(settings.COVERAGE_DIR):
      os.makedirs(settings.COVERAGE_DIR)
    lIndexFile = open('%s/coverage.txt' % (settings.COVERAGE_DIR), "w")
    coverage.report(coverage_modules, show_missing=1,file=lIndexFile)
    for module_string in settings.COVERAGE_MODULES:
      module = __import__(module_string, globals(), locals(), [""])
      f,s,m,mf = coverage.analysis(module)
      fp = file(os.path.join(settings.COVERAGE_DIR, module_string + ".html"), "wb")
      coverage_color.colorize_file(f, outstream=fp, not_covered=mf)
      fp.close()
     coverage.erase()

    # Print code metrics footer
    print '----------------------------------------------------------------------'

  return test_results

coverage_color.py

# -*- coding: iso-8859-1 -*-
#
# Code coverage colorization:
#  - sébastien Martini <sebastien.martini@gmail.com>
#    * 5/24/2006 fixed: bug when code is completely covered (Kenneth Lind).
#
# Original recipe:
#  http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52298
#
# Original Authors:
#  - Jürgen Hermann
#  - Mike Brown <http://skew.org/~mike/>
#  - Christopher Arndt <http://chrisarndt.de>
#
import cgi
import string
import sys
import cStringIO
import os
import keyword
import token
import tokenize

_VERBOSE = False

_KEYWORD = token.NT_OFFSET + 1
_TEXT    = token.NT_OFFSET + 2

_css_classes = {
    token.NUMBER:       'number',
    token.OP:           'operator',
    token.STRING:       'string',
    tokenize.COMMENT:   'comment',
    token.NAME:         'name',
    token.ERRORTOKEN:   'error',
    _KEYWORD:           'keyword',
    _TEXT:              'text',
}

_HTML_HEADER = """\
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"
  "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>code coverage of %(title)s</title>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1">

<style type="text/css">
pre.code {
    font-style: Lucida,"Courier New";
}
.number {
     color: #0080C0;
}
.operator {
    color: #000000;
}
.string {
    color: #008000;
}
.comment {
    color: #808080;
}
.name {
    color: #000000;
}
.error {
    color: #FF8080;
    border: solid 1.5pt #FF0000;
}
.keyword {
    color: #0000FF;
    font-weight: bold;
}
.text {
    color: #000000;
}
.notcovered {
    background-color: #FFB2B2;
}
</style>

</head>
<body>
"""

_HTML_FOOTER = """\
</body>
</html>
"""

class Parser:
    """ Send colored python source.
    """
    def __init__(self, raw, out=sys.stdout, not_covered=[]):
        """ Store the source text.
        """
        self.raw = string.strip(string.expandtabs(raw))
        self.out = out
        self.not_covered = not_covered  # not covered list of lines
        self.cover_flag = False  # is there a <span> tag opened?

    def format(self):
        """ Parse and send the colored source.
        """
        # store line offsets in self.lines
        self.lines = [0, 0]
        pos = 0
        while 1:
            pos = string.find(self.raw, '\n', pos) + 1
            if not pos: break
            self.lines.append(pos)
        self.lines.append(len(self.raw))

        # parse the source and write it
        self.pos = 0
        text = cStringIO.StringIO(self.raw)
        self.out.write('<pre class="code">\n')
        try:
            tokenize.tokenize(text.readline, self)
        except tokenize.TokenError, ex:
            msg = ex[0]
            line = ex[1][0]
            self.out.write("<h3>ERROR: %s</h3>%s\n" % (
                msg, self.raw[self.lines[line]:]))
        if self.cover_flag:
            self.out.write('</span>')
            self.cover_flag = False
        self.out.write('\n</pre>')

    def __call__(self, toktype, toktext, (srow,scol), (erow,ecol), line):
        """ Token handler.
        """
        if _VERBOSE:
            print "type", toktype, token.tok_name[toktype], "text", toktext,
            print "start", srow,scol, "end", erow,ecol, "<br>"

        # calculate new positions
        oldpos = self.pos
        newpos = self.lines[srow] + scol
        self.pos = newpos + len(toktext)

        if not self.cover_flag and srow in self.not_covered:
            self.out.write('<span class="notcovered">')
            self.cover_flag = True

        # handle newlines
        if toktype in [token.NEWLINE, tokenize.NL]:
            if self.cover_flag:
                self.out.write('</span>')
                self.cover_flag = False

        # send the original whitespace, if needed
        if newpos > oldpos:
            self.out.write(self.raw[oldpos:newpos])

        # skip indenting tokens
        if toktype in [token.INDENT, token.DEDENT]:
            self.pos = newpos
            return

        # map token type to a color group
        if token.LPAR <= toktype and toktype <= token.OP:
            toktype = token.OP
        elif toktype == token.NAME and keyword.iskeyword(toktext):
            toktype = _KEYWORD
        css_class = _css_classes.get(toktype, 'text')

        # send text
        self.out.write('<span class="%s">' % (css_class,))
        self.out.write(cgi.escape(toktext))
        self.out.write('</span>')

class MissingList(list):
    def __init__(self, i):
        list.__init__(self, i)

    def __contains__(self, elem):
        for i in list.__iter__(self):
            v_ = m_ = s_ = None
            try:
                v_ = int(i)
            except ValueError:
                m_, s_ = i.split('-')
            if v_ is not None and v_ == elem:
                return True
            elif (m_ is not None) and (s_ is not None) and \
                     (int(m_) <= elem) and (elem <= int(s_)):
                return True
        return False

def colorize_file(filename, outstream=sys.stdout, not_covered=[]):
    """
    Convert a python source file into colorized HTML.

    Reads file and writes to outstream (default sys.stdout).
    """
    fo = file(filename, 'rb')
    try:
        source = fo.read()
    finally:
        fo.close()
    outstream.write(_HTML_HEADER % {'title': os.path.basename(filename)})
    Parser(source, out=outstream,
           not_covered=MissingList((not_covered and \
                                    not_covered.split(', ')) or \
                                   [])).format()
    outstream.write(_HTML_FOOTER)

Tags: django test coverage