diff --git a/ChangeLog b/ChangeLog index 8a787280a..9034a6ced 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,9 @@ +2007-11-05 Jim Sack + * src/test directory added + * src/test/{__init__,regrtest,test_init}.py added + * src/test/test/test_util_test.py added + First steps in a new unittest framework + 2007-11-05 Jim Sack * remove 2 files from version control: config.guess and config.sub These are generated by configure (via autogen.sh). diff --git a/src/test/__init__.py b/src/test/__init__.py new file mode 100644 index 000000000..8cf3a16d5 --- /dev/null +++ b/src/test/__init__.py @@ -0,0 +1,46 @@ +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2004-2006 Donald N. Allingham +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# + +# $Id:$ + +""" +This package implements unittest support for GRAMPS + +It includes a test-running program regrtest.py, + and various test support utility modules + (first one created being test_util.py) + +Also includes tests for code in the parent (src) directory +and other tests that may be considered top-level tests + +Note: tests for utility code in this directory would be in a +subdirectory also named test by convention for gramps testing. + +Note: test subdirectories do not normally need to have a +package-enabling module __init__.py, but this one (src/test) does +because it contains utilities used by other test modules. +Thus, this package would allow test code like + from test import test_util + +""" + +# This file does not presently contain any code. + +#===eof=== diff --git a/src/test/regrtest.py b/src/test/regrtest.py new file mode 100755 index 000000000..f78335965 --- /dev/null +++ b/src/test/regrtest.py @@ -0,0 +1,155 @@ +#! /usr/bin/env python +# +# Gramps - a GTK+/GNOME based genealogy program +# +# Copyright (C) 2000-2005 Donald N. Allingham +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA +# +# Original: RunAllTests.py Written by Richard Taylor +# (jgs: revised for embedded "test" subdirs as regrtest.py ) + +""" +Testing framework for performing a variety of unttests for GRAMPS. +""" + +# TODO: review whether logging is really useful for unittest +# it does seem to work .. try -v5 +import logging + +import os +import sys +import unittest +from optparse import OptionParser + +from test import test_util as tu +gramps_root=tu.path_append_parent() + +def make_parser(): + usage = "usage: %prog [options]" + parser = OptionParser(usage) + parser.add_option("-v", "--verbosity", type="int", + dest="verbose_level", default=0, + help="Level of verboseness") + parser.add_option("-p", "--performance", action="store_true", + dest="performance", default=False, + help="Run the performance tests.") + return parser + + +def getTestSuites(loc=gramps_root): + # in a developer's checkout, it is worth filtering-out .svn + # and we only need to look inside test subdirs + # (this might change) + # this is not so performance critical that we can't afford + # a couple of function calls to make it readable + # TODO: handle parts of a module (see unittest.py) + + ldr= unittest.defaultTestLoader + + test_dirname = "test" + test_suffix = "_test.py" + def test_mod(p,ds): + """ test for path p=test dir; removes a dir '.svn' in ds """ + if ".svn" in ds: + ds.remove(".svn") + return os.path.basename(p) == test_dirname + def match_mod(fs): + """ test for any test modules; deletes all non-tests """ + # NB: do not delete fs elements within a "for f in fs" + dels= [f for f in fs if not f.endswith(test_suffix)] + for f in dels: + fs.remove(f) + return len(fs) > 0 + + test_suites = [] + perf_suites = [] + # note that test_mod and match_mod modify passed-in lists + paths = [(path,files) for path,dirs,files in os.walk(loc) \ + if test_mod(path,dirs) and match_mod(files)] + + oldpath = list(sys.path) + for (dir,test_modules) in paths: + sys.path.append(dir) + + for module in test_modules: + if not module.endswith(test_suffix): + raise ValueError + mod = __import__(module[:-len(".py")]) + if getattr(mod,"suite",None): + test_suites.append(mod.suite()) + else: + test_suites.append(ldr.loadTestsFromModule(mod)) + try: + perf_suites.append(mod.perfSuite()) + except: + pass + # remove temporary paths added + sys.path = list(oldpath) + return (test_suites,perf_suites) + + +if __name__ == '__main__': + def logging_init(): + global logger + global console + console = logging.StreamHandler() + console.setLevel(logging.INFO) + console.setFormatter(logging.Formatter( + '%(name)-12s: %(levelname)-8s %(message)s')) + logger = logging.getLogger('Gramps') + logger.addHandler(console) + return console, logger + + def logging_adjust(verbose_level): + if verbose_level == 1: + logger.setLevel(logging.INFO) + console.setLevel(logging.INFO) + elif verbose_level == 2: + logger.setLevel(logging.DEBUG) + console.setLevel(logging.DEBUG) + elif verbose_level == 3: + logger.setLevel(logging.NOTSET) + console.setLevel(logging.NOTSET) + elif verbose_level >= 4: + logger.setLevel(logging.NOTSET) + console.setLevel(logging.NOTSET) + os.environ['GRAMPS_SIGNAL'] = "1" + else: + logger.setLevel(logging.ERROR) + console.setLevel(logging.ERROR) + + console,logger = logging_init() + options,args = make_parser().parse_args() + logging_adjust(options.verbose_level) + + # TODO allow multiple subdirs, modules, or testnames + # (see unittest.py) + # hmmm this is starting to look like a unittest.Testprog + # (maybe with a custom TestLoader) + if args and os.path.isdir(args[0]): + loc = args[0] + else: + loc = gramps_root + + utests, ptests = getTestSuites(loc) + if options.performance: + suite = unittest.TestSuite(ptests) + else: + suite = unittest.TestSuite(utests) + + unittest.TextTestRunner(verbosity=options.verbose_level).run(suite) + +#===eof=== diff --git a/src/test/test/test_util_test.py b/src/test/test/test_util_test.py new file mode 100644 index 000000000..2b3fb9b37 --- /dev/null +++ b/src/test/test/test_util_test.py @@ -0,0 +1,167 @@ +"""unittest (test_util_test.py) for test_util.py""" + +import sys +import os +import tempfile +import unittest as U + +usage_note=""" + ************************************************************** + Testing (and runing) Gramps requires that PYTHONPATH include + the path to the top Gramps directory (where gramps.py resides). + + For example, in bash, a shell export would look like + export PYTHONPATH=/.../src + with the ... filled in appropriately. + ************************************************************** +""" + +# ************************************************************** +# +# Since this module is used by other test modules, it is +# strongly advised to test this module to 100% coverage, +# and in all calling variations, eg: +# run directly, from this dir with and without ./ prefix +# run from other dirs (with path prefix) +# run from within regrtest.py +# run from regrtest.py with other test modules present +# which use the test_util module itself +# +# ************************************************************** + +try: + from test import test_util as tu + ##here = tu.absdir() +except ImportError: + print "Cannot import 'test_util'from package 'test'" + usage_note + exit(1) + + +# grouping into multiple TestCases (classes) is not required, +# but may be useful for various reasons, such as collecting +# tests that share a setUp/tearDown mechanism or that share +# some test data, or just because they're related. +# +# The test function name should not have docstrings, but should +# have names which add to the value of failure reporting, and +# which make it easy to find them within the source. + + +# some enabling infrastructure features +class Test1(U.TestCase): + def test1a_custom_exception(s): + tmsg = "testing" + try: + err = None + raise tu.TestError(tmsg) + except tu.TestError,e: + emsg = e.value + s.assertEqual(emsg, tmsg, + "raising TestError: g=%r e=%r" % (emsg, tmsg)) + + def test1b_msg_reporting_utility(s): + g,e = "got this", "expected that" + m,p = "your message here", "pfx" + tmsg0 = m + "\n .....got:'" + g + \ + "'\n expected:'" + e +"'" + tmsg1 = p + ": " + tmsg0 + s.assertEqual(tu.msg(g,e,m), tmsg0, "non-prefix message") + s.assertEqual(tu.msg(g,e,m,p), tmsg1, "prefix message") + + +# path-related features (note use of tu.msg tested above) +class Test2(U.TestCase): + def test2a_context_via_traceback(s): + e = __file__.rstrip(".co") # eg in *.py[co] + g = tu._caller_context()[0] + g.rstrip('c') + s.assertEqual(g,e, tu.msg(g,e, "_caller_context")) + + def test2b_absdir(s): + here = tu.absdir(); + g=tu.absdir(__file__) + s.assertEqual(g,here, tu.msg(g,here, "absdir")) + + def test2c_path_append_parent(s): + here = tu.absdir(); + par = os.path.dirname(here) + was_there = par in sys.path + if was_there: + while par in sys.path: + sys.path.remove(par) + np = len(sys.path) + + for p in (None, __file__): + s.assertFalse(par in sys.path, "par not in initial path") + if not p: + g = tu.path_append_parent() + else: + g = tu.path_append_parent(p) + s.assertEqual(g,par, tu.msg(g,par, "path_append_parent return")) + s.assertTrue(par in sys.path, "actually appends") + sys.path.remove(par) + l= len(sys.path) + s.assertEqual(l,np, tu.msg(l,np,"numpaths")) + if was_there: + # restore entry state (but no multiples needed!) + sys.path.append(par) + +# make and remove test dirs +class Test3(U.TestCase): + here = tu.absdir() + bases = (here, tempfile.gettempdir()) + asubs = map(lambda b: os.path.join(b,"test_sub"), bases) + home= os.environ["HOME"] + if home: + home_junk = os.path.join(home,"test_junk") + def _rmsubs(s): + import shutil + for sub in s.asubs: + if os.path.isdir(sub): + shutil.rmtree(sub) + + def setUp(s): + s._rmsubs() + if s.home and not os.path.isdir(s.home_junk): + os.mkdir(s.home_junk) + + def tearDown(s): + s._rmsubs() + if s.home and os.path.isdir(s.home_junk): + os.rmdir(s.home_junk) + + def test3a_subdir(s): + for sub in s.asubs: + s.assertFalse(os.path.isdir(sub), "init: no dir %r" % sub) + b,d = os.path.dirname(sub), os.path.basename(sub) + md = tu.make_subdir(d, b) + s.assertTrue(os.path.isdir(sub), "made dir %r" % sub) + s.assertEqual(md,sub, tu.msg(md,sub, + "make_subdir returns path")) + + s2 = os.path.join(sub,"sub2") + tu.make_subdir("sub2", sub) + s.assertTrue(os.path.isdir(s2), "made dir %r" % s2) + f = os.path.join(s2,"test_file") + + open(f,"w").write("testing..") + s.assertTrue(os.path.isfile(f), "file %r exists" % f) + tu.delete_tree(sub) + s.assertFalse(os.path.isdir(sub), + "delete_tree removes subdir %r" % sub ) + + def test3b_delete_tree_constraint(s): + if s.home: + err = None + try: + tu.delete_tree(s.home_junk) + except tu.TestError, e: + err = e.value + s.assertFalse(err is None, + "deltree on %r raises TestError" % (s.home_junk)) + else: + s.fail("Skip deltree constraint test, no '$HOME' var") + +if __name__ == "__main__": + U.main() +#===eof=== diff --git a/src/test/test_util.py b/src/test/test_util.py new file mode 100644 index 000000000..e49431d4d --- /dev/null +++ b/src/test/test_util.py @@ -0,0 +1,143 @@ +"""unittest support utility module""" + +import os +import sys +import traceback +import tempfile +import shutil + +# _caller_context is primarily here to support and document the process +# of determining the test-module's directory. +# +# NB: the traceback 0-element is 'limit'-levels back, or earliest calling +# context if that is less than limit. +# The -1 element is this very function; -2 is its caller, etc. +# A traceback context tuple is: +# (file, line, active function, text of the call-line) +def _caller_context(): + """Return context of first caller outside this module""" + lim = 5 # 1 for this function, plus futrher chain within this module + st = traceback.extract_stack(limit=lim) + thisfile = __file__.rstrip("co") # eg, in ".py[co] + while st and st[-1][0] == thisfile: + del(st[-1]) + if not st: + raise TestError("Unexpected function call chain length!") + return st[-1] + + +# NB: tb[0] differs between running 'XYZ_test.py' and './XYZ_test.py' +# so, always take the abspath. +def _caller_dir(): + """Return directory of caller function (caller outside this module)""" + tb = _caller_context() + return os.path.dirname(os.path.abspath(tb[0])) + + +class TestError(Exception): + """Exception for use by test modules + + Use this, for example, to distuinguish testing errors. + + """ + def __init__(self, value): + self.value = value + def __str__(self): + return repr(self.value) + + +def msg(got, exp, msg, pfx=""): + """Error-report message formatting utility + + This improves unittest failure messages by showing data values + Usage: + assertEqual(got,exp, msg(got,exp,"mess" [,prefix]) + The failure message will show as + [prefix: ] mess + .....got:repr(value-of-got) + expected:repr(value-of-exp) + + """ + if pfx: + pfx += ": " + return "%s%s\n .....got:%r\n expected:%r" % (pfx, msg, got, exp) + + +def absdir(path=None): + """Return absolute dir of the specified path + + The path parm may be dir or file or missing. + If a file, the dir of the file is used. + If missing, the dir of test-module caller is used + + Common usage is + here = absdir() + here = absdir(__file__) + These 2 return the same result + + """ + if not path: + path = _caller_dir() + loc = os.path.abspath(path) + if os.path.isfile(loc): + loc = os.path.dirname(loc) + return loc + + +def path_append_parent(path=None): + """Append (if required) the parent of a path to the python system path, + and return the abspath to the parent as a possible convenience + + The path parm may be a dir or a file or missing. + If a file, the the dir of the file is used. + If missing the test-module caller's dir is used. + And then the parent of that dir is appended (if not already present) + + Common usage is + path_append_parent() + path_append_parent(__file__) + These 2 produce the same result + + """ + pdir = os.path.dirname(absdir(path)) + if not pdir in sys.path: + sys.path.append(pdir) + return pdir + + +def make_subdir(dir, parent=None): + """Make (if required) a subdir to a given parent and return its path + + The parent parm may be dir or file or missing + If a file, the dir of the file us used + If missing, the test-module caller's dir is used + Then the subdir dir in the parent dir is created if not already present + + """ + if not parent: + parent = _caller_dir() + sdir = os.path.join(parent,dir) + if not os.path.exists(sdir): + os.mkdir(sdir) + return sdir + +def delete_tree(dir): + """Recursively delete directory and content + + WARNING: this is clearly dangerous + it will only operate on subdirs of the test module dir or of /tmp + + Test writers may explicitly use shutil.rmtree if really needed + """ + + if not os.path.isdir(dir): + raise TestError("%r is not a dir" % dir) + sdir = os.path.abspath(dir) + here = _caller_dir() + os.path.sep + tmp = tempfile.gettempdir() + os.path.sep + if not (sdir.startswith(here) or sdir.startswith(tmp)): + raise TestError("%r is not a subdir of here (%r) or %r" + % (dir, here, tmp)) + shutil.rmtree(sdir) + +#===eof===