瀏覽代碼

gunittest: move package from sandbox to trunk (leave static check script in sandbox)

git-svn-id: https://svn.osgeo.org/grass/grass/trunk@61225 15284696-431f-4ddb-bdfa-cd5b030d7da7
Vaclav Petras 10 年之前
父節點
當前提交
8f666a266e
共有 32 個文件被更改,包括 3611 次插入0 次删除
  1. 19 0
      lib/python/gunittest/__init__.py
  2. 596 0
      lib/python/gunittest/case.py
  3. 591 0
      lib/python/gunittest/checkers.py
  4. 132 0
      lib/python/gunittest/gmodules.py
  5. 18 0
      lib/python/gunittest/gutils.py
  6. 141 0
      lib/python/gunittest/invoker.py
  7. 152 0
      lib/python/gunittest/loader.py
  8. 143 0
      lib/python/gunittest/main.py
  9. 231 0
      lib/python/gunittest/reporters.py
  10. 211 0
      lib/python/gunittest/runner.py
  11. 298 0
      lib/python/gunittest/testing.rst
  12. 1 0
      lib/python/gunittest/testsuite/data/samplecode/fake_code.py
  13. 1 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/fake_code.py
  14. 1 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/fake_code.py
  15. 59 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/testsuite/test_error.py
  16. 24 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/testsuite/test_import_error.py
  17. 1 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/fake_code.py
  18. 18 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_gfatalerror.py
  19. 18 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_osexit_one.py
  20. 18 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_osexit_zero.py
  21. 25 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_segfaut.py
  22. 18 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_sysexit_one.py
  23. 18 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_sysexit_zero.py
  24. 1 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_test_fail/fake_code.py
  25. 16 0
      lib/python/gunittest/testsuite/data/samplecode/submodule_test_fail/testsuite/test_fail.py
  26. 36 0
      lib/python/gunittest/testsuite/data/samplecode/testsuite/test_success.py
  27. 280 0
      lib/python/gunittest/testsuite/test_assertions.py
  28. 314 0
      lib/python/gunittest/testsuite/test_checkers.py
  29. 37 0
      lib/python/gunittest/testsuite/test_doctests.py
  30. 92 0
      lib/python/gunittest/testsuite/test_gmodules.py
  31. 41 0
      lib/python/gunittest/testsuite/test_module_assertions.py
  32. 60 0
      lib/python/gunittest/utils.py

+ 19 - 0
lib/python/gunittest/__init__.py

@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest
+
+@brief GRASS Python testing framework module for running from command line
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+@author Soeren Gebbert
+
+Initial version of `gunittest` was created during Google Summer of Code 2014
+by Vaclav Petras as a student and Soeren Gebbert as a mentor.
+"""
+
+from .case import TestCase
+from .main import test

+ 596 - 0
lib/python/gunittest/case.py

@@ -0,0 +1,596 @@
+# -*- coding: utf-8 -*-
+
+"""!@package grass.gunittest.case
+
+@brief GRASS Python testing framework test case
+
+(C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+
+@author Vaclav Petras
+"""
+
+import os
+import subprocess
+import unittest
+from unittest.util import safe_repr
+
+from grass.pygrass.modules import Module
+from grass.exceptions import CalledModuleError
+
+from .gmodules import call_module
+from .checkers import (check_text_ellipsis,
+                       text_to_keyvalue, keyvalue_equals, diff_keyvalue,
+                       file_md5, files_equal_md5)
+
+
+class TestCase(unittest.TestCase):
+    # we dissable R0904 for all TestCase classes because their purpose is to
+    # provide a lot of assert methods
+    # pylint: disable=R0904
+    """
+
+    Always use keyword arguments for all parameters other than first two. For
+    the first two, it is recommended to use keyword arguments but not required.
+    """
+    longMessage = True  # to get both standard and custom message
+    maxDiff = None  # we can afford long diffs
+    _temp_region = None  # to control the temporary region
+
+    def __init__(self, methodName):
+        super(TestCase, self).__init__(methodName)
+
+    def _formatMessage(self, msg, standardMsg):
+        """Honor the longMessage attribute when generating failure messages.
+
+        If longMessage is False this means:
+
+        * Use only an explicit message if it is provided
+        * Otherwise use the standard message for the assert
+
+        If longMessage is True:
+
+        * Use the standard message
+        * If an explicit message is provided, return string with both messages
+
+        Based on Python unittest _formatMessage, formatting changed.
+        """
+        if not self.longMessage:
+            return msg or standardMsg
+        if msg is None:
+            return standardMsg
+        try:
+            # don't switch to '{}' formatting in Python 2.X
+            # it changes the way unicode input is handled
+            return '%s \n%s' % (msg, standardMsg)
+        except UnicodeDecodeError:
+            return '%s \n%s' % (safe_repr(msg), safe_repr(standardMsg))
+
+    @classmethod
+    def use_temp_region(cls):
+        """Use temporary region instead of the standard one for this process.
+
+        If you use this method, you have to call it in `setUpClass()`
+        and call `del_temp_region()` in `tearDownClass()`. By this you
+        ensure that each test method will have its own region and will
+        not influence other classes.
+
+        ::
+
+            @classmethod
+            def setUpClass(self):
+                self.use_temp_region()
+
+            @classmethod
+            def tearDownClass(self):
+                self.del_temp_region()
+
+        You can also call the methods in `setUp()` and `tearDown()` if
+        you are using them.
+
+        Copies the current region to a temporary region with
+        ``g.region save=``, then sets ``WIND_OVERRIDE`` to refer
+        to that region.
+        """
+        # we use just the class name since we rely on the invokation system
+        # where each test file is separate process and nothing runs
+        # in parallel inside
+        name = "tmp.%s" % (cls.__name__)
+        call_module("g.region", save=name, overwrite=True)
+        os.environ['WIND_OVERRIDE'] = name
+        cls._temp_region = name
+
+    @classmethod
+    def del_temp_region(cls):
+        """Remove the temporary region.
+
+        Unsets ``WIND_OVERRIDE`` and removes any region named by it.
+        """
+        assert cls._temp_region
+        name = os.environ.pop('WIND_OVERRIDE')
+        if name != cls._temp_region:
+            # be strict about usage of region
+            raise RuntimeError("Inconsistent use of"
+                               " TestCase.use_temp_region, WIND_OVERRIDE"
+                               " or temporary region in general\n"
+                               "Region to which should be now deleted ({n})"
+                               " by TestCase class"
+                               "does not corresond to currently set"
+                               " WIND_OVERRIDE ({c})",
+                               n=cls._temp_region, c=name)
+        call_module("g.remove", quiet=True, region=name)
+        # TODO: we don't know if user calls this
+        # so perhaps some decorator which would use with statemet
+        # but we have zero chance of infuencing another test class
+        # since we use class-specific name for temporary region
+
+    def assertLooksLike(self, actual, reference, msg=None):
+        """Test that ``actual`` text is the same as ``referece`` with ellipses.
+
+        See :func:`check_text_ellipsis` for details of behavior.
+        """
+        self.assertTrue(isinstance(actual, basestring), (
+                        'actual argument is not a string'))
+        self.assertTrue(isinstance(reference, basestring), (
+                        'reference argument is not a string'))
+        if not check_text_ellipsis(actual=actual, reference=reference):
+            # TODO: add support for multiline (first line general, others with details)
+            standardMsg = '"%s" does not correspond with "%s"' % (actual,
+                                                                  reference)
+            self.fail(self._formatMessage(msg, standardMsg))
+
+    # TODO: decide if precision is mandatory
+    # (note that we don't need precision for strings and usually for integers)
+    # TODO: auto-determine precision based on the map type
+    # TODO: we can have also more general function without the subset reference
+    # TODO: change name to Module
+    def assertCommandKeyValue(self, module, reference, sep,
+                              precision, msg=None, **parameters):
+        """Test that output of a module is the same as provided subset.
+
+        ::
+
+            self.assertCommandKeyValue('r.info', map='elevation', flags='gr',
+                                       reference=dict(min=55.58, max=156.33),
+                                       precision=0.01, sep='=')
+
+        ::
+
+            module = SimpleModule('r.info', map='elevation', flags='gr')
+            self.assertCommandKeyValue(module,
+                                       reference=dict(min=55.58, max=156.33),
+                                       precision=0.01, sep='=')
+
+        The output of the module should be key-value pairs (shell script style)
+        which is typically obtained using ``-g`` flag.
+        """
+        if isinstance(reference, basestring):
+            reference = text_to_keyvalue(reference, sep=sep, skip_empty=True)
+        module = _module_from_parameters(module, **parameters)
+        self.runModule(module)
+        raster_univar = text_to_keyvalue(module.outputs.stdout,
+                                         sep=sep, skip_empty=True)
+        if not keyvalue_equals(dict_a=reference, dict_b=raster_univar,
+                                a_is_subset=True, precision=precision):
+            unused, missing, mismatch = diff_keyvalue(dict_a=reference,
+                                                      dict_b=raster_univar,
+                                                      a_is_subset=True,
+                                                      precision=precision)
+            if missing:
+                raise ValueError("%s output does not contain"
+                                 " the following keys"
+                                 " provided in reference"
+                                 ": %s\n" % (module, ", ".join(missing)))
+            if mismatch:
+                stdMsg = "%s difference:\n" % module
+                stdMsg += "mismatch values"
+                stdMsg += "(key, reference, actual): %s\n" % mismatch
+                stdMsg += 'command: %s %s' % (module, parameters)
+            else:
+                # we can probably remove this once we have more tests
+                # of keyvalue_equals and diff_keyvalue against each other
+                raise RuntimeError("keyvalue_equals() showed difference but"
+                                   " diff_keyvalue() did not. This can be"
+                                   " a bug in one of them or in the caller"
+                                   " (assertCommandKeyValue())")
+            self.fail(self._formatMessage(msg, stdMsg))
+
+    def assertRasterFitsUnivar(self, raster, reference,
+                               precision=None, msg=None):
+        r"""Test that raster map has the values obtained by r.univar module.
+
+        The function does not require all values from r.univar.
+        Only the provided values are tested.
+        Typical example is checking minimum, maximum and number of NULL cells
+        in the map::
+
+            values = 'null_cells=0\nmin=55.5787925720215\nmax=156.329864501953'
+            self.assertRasterFitsUnivar(map='elevation', reference=values)
+
+        Use keyword arguments syntax for all function parameters.
+
+        Does not -e (extended statistics) flag, use `assertCommandKeyValue()`
+        for the full interface of arbitrary module.
+        """
+        self.assertCommandKeyValue(module='r.univar',
+                                   map=raster,
+                                   separator='=',
+                                   flags='g',
+                                   reference=reference, msg=msg, sep='=',
+                                   precision=precision)
+
+    def assertRasterFitsInfo(self, raster, reference,
+                             precision=None, msg=None):
+        r"""Test that raster map has the values obtained by v.univar module.
+
+        The function does not require all values from v.univar.
+        Only the provided values are tested.
+        Typical example is checking minimum, maximum and type of the map::
+
+            minmax = 'min=0\nmax=1451\ndatatype=FCELL'
+            self.assertRasterFitsInfo(map='elevation', reference=values)
+
+        Use keyword arguments syntax for all function parameters.
+
+        This function supports values obtained -r (range) and
+        -e (extended metadata) flags.
+        """
+        self.assertCommandKeyValue(module='r.info',
+                                   map=raster, flags='gre',
+                                   reference=reference, msg=msg, sep='=',
+                                   precision=precision)
+
+    def assertVectorFitsUnivar(self, map, column, reference, msg=None,
+                               layer=None, type=None, where=None,
+                               precision=None):
+        r"""Test that vector map has the values obtained by v.univar module.
+
+        The function does not require all values from v.univar.
+        Only the provided values are tested.
+        Typical example is checking minimum and maximum of a column::
+
+            minmax = 'min=0\nmax=1451'
+            self.assertVectorFitsUnivar(map='bridges', column='WIDTH',
+                                        reference=minmax)
+
+        Use keyword arguments syntax for all function parameters.
+
+        Does not support -d (geometry distances) flag, -e (extended statistics)
+        flag and few other, use `assertCommandKeyValue` for the full interface
+        of arbitrary module.
+        """
+        parameters = dict(map=map, column=column, flags='g')
+        if layer:
+            parameters.update(layer=layer)
+        if type:
+            parameters.update(type=type)
+        if where:
+            parameters.update(where=where)
+        self.assertCommandKeyValue(module='v.univar',
+                                   reference=reference, msg=msg, sep='=',
+                                   precision=precision,
+                                   **parameters)
+
+    # TODO: use precision?
+    # TODO: write a test for this method with r.in.ascii
+    def assertRasterMinMax(self, map, refmin, refmax, msg=None):
+        """Test that raster map minimum and maximum are within limits.
+
+        Map minimum and maximum is tested against expression::
+
+            refmin <= actualmin and refmax >= actualmax
+
+        Use keyword arguments syntax for all function parameters.
+
+        To check that more statistics have certain values use
+        `assertRasterFitsUnivar()` or `assertRasterFitsInfo()`
+        """
+        stdout = call_module('r.info', map=map, flags='r')
+        actual = text_to_keyvalue(stdout, sep='=')
+        if refmin > actual['min']:
+            stdmsg = ('The actual minimum ({a}) is smaller than the reference'
+                      ' one ({r}) for raster map {m}'
+                      ' (with maximum {o})'.format(
+                          a=actual['min'], r=refmin, m=map, o=actual['max']))
+            self.fail(self._formatMessage(msg, stdmsg))
+        if refmax < actual['max']:
+            stdmsg = ('The actual maximum ({a}) is greater than the reference'
+                      ' one ({r}) for raster map {m}'
+                      ' (with minimum {o})'.format(
+                          a=actual['max'], r=refmax, m=map, o=actual['min']))
+            self.fail(self._formatMessage(msg, stdmsg))
+
+    def assertFileExists(self, filename, msg=None,
+                         skip_size_check=False, skip_access_check=False):
+        """Test the existence of a file.
+
+        .. note:
+            By default this also checks if the file size is greater than 0
+            since we rarely want a file to be empty. And it also checks
+            if the file is access for reading.
+        """
+        if not os.path.isfile(filename):
+            stdmsg = 'File %s does not exist' % filename
+            self.fail(self._formatMessage(msg, stdmsg))
+        if not skip_size_check and not os.path.getsize(filename):
+            stdmsg = 'File %s is empty' % filename
+            self.fail(self._formatMessage(msg, stdmsg))
+        if not skip_access_check and not os.access(filename, os.R_OK):
+            stdmsg = 'File %s is not accessible for reading' % filename
+            self.fail(self._formatMessage(msg, stdmsg))
+
+    def assertFileMd5(self, filename, md5, msg=None):
+        """Test that file MD5 sum is equal to the provided sum.
+
+        The typical workflow is that you create a file in a way you
+        trust (that you obtain the right file). Then you compute MD5
+        sum of the file. And provide the sum in a test as a string::
+
+            self.assertFileMd5('result.txt', md5='807bba4ffa...')
+
+        Use `file_md5()` function from this package::
+
+            file_md5('original_result.txt')
+
+        Or in command line, use ``md5sum`` command if available:
+
+        .. code-block:: sh
+            md5sum some_file.txt
+
+        Finaly, you can use Python ``hashlib`` to obtain MD5::
+
+            import hashlib
+            hasher = hashlib.md5()
+            # expecting the file to fit into memory
+            hasher.update(open('original_result.txt', 'rb').read())
+            hasher.hexdigest()
+        """
+        self.assertFileExists(filename, msg=msg)
+        if not file_md5(filename) == md5:
+            standardMsg = 'File %s does not have the right MD5 sum' % filename
+            self.fail(self._formatMessage(msg, standardMsg))
+
+    def assertFilesEqualMd5(self, filename, reference, msg=None):
+        """Test that files are the same using MD5 sum.
+
+        This functions requires you to provide a file to test and
+        a reference file. For both, MD5 sum will be computed and compared with
+        each other.
+        """
+        self.assertFileExists(filename, msg=msg)
+        # nothing for ref, missing ref_filename is an error not a test failure
+        if not files_equal_md5(filename, reference):
+            stdmsg = 'Files %s and %s don\'t have the same MD5 sums' % (filename,
+                                                                        reference)
+            self.fail(self._formatMessage(msg, stdmsg))
+
+    def _compute_difference_raster(self, first, second, name_part):
+        """Compute difference of two rasters (first - second)
+
+        The name of the new raster is a long name designed to be as unique as
+        possible and contains names of two input rasters.
+
+        :param first: raster to subtract from
+        :param second: raster used as decrement
+        :param name_part: a unique string to be used in the difference name
+
+        :returns: name of a new raster
+        """
+        diff = ('tmp_' + self.id() + '_compute_difference_raster_'
+                + name_part + '_' + first + '_minus_' + second)
+        call_module('r.mapcalc',
+                    stdin='"{d}" = "{f}" - "{s}"'.format(d=diff,
+                                                         f=first,
+                                                         s=second))
+        return diff
+
+    def assertRastersNoDifference(self, actual, reference,
+                                   precision, statistics=None, msg=None):
+        """Test that `actual` raster is not different from `reference` raster
+
+        Method behaves in the same way as `assertRasterFitsUnivar()`
+        but works on difference ``reference - actual``.
+        If statistics is not given ``dict(min=-precision, max=precision)``
+        is used.
+        """
+        if statistics is None or sorted(statistics.keys()) == ['max', 'min']:
+            if statistics is None:
+                statistics = dict(min=-precision, max=precision)
+            diff = self._compute_difference_raster(reference, actual,
+                                                   'assertRastersNoDifference')
+            try:
+                self.assertCommandKeyValue('r.info', map=diff, flags='r',
+                                           sep='=', precision=precision,
+                                           reference=statistics, msg=msg)
+            finally:
+                call_module('g.remove', rast=diff)
+        # general case
+        self.assertRastersDifference(actual=actual, reference=reference,
+                                     statistics=statistics,
+                                     precision=precision, msg=msg)
+
+    def assertRastersDifference(self, actual, reference,
+                                statistics, precision, msg=None):
+        """Test statistical values of difference of reference and actual rasters
+
+        For cases when you are interested in no or minimal difference,
+        use `assertRastersNoDifference()` instead.
+
+        This method should not be used to test r.mapcalc or r.univar.
+        """
+        diff = self._compute_difference_raster(reference, actual,
+                                               'assertRastersDifference')
+        try:
+            self.assertRasterFitsUnivar(raster=diff, reference=statistics,
+                                        precision=precision, msg=msg)
+        finally:
+            call_module('g.remove', rast=diff)
+
+    @classmethod
+    def runModule(cls, module, **kwargs):
+        """Run PyGRASS module.
+
+        Runs the module and raises an exception if the module ends with
+        non-zero return code. Usually, this is the same as testing the
+        return code and raising exception but by using this method,
+        you give testing framework more control over the execution,
+        error handling and storing of output.
+
+        In terms of testing framework, this function causes a common error,
+        not a test failure.
+
+        :raises CalledModuleError: if the module failed
+        """
+        module = _module_from_parameters(module, **kwargs)
+
+        if module.run_:
+            raise ValueError('Do not run the module manually, set run_=False')
+        if not module.finish_:
+            raise ValueError('This function will always finish module run,'
+                             ' set finish_=None or finish_=True.')
+        # we expect most of the usages with stdout=PIPE
+        # TODO: in any case capture PIPE always?
+        if module.stdout_ is None:
+            module.stdout_ = subprocess.PIPE
+        elif module.stdout_ != subprocess.PIPE:
+            raise ValueError('stdout_ can be only PIPE or None')
+        if module.stderr_ is None:
+            module.stderr_ = subprocess.PIPE
+        elif module.stderr_ != subprocess.PIPE:
+            raise ValueError('stderr_ can be only PIPE or None')
+            # because we want to capture it
+        module.run()
+        if module.popen.returncode:
+            errors = module.outputs['stderr'].value
+            # provide diagnostic at least in English locale
+            # TODO: standardized error code would be handy here
+            import re
+            if re.search('Raster map.*not found', errors, flags=re.DOTALL):
+                errors += "\nSee available raster maps:\n"
+                errors += call_module('g.list', type='rast')
+            if re.search('Vector map.*not found', errors, flags=re.DOTALL):
+                errors += "\nSee available vector maps:\n"
+                errors += call_module('g.list', type='vect')
+            # TODO: message format, parameters
+            raise CalledModuleError(module.popen.returncode, module.name,
+                                    module.get_python(),
+                                    errors=errors)
+
+    # TODO: we can also comapre time to some expected but that's tricky
+    # maybe we should measure time but the real benchmarks with stdin/stdout
+    # should be done by some other function
+    # TODO: this should be the function used for valgrind or profiling or debug
+    # TODO: it asserts the rc but it does much more, so testModule?
+    # TODO: do we need special function for testing module failures or just add parameter returncode=0?
+    # TODO: consider not allowing to call this method more than once
+    # the original idea was to run this method just once for test method
+    # but for "integration" tests  (script-like tests with more than one module)
+    # it would be better to be able to use this multiple times
+    # TODO: enable merging streams?
+    def assertModule(self, module, msg=None, **kwargs):
+        """Run PyGRASS module in controlled way and assert non-zero return code.
+
+        You should use this method to invoke module you are testing.
+        By using this method, you give testing framework more control over
+        the execution, error handling and storing of output.
+
+        It will not print module stdout and stderr, instead it will always
+        store them for further examination. Streams are stored separately.
+
+        This method is not suitable for testing error states of the module.
+        If you want to test behavior which involves non-zero return codes
+        and examine stderr in test, use `assertModuleFail()` method.
+
+        Runs the module and causes test failure if module ends with
+        non-zero return code.
+        """
+        module = _module_from_parameters(module, **kwargs)
+
+        # TODO: merge stderr to stdout? if caller gives PIPE, for sure not
+        if module.run_:
+            raise ValueError('Do not run the module manually, set run_=False')
+        if not module.finish_:
+            raise ValueError('This function will always finish module run,'
+                             ' set finish_=None or finish_=True.')
+        if module.stdout_ is None:
+            module.stdout_ = subprocess.PIPE
+        elif module.stdout_ != subprocess.PIPE:
+            raise ValueError('stdout_ can be only PIPE or None')
+            # because we want to capture it
+        if module.stderr_ is None:
+            module.stderr_ = subprocess.PIPE
+        elif module.stderr_ != subprocess.PIPE:
+            raise ValueError('stderr_ can be only PIPE or None')
+            # because we want to capture it
+
+        module.run()
+        print module.outputs['stdout'].value
+        print module.outputs['stderr'].value
+        if module.popen.returncode:
+            # TODO: message format
+            # TODO: stderr?
+            stdmsg = ('Running <{m.name}> module ended'
+                      ' with non-zero return code ({m.popen.returncode})\n'
+                      'Called: {code}\n'
+                      'See the folowing errors:\n'
+                      '{errors}'.format(
+                          m=module, code=module.get_python(),
+                          errors=module.outputs["stderr"].value
+                      ))
+            self.fail(self._formatMessage(msg, stdmsg))
+
+        # log these to final report
+        # TODO: always or only if the calling test method failed?
+        # in any case, this must be done before self.fail()
+        # module.outputs['stdout'].value
+        # module.outputs['stderr'].value
+
+    # TODO: should we merge stderr to stdout in this case?
+    def assertModuleFail(self, module, msg=None, **kwargs):
+        """Test that module fails with a non-zero return code.
+
+        Works like `assertModule()` but expects module to fail.
+        """
+        module = _module_from_parameters(module, **kwargs)
+
+        if module.run_:
+            raise ValueError('Do not run the module manually, set run_=False')
+        if not module.finish_:
+            raise ValueError('This function will always finish module run,'
+                             ' set finish_=None or finish_=True.')
+        if module.stdout_ is None:
+            module.stdout_ = subprocess.PIPE
+        elif module.stdout_ != subprocess.PIPE:
+            raise ValueError('stdout_ can be only PIPE or None')
+            # because we want to capture it
+        if module.stderr_ is None:
+            module.stderr_ = subprocess.PIPE
+        elif module.stderr_ != subprocess.PIPE:
+            raise ValueError('stderr_ can be only PIPE or None')
+            # because we want to capture it
+
+        module.run()
+        print module.outputs['stdout'].value
+        print module.outputs['stderr'].value
+        if not module.popen.returncode:
+            stdmsg = ('Running <%s> ended with zero (successful) return code'
+                      ' when expecting module to fail' % module.get_python())
+            self.fail(self._formatMessage(msg, stdmsg))
+
+
+# TODO: add tests and documentation to methods which are using this function
+# some test and documentation add to assertCommandKeyValue
+def _module_from_parameters(module, **kwargs):
+    if kwargs:
+        if not isinstance(module, basestring):
+            raise ValueError('module can be only string or PyGRASS Module')
+        if isinstance(module, Module):
+            raise ValueError('module can be only string if other'
+                             ' parameters are given')
+            # allow to pass all parameters in one dictionary called parameters
+        if kwargs.keys() == ['parameters']:
+            kwargs = kwargs['parameters']
+        module = Module(module, run_=False, **kwargs)
+    return module

+ 591 - 0
lib/python/gunittest/checkers.py

@@ -0,0 +1,591 @@
+# -*- coding: utf-8 -*-
+
+"""!@package grass.gunittest.checkers
+
+@brief GRASS Python testing framework checkers
+
+(C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+
+@author Vaclav Petras
+@author Soeren Gebbert
+"""
+
+import sys
+import re
+import doctest
+import grass.script.core as gcore
+
+# alternative term to check(er(s)) would be compare
+
+
+def unify_projection(dic):
+    """Unifies names of projections.
+
+    Some projections are referred using different names like
+    'Universal Transverse Mercator' and 'Universe Transverse Mercator'.
+    This function replaces synonyms by a unified name.
+
+    Example of common typo in UTM replaced by correct spelling::
+
+        >>> unify_projection({'name': ['Universe Transverse Mercator']})
+        {'name': ['Universal Transverse Mercator']}
+
+    :param dic: The dictionary containing information about projection
+
+    :return: The dictionary with the new values if needed or a copy of old one
+    """
+    # the lookup variable is a list of list, each list contains all the
+    # possible name for a projection system
+    lookup = [['Universal Transverse Mercator',
+               'Universe Transverse Mercator']]
+    dic = dict(dic)
+    for l in lookup:
+        for n in range(len(dic['name'])):
+            if dic['name'][n] in l:
+                dic['name'][n] = l[0]
+    return dic
+
+
+def unify_units(dic):
+    """Unifies names of units.
+
+    Some units have different spelling although they are the same units.
+    This functions replaces different spelling options by unified one.
+
+    Example of British English spelling replaced by US English spelling::
+
+        >>> unify_units({'units': ['metres'], 'unit': ['metre']})
+        {'units': ['meters'], 'unit': ['meter']}
+
+    :param dic: The dictionary containing information about units
+
+    :return: The dictionary with the new values if needed or a copy of old one
+    """
+    # the lookup variable is a list of list, each list contains all the
+    # possible name for a units
+    lookup = [['meter', 'metre'], ['meters', 'metres'],
+              ['Meter', 'Metre'], ['Meters', 'Metres'],
+              ['kilometer', 'kilometre'], ['kilometers', 'kilometres'],
+              ['Kilometer', 'Kilometre'], ['Kilometers', 'Kilometres'],
+              ]
+    dic = dict(dic)
+    for l in lookup:
+        import types
+        if not isinstance(dic['unit'], types.StringTypes):
+            for n in range(len(dic['unit'])):
+                if dic['unit'][n] in l:
+                    dic['unit'][n] = l[0]
+        else:
+            if dic['unit'] in l:
+                dic['unit'] = l[0]
+        if not isinstance(dic['units'], types.StringTypes):
+            for n in range(len(dic['units'])):
+                if dic['units'][n] in l:
+                    dic['units'][n] = l[0]
+        else:
+            if dic['units'] in l:
+                dic['units'] = l[0]
+    return dic
+
+
+def value_from_string(value):
+    """Create value of a most fitting type from a string.
+
+    Type conversions are applied in order ``int``, ``float``, ``string``
+    where string is no conversion.
+
+    >>> value_from_string('1')
+    1
+    >>> value_from_string('5.6')
+    5.6
+    >>> value_from_string('  5.6\t  ')
+    5.6
+    >>> value_from_string('hello')
+    'hello'
+    """
+    not_float = False
+    not_int = False
+    # Convert values into correct types
+    # We first try integer then float because
+    # int('1.0') is ValueError (although int(1.0) is not)
+    # while float('1') is not
+    try:
+        value_converted = int(value)
+    except ValueError:
+        not_int = True
+    if not_int:
+        try:
+            value_converted = float(value)
+        except ValueError:
+            not_float = True
+    # strip strings from whitespace (expecting spaces and tabs)
+    if not_int and not_float:
+        value_converted = value.strip()
+    return value_converted
+
+
+# TODO: what is the default separator?
+def text_to_keyvalue(text, sep=":", val_sep=",", functions=None,
+                     skip_invalid=False, skip_empty=False,
+                     from_string=value_from_string):
+    """Convert test to key-value pairs (dictionary-like KeyValue object).
+
+    Converts a key-value text file, where entries are separated
+    by newlines and the key and value are separated by `sep`,
+    into a key-value dictionary and discovers/uses the correct
+    data types (float, int or string) for values.
+
+    Besides key-value pairs it also parses values itself. Value is created
+    with the best fitting type using `value_from_string()` function by default.
+    When val_sep is present in value part, the resulting value is
+    a list of values.
+
+    :param text: string to convert
+    :param sep: character that separates the keys and values
+    :param val_sep: character that separates the values of a single key
+    :param functions: list of functions to apply on the resulting dictionary
+    :param skip_invalid: skip all lines which does not contain separator
+    :param skip_empty: skip empty lines
+    :param from_string: a function used to convert strings to values,
+        use ``lambda x: x`` for no conversion
+
+    :return: a dictionary representation of text
+    :return type: grass.script.core.KeyValue
+
+    And example of converting text with text, floats, integers and list
+    to a dictionary::
+
+        >>> sorted(text_to_keyvalue('''a: Hello
+        ... b: 1.0
+        ... c: 1,2,3,4,5
+        ... d : hello,8,0.1''').items())  # sorted items from the dictionary
+        [('a', 'Hello'), ('b', 1.0), ('c', [1, 2, 3, 4, 5]), ('d', ['hello', 8, 0.1])]
+
+    .. warning::
+        And empty string is a valid input because empty dictionary is a valid
+        dictionary. You need to test this separately according
+        to the circumstances.
+    """
+    # splitting according to universal newlines approach
+    # TODO: add also general split with vsep
+    text = text.splitlines()
+    kvdict = gcore.KeyValue()
+    functions = [] if functions is None else functions
+
+    for line in text:
+        if line.find(sep) >= 0:
+            key, value = line.split(sep, 1)
+            key = key.strip()
+            value = value.strip()
+            # this strip may not be necessary, we strip each item in list
+            # and also if there is only one value
+        else:
+            # lines with no separator (empty or invalid)
+            if not line:
+                if not skip_empty:
+                    # TODO: here should go _ for translation
+                    # TODO: the error message is not really informative
+                    # in case of skipping lines we may get here with no key
+                    msg = ("Empty line in the parsed text.")
+                    if kvdict:
+                        # key is the one from previous line
+                        msg = ("Empty line in the parsed text."
+                               " Previous line's key is <%s>") % key
+                    raise ValueError(msg)
+            else:
+                # line contains something but not separator
+                if not skip_invalid:
+                    # TODO: here should go _ for translation
+                    raise ValueError(("Line <{l}> does not contain"
+                                      " separator <{s}>.").format(l=line, s=sep))
+            # if we get here we are silently ignoring the line
+            # because it is invalid (does not contain key-value separator) or
+            # because it is empty
+            continue
+        if value.find(val_sep) >= 0:
+            # lists
+            values = value.split(val_sep)
+            value_list = []
+            for value in values:
+                value_converted = from_string(value)
+                value_list.append(value_converted)
+            kvdict[key] = value_list
+        else:
+            # single values
+            kvdict[key] = from_string(value)
+    for function in functions:
+        kvdict = function(kvdict)
+    return kvdict
+
+
+# TODO: decide if there should be some default for precision
+# TODO: define standard precisions for DCELL, FCELL, CELL, mm, ft, cm, ...
+# TODO: decide if None is valid, and use some default or no compare
+# TODO: is None a valid value for precision?
+def values_equal(value_a, value_b, precision=0.000001):
+    """
+    >>> values_equal(1.022, 1.02, precision=0.01)
+    True
+    >>> values_equal([1.2, 5.3, 6.8], [1.1, 5.2, 6.9], precision=0.2)
+    True
+    >>> values_equal(7, 5, precision=2)
+    True
+    >>> values_equal(1, 5.9, precision=10)
+    True
+    >>> values_equal('Hello', 'hello')
+    False
+    """
+    # each if body needs to handle only not equal state
+
+    if isinstance(value_a, float) and isinstance(value_b, float):
+        # both values are float
+        # this could be also changed to is None and raise TypeError
+        # in Python 2 None is smaller than anything
+        # in Python 3 None < 3 raises TypeError
+        precision = float(precision)
+        if abs(value_a - value_b) > precision:
+            return False
+
+    elif (isinstance(value_a, float) and isinstance(value_b, int)) or \
+            (isinstance(value_b, float) and isinstance(value_a, int)):
+        # on is float the other is int
+        # don't accept None
+        precision = float(precision)
+        # we will apply precision to int-float comparison
+        # rather than converting both to integer
+        # (as in the original function from grass.script.core)
+        if abs(value_a - value_b) > precision:
+            return False
+
+    elif isinstance(value_a, int) and isinstance(value_b, int) and \
+            precision and int(precision) > 0:
+        # both int but precision applies for them
+        if abs(value_a - value_b) > precision:
+            return False
+
+    elif isinstance(value_a, list) and isinstance(value_b, list):
+        if len(value_a) != len(value_b):
+            return False
+        for i in range(len(value_a)):
+            # apply this function for comparison of items in the list
+            if not values_equal(value_a[i], value_b[i], precision):
+                return False
+    else:
+        if value_a != value_b:
+            return False
+    return True
+
+
+def keyvalue_equals(dict_a, dict_b, precision,
+                    def_equal=values_equal, key_equal=None,
+                    a_is_subset=False):
+    """Compare two dictionaries.
+
+    .. note::
+        Always use keyword arguments for all parameters with defaults.
+        It is a good idea to use keyword arguments also for the first
+        two parameters.
+
+    An example of key-value texts comparison::
+
+        >>> keyvalue_equals(text_to_keyvalue('''a: Hello
+        ... b: 1.0
+        ... c: 1,2,3,4,5
+        ... d: hello,8,0.1'''),
+        ... text_to_keyvalue('''a: Hello
+        ... b: 1.1
+        ... c: 1,22,3,4,5
+        ... d: hello,8,0.1'''), precision=0.1)
+        False
+
+    :param dict_a: first dictionary
+    :param dict_b: second dictionary
+    :param precision: precision with which the floating point values
+        are compared (passed to equality functions)
+    :param callable def_equal: function used for comparison by default
+    :param dict key_equal: dictionary of functions used for comparison
+        of specific keys, `def_equal` is used for the rest,
+        keys in dictionary are keys in `dict_a` and `dict_b` dictionaries,
+        values are the fuctions used to comapare the given key
+    :param a_is_subset: `True` if `dict_a` is a subset of `dict_b`,
+        `False` otherwise
+
+    :return: `True` if identical, `False` if different
+
+    Use `diff_keyvalue()` to get information about differeces.
+    You can use this function to find out if there is a difference and then
+    use `diff_keyvalue()` to determine all the differences between
+    dictionaries.
+    """
+    key_equal = {} if key_equal is None else key_equal
+
+    if not a_is_subset and sorted(dict_a.keys()) != sorted(dict_b.keys()):
+        return False
+    b_keys = dict_b.keys() if a_is_subset else None
+
+    # iterate over subset or just any if not a_is_subset
+    # check for missing keys in superset
+    # compare matching keys
+    for key in dict_a.keys():
+        if a_is_subset and key not in b_keys:
+            return False
+        equal_fun = key_equal.get(key, def_equal)
+        if not equal_fun(dict_a[key], dict_b[key], precision):
+            return False
+    return True
+
+
+# TODO: should the return depend on the a_is_subset parameter?
+# this function must have the same interface and behavior as keyvalue_equals
+def diff_keyvalue(dict_a, dict_b, precision,
+                  def_equal=values_equal, key_equal=None,
+                  a_is_subset=False):
+    """Determine the difference of two dictionaries.
+
+    The function returns missing keys and different values for common keys::
+
+        >>> a = {'c': 2, 'b': 3, 'a': 4}
+        >>> b = {'c': 1, 'b': 3, 'd': 5}
+        >>> diff_keyvalue(a, b, precision=0)
+        (['d'], ['a'], [('c', 2, 1)])
+
+    You can provide only a subset of values in dict_a, in this case
+    first item in tuple is an emptu list::
+
+        >>> diff_keyvalue(a, b, a_is_subset=True, precision=0)
+        ([], ['a'], [('c', 2, 1)])
+
+    This function behaves the same as `keyvalue_equals()`.
+
+    :returns: A tuple of lists, fist is list of missing keys in dict_a,
+        second missing keys in dict_b and third is a list of mismatched
+        values as tuples (key, value_from_a, value_from_b)
+    :rtype: (list, list, list)
+
+    Comparing to the Python ``difflib`` package this function does not create
+    any difference output. It just returns the dictionaries.
+    Comparing to the Python ``unittest`` ``assertDictEqual()``,
+    this function does not issues error or exception, it just determines
+    what it the difference.
+    """
+    key_equal = {} if key_equal is None else key_equal
+
+    a_keys = dict_a.keys()
+    b_keys = dict_b.keys()
+
+    missing_in_a = []
+    missing_in_b = []
+    mismatched = []
+
+    if not a_is_subset:
+        for key in b_keys:
+            if key not in a_keys:
+                missing_in_a.append(key)
+
+    # iterate over a, so we know that it is in a
+    for key in a_keys:
+        # check if it is in b
+        if key not in b_keys:
+            missing_in_b.append(key)
+        else:
+            equal_fun = key_equal.get(key, def_equal)
+            if not equal_fun(dict_a[key], dict_b[key], precision):
+                mismatched.append((key, dict_a[key], dict_b[key]))
+
+    return sorted(missing_in_a), sorted(missing_in_b), sorted(mismatched)
+
+
+def proj_info_equals(text_a, text_b):
+    """Test if two PROJ_INFO texts are equal."""
+    def compare_sums(list_a, list_b, precision):
+        """Compare difference of sums of two list using precision"""
+        # derived from the code in grass.script.core
+        if abs(sum(list_a) - sum(list_b)) > precision:
+            return False
+    sep = ':'
+    val_sep = ','
+    key_equal = {'+towgs84': compare_sums}
+    dict_a = text_to_keyvalue(text_a, sep=sep, val_sep=val_sep,
+                              functions=[unify_projection])
+    dict_b = text_to_keyvalue(text_b, sep=sep, val_sep=val_sep,
+                              functions=[unify_projection])
+    return keyvalue_equals(dict_a, dict_b,
+                            precision=0.000001,
+                            def_equal=values_equal,
+                            key_equal=key_equal)
+
+
+def proj_units_equals(text_a, text_b):
+    """Test if two PROJ_UNITS texts are equal."""
+    def lowercase_equals(string_a, string_b, precision=None):
+        # we don't need a waring for unused precision
+        # pylint: disable=W0613
+        """Test equality of two strings ignoring their case using ``lower()``.
+
+        Precision is accepted as require by `keyvalue_equals()` but ignored.
+        """
+        return string_a.lower() == string_b.lower()
+    sep = ':'
+    val_sep = ','
+    key_equal = {'unit': lowercase_equals, 'units': lowercase_equals}
+    dict_a = text_to_keyvalue(text_a, sep=sep, val_sep=val_sep,
+                              functions=[unify_units])
+    dict_b = text_to_keyvalue(text_b, sep, val_sep,
+                              functions=[unify_units])
+    return keyvalue_equals(dict_a, dict_b,
+                            precision=0.000001,
+                            def_equal=values_equal,
+                            key_equal=key_equal)
+
+
+# TODO: support also float (with E, e, inf, nan, ...?) and int (###, ##.)
+# http://hg.python.org/cpython/file/943d3e289ab4/Lib/decimal.py#l6098
+# perhaps a separate function?
+# alternative names: looks like, correspond with/to
+# TODO: change checking over lines?
+# TODO: change parameter order?
+# TODO: the behavior with last \n is strange but now using DOTALL and $
+def check_text_ellipsis(reference, actual):
+    r"""
+    >>> check_text_ellipsis("Vector map <...> contains ... points.",
+    ...                     "Vector map <bridges> contains 5268 points.")
+    True
+    >>> check_text_ellipsis("user: ...\\nname: elevation",
+    ...                     "user: some_user\\nname: elevation")
+    True
+    >>> check_text_ellipsis("user: ...\\nname: elevation",
+    ...                     "user: \\nname: elevation")
+    False
+
+    The ellipsis is always considered even if it is followed by another
+    dots. Consequently, a dot at the end of the sentence with preceding
+    ellipsis will work as well as a line filled with undefined number of dots.
+
+    >>> check_text_ellipsis("The result is ....",
+    ...                     "The result is 25.")
+    True
+    >>> check_text_ellipsis("max ..... ...",
+    ...                     "max ....... 6")
+    True
+
+    However, there is no way how to express that the dot should be in the
+    beginning and the ellipsis is at the end of the group of dots.
+
+    >>> check_text_ellipsis("The result is ....",
+    ...                     "The result is .25")
+    False
+
+    The matching goes over lines (TODO: should this be changed?):
+    >>> check_text_ellipsis("a=11\nb=...", "a=11\nb=22\n")
+    True
+
+    This function is based on regular expression containing .+ but no other
+    regular expression matching will be done.
+
+    >>> check_text_ellipsis("Result: [569] (...)",
+    ...                     "Result: 9 (too high)")
+    False
+    """
+    ref_escaped = re.escape(reference)
+    exp = re.compile(r'\\\.\\\.\\\.')  # matching escaped ...
+    ref_regexp = exp.sub('.+', ref_escaped) + "$"
+    if re.match(ref_regexp, actual, re.DOTALL):
+        return True
+    else:
+        return False
+
+
+def check_text_ellipsis_doctest(reference, actual):
+    """
+    >>> check_text_ellipsis_doctest("user: ...\\nname: elevation",
+    ...                     "user: some_user\\nname: elevation")
+    True
+    >>> check_text_ellipsis_doctest("user: ...\\nname: elevation",
+    ...                     "user: \\nname: elevation")
+    True
+
+    This function is using doctest's function to check the result, so we
+    will discuss here how the underlying function behaves.
+
+    >>> checker = doctest.OutputChecker()
+    >>> checker.check_output("user: some_user\\nname: elevation",
+    ...                      "user: some_user\\nname: elevation",
+    ...                      optionflags=None)
+    True
+    >>> checker.check_output("user: user1\\nname: elevation",
+    ...                      "user: some_user\\nname: elevation",
+    ...                      optionflags=doctest.ELLIPSIS)
+    False
+    >>> checker.check_output("user: ...\\nname: elevation",
+    ...                      "user: some_user\\nname: elevation",
+    ...                      optionflags=doctest.ELLIPSIS)
+    True
+
+    The ellipsis matches also an empty string, so the following matches:
+
+    >>> checker.check_output("user: ...\\nname: elevation",
+    ...                      "user: \\nname: elevation",
+    ...                      optionflags=doctest.ELLIPSIS)
+    True
+
+    It is robust concerning misspelled matching string but does not allow
+    ellipsis followed by a dot, e.g. at the end of the sentence:
+
+    >>> checker.check_output("user: ....\\nname: elevation",
+    ...                      "user: some_user\\nname: elevation",
+    ...                      optionflags=doctest.ELLIPSIS)
+    False
+    """
+    # this can be also global
+    checker = doctest.OutputChecker()
+    return checker.check_output(reference, actual,
+                                optionflags=doctest.ELLIPSIS)
+
+
+import hashlib
+
+# optimal size depends on file system and maybe on hasher.block_size
+_BUFFER_SIZE = 2**16
+
+
+# TODO: accept also open file object
+def file_md5(filename):
+    """Get MD5 (check) sum of a file."""
+    hasher = hashlib.md5()
+    with open(filename, 'rb') as f:
+        buf = f.read(_BUFFER_SIZE)
+        while len(buf) > 0:
+            hasher.update(buf)
+            buf = f.read(_BUFFER_SIZE)
+    return hasher.hexdigest()
+
+
+def text_file_md5(filename, exclude_lines=None,
+                  prepend_lines=None, append_lines=None):
+    """Get a MD5 (check) sum of a text file.
+
+    Works in the same way as `file_md5()` function but allows to
+    exclude lines from the file as well as prepend or append them.
+
+    .. todo::
+        Implement this function.
+    """
+    raise NotImplementedError("Implement, or use file_md5() function instead")
+
+
+def files_equal_md5(filename_a, filename_b):
+    """Check equality of two files according to their MD5 sums"""
+    return file_md5(filename_a) == file_md5(filename_b)
+
+
+def main():  # pragma: no cover
+    """Run the doctest"""
+    ret = doctest.testmod()
+    return ret.failed
+
+
+if __name__ == '__main__':  # pragma: no cover
+    sys.exit(main())

+ 132 - 0
lib/python/gunittest/gmodules.py

@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.gmodules
+
+@brief Specialized interfaces for invoking modules for testing framework
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+@author Soeren Gebbert
+"""
+import subprocess
+from grass.script.core import start_command
+from grass.exceptions import CalledModuleError
+from grass.pygrass.modules import Module
+
+from .utils import do_doctest_gettext_workaround
+
+
+class SimpleModule(Module):
+    """Simple wrapper around pygrass.modules.Module to make sure that
+       run_, finish_, stdout and stderr are set correctly.
+
+    >>> mapcalc = SimpleModule('r.mapcalc', expression='test_a = 1',
+    ...                        overwrite=True)
+    >>> mapcalc.run()
+    Module('r.mapcalc')
+    >>> mapcalc.popen.returncode
+    0
+
+    >>> colors = SimpleModule('r.colors',
+    ...                       map='test_a', rules='-', stdin_='1 red')
+    >>> colors.run()
+    Module('r.colors')
+    >>> colors.popen.returncode
+    0
+    >>> str(colors.inputs.stdin)
+    '1 red'
+    >>> str(colors.outputs.stdout)
+    ''
+    >>> colors.outputs.stderr.strip()
+    "Color table for raster map <test_a> set to 'rules'"
+    """
+    def __init__(self, cmd, *args, **kargs):
+        for banned in ['stdout_', 'stderr_', 'finish_', 'run_']:
+            if banned in kargs:
+                raise ValueError('Do not set %s parameter'
+                                 ', it would be overriden' % banned)
+        kargs['stdout_'] = subprocess.PIPE
+        kargs['stderr_'] = subprocess.PIPE
+        kargs['finish_'] = True
+        kargs['run_'] = False
+
+        Module.__init__(self, cmd, *args, **kargs)
+
+
+def call_module(module, stdin=None,
+                merge_stderr=False, capture_stdout=True, capture_stderr=True,
+                **kwargs):
+    r"""Run module with parameters given in `kwargs` and return its output.
+
+    >>> print call_module('g.region', flags='pg')  # doctest: +ELLIPSIS
+    n=...
+    s=...
+    w=...
+    >>> call_module('m.proj', flags='i', input='-', stdin="50.0 41.5")
+    '8642890.65|6965155.61|0.00\n'
+    >>> call_module('g.region', aabbbccc='notexist')  # doctest: +IGNORE_EXCEPTION_DETAIL
+    Traceback (most recent call last):
+        ...
+    CalledModuleError: Module run g.region ... ended with error
+
+    If `stdin` is not set and `kwargs` contains ``input`` with value set
+    to ``-`` (dash), the function raises an error.
+
+    Note that ``input`` nor ``output`` parameters are used by this
+    function itself, these are usually module parameters which this
+    function just passes to it. However, when ``input`` is in parameters
+    the function checks if its values is correct considering value of
+    ``stdin`` parameter.
+
+    :param str module: module name
+    :param stdin: string to be used as module standard input (stdin) or `None`
+    :param merge_stderr: if the standard error output should be merged with stdout
+    :param kwargs: module parameters
+
+    :returns: module standard output (stdout) as string or None if apture_stdout is False
+
+    :raises CalledModuleError: if module return code is non-zero
+    :raises ValueError: if the parameters are not correct
+
+    .. note::
+        The data read is buffered in memory, so do not use this method
+        if the data size is large or unlimited.
+    """
+    # TODO: remove this:
+    do_doctest_gettext_workaround()
+    # implemenation inspired by subprocess.check_output() function
+    if stdin:
+        if 'input' in kwargs and kwargs['input'] != '-':
+            raise ValueError(_("input='-' must be used when stdin is specified"))
+        if stdin == subprocess.PIPE:
+            raise ValueError(_("stdin must be string or buffer, not PIPE"))
+        kwargs['stdin'] = subprocess.PIPE  # to be able to send data to stdin
+    elif 'input' in kwargs and kwargs['input'] == '-':
+        raise ValueError(_("stdin must be used when input='-'"))
+    if merge_stderr and not (capture_stdout and capture_stderr):
+        raise ValueError(_("You cannot merge stdout and stderr and not capture them"))
+    if 'stdout' in kwargs:
+        raise TypeError(_("stdout argument not allowed, it could be overridden"))
+    if 'stderr' in kwargs:
+        raise TypeError(_("stderr argument not allowed, it could be overridden"))
+
+    if capture_stdout:
+        kwargs['stdout'] = subprocess.PIPE
+    if capture_stderr:
+        if merge_stderr:
+            kwargs['stderr'] = subprocess.STDOUT
+        else:
+            kwargs['stderr'] = subprocess.PIPE
+    process = start_command(module, **kwargs)
+    # input=None means no stdin (our default)
+    # for no stdout, output is None which is out interface
+    # for stderr=STDOUT or no stderr, errors is None
+    # which is fine for CalledModuleError
+    output, errors = process.communicate(input=stdin)
+    returncode = process.poll()
+    if returncode:
+        raise CalledModuleError(returncode, module, kwargs, errors)
+    return output

+ 18 - 0
lib/python/gunittest/gutils.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.gutils
+
+@brief Utilities related to GRASS GIS for GRASS Python testing framework
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+from .gmodules import call_module
+
+
+def get_current_mapset():
+    """Get curret mapset name as a string"""
+    return call_module('g.mapset', flags='p').strip()

+ 141 - 0
lib/python/gunittest/invoker.py

@@ -0,0 +1,141 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.invoker
+
+@brief GRASS Python testing framework test files invoker (runner)
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+
+import os
+import sys
+import shutil
+import string
+import subprocess
+
+from unittest.main import TestProgram, USAGE_AS_MAIN
+TestProgram.USAGE = USAGE_AS_MAIN
+
+from .loader import GrassTestLoader, discover_modules
+from .reporters import GrassTestFilesReporter
+from .utils import silent_rmtree, ensure_dir
+
+import grass.script.setup as gsetup
+
+
+class GrassTestFilesInvoker(object):
+    """A class used to invoke test files and create the main report"""
+
+    # TODO: it is not clear what clean_outputs mean, if should be split
+    # std stream, random outputs, saved results, profiling
+    # not stdout and stderr if they contain test results
+    # we can also save only failed tests, or generate only if assert fails
+    def __init__(self, start_dir,
+                 clean_mapsets=True, clean_outputs=True, clean_before=True,
+                 testsuite_dir='testsuite'):
+        """
+
+        :param bool clean_mapsets: if the mapsets should be removed
+        :param bool clean_outputs: meaning is unclear: random tests outputs,
+            saved images from maps, profiling?
+        :param bool clean_before: if mapsets, outputs, and results
+            should be removed before the tests start
+            (advantageous when the previous run left everything behind)
+        """
+        self.start_dir = start_dir
+        self.clean_mapsets = clean_mapsets
+        self.clean_outputs = clean_outputs
+        self.clean_before = clean_before
+        self.testsuite_dir = testsuite_dir
+        # reporter is created for each call of run_in_location()
+        self.reporter = None
+
+    def _create_mapset(self, gisdbase, location, module):
+        """Create mapset according to informations in module.
+
+        :param loader.GrassTestPythonModule module:
+        """
+        # using path.sep but also / and \ for cases when it is confused
+        # (namely the case of Unix path on MS Windows)
+        # replace . to get rid of unclean path
+        # TODO: clean paths
+        # note that backslash cannot be at the end of raw string
+        dir_as_name = module.tested_dir.translate(string.maketrans(r'/\.', '___'))
+        mapset = dir_as_name + '_' + module.name
+        # TODO: use grass module to do this? but we are not in the right gisdbase
+        mapset_dir = os.path.join(gisdbase, location, mapset)
+        if self.clean_before:
+            silent_rmtree(mapset_dir)
+        os.mkdir(mapset_dir)
+        # TODO: default region in mapset will be what?
+        # copy WIND file from PERMANENT
+        # TODO: this should be a function in grass.script (used also in gis_set.py, PyGRASS also has its way with Mapset)
+        # TODO: are premisions an issue here?
+        shutil.copy(os.path.join(gisdbase, location, 'PERMANENT', 'WIND'),
+                    os.path.join(mapset_dir))
+        return mapset, mapset_dir
+
+    def _run_test_module(self, module, results_dir, gisdbase, location):
+        """Run one test file."""
+        cwd = os.path.join(results_dir, module.tested_dir, module.name)
+        data_dir = os.path.join(module.file_dir, 'data')
+        if os.path.exists(data_dir):
+            shutil.copytree(data_dir, os.path.join(cwd, 'data'),
+                            ignore=shutil.ignore_patterns('*.svn*'))
+        ensure_dir(os.path.abspath(cwd))
+        # TODO: put this to constructor and copy here again
+        env = os.environ.copy()
+        mapset, mapset_dir = self._create_mapset(gisdbase, location, module)
+        gisrc = gsetup.write_gisrc(gisdbase, location, mapset)
+        env['GISRC'] = gisrc
+
+        stdout_path = os.path.join(cwd, 'stdout.txt')
+        stderr_path = os.path.join(cwd, 'stderr.txt')
+        stdout = open(stdout_path, 'w')
+        stderr = open(stderr_path, 'w')
+
+        self.reporter.start_file_test(module)
+        # TODO: we might clean the directory here before test if non-empty
+        # TODO: use some grass function to run?
+        p = subprocess.Popen([sys.executable, module.abs_file_path],
+                             cwd=cwd, env=env,
+                             stdout=stdout, stderr=stderr)
+        returncode = p.wait()
+        stdout.close()
+        stderr.close()
+        self.reporter.end_file_test(module=module, cwd=cwd,
+                                    returncode=returncode,
+                                    stdout=stdout_path, stderr=stderr_path)
+        # TODO: add some try-except or with for better error handling
+        os.remove(gisrc)
+        # TODO: only if clean up
+        if self.clean_mapsets:
+            shutil.rmtree(mapset_dir)
+
+    def run_in_location(self, gisdbase, location, location_shortcut,
+                        results_dir):
+        """Run tests in a given location"""
+        if os.path.abspath(results_dir) == os.path.abspath(self.start_dir):
+            raise RuntimeError("Results root directory should not be the same"
+                               " as discovery start directory")
+        self.reporter = GrassTestFilesReporter(results_dir=results_dir)
+
+        # TODO: move constants out of loader class or even module
+        modules = discover_modules(start_dir=self.start_dir,
+                                   grass_location=location_shortcut,
+                                   file_pattern=GrassTestLoader.files_in_testsuite,
+                                   skip_dirs=GrassTestLoader.skip_dirs,
+                                   testsuite_dir=GrassTestLoader.testsuite_dir,
+                                   all_locations_value=GrassTestLoader.all_tests_value,
+                                   universal_location_value=GrassTestLoader.universal_tests_value,
+                                   import_modules=False)
+
+        for module in modules:
+            self._run_test_module(module=module, results_dir=results_dir,
+                                  gisdbase=gisdbase, location=location)
+
+        self.reporter.finish()

+ 152 - 0
lib/python/gunittest/loader.py

@@ -0,0 +1,152 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.loader
+
+@brief GRASS Python testing framework test loading functionality
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+
+import os
+import sys
+import fnmatch
+import unittest
+import collections
+import importlib
+
+
+# TODO: resolve test file versus test module
+GrassTestPythonModule = collections.namedtuple('GrassTestPythonModule',
+                                               ['name', 'module',
+                                                'tested_dir',
+                                                'file_dir',
+                                                'abs_file_path'])
+
+
+# TODO: implement loading without the import
+def discover_modules(start_dir, file_pattern, skip_dirs, testsuite_dir,
+                     grass_location,
+                     all_locations_value, universal_location_value,
+                     import_modules):
+    """Find all test files (modules) in a directory tree.
+
+    The function is designed specifically for GRASS testing framework
+    test layout. It expects some directories to have a "testsuite"
+    directory where test files (test modules) are present.
+    Additionally, it also handles loading of test files which specify
+    in which location they can run.
+
+    :param start_dir: directory to start the search
+    :param file_pattern: pattern of files in a test suite directory
+        (using Unix shell-style wildcards)
+    :param skip_dirs: directories not to recurse to (e.g. ``.svn``)
+    :param testsuite_dir: name of directory where the test files are found,
+        the function will not recurse to this directory
+    :param grass_location: string with an accepted location (shortcut)
+    :param all_locations_value: string used to say that all locations
+        should be loaded (grass_location can be set to this value)
+    :param universal_location_value: string marking a test as
+        location-independent (same as not providing any)
+    :param import_modules: True if files should be imported as modules,
+        False if the files should be just searched for the needed values
+
+    :returns: a list of GrassTestPythonModule objects
+
+    .. todo::
+        Implement import_modules.
+    """
+    modules = []
+    for root, dirs, files in os.walk(start_dir):
+        for dir_pattern in skip_dirs:
+            to_skip = fnmatch.filter(dirs, dir_pattern)
+            for skip in to_skip:
+                dirs.remove(skip)
+
+        if testsuite_dir in dirs:
+            dirs.remove(testsuite_dir)  # do not recurse to testsuite
+            full = os.path.join(root, testsuite_dir)
+            files = fnmatch.filter(os.listdir(full), file_pattern)
+            # get test/module name without .py
+            # extecting all files to end with .py
+            # this will not work for invoking bat files but it works fine
+            # as long as we handle only Python files (and using Python
+            # interpreter for invoking)
+            # we always ignore __init__.py
+            module_names = [f[:-3] for f in files if not f == '__init__.py']
+            # TODO: warning (in what way?) about no tests in testsuite
+            for name in module_names:
+                # TODO: rewrite to use import_module and search the file if not
+                # TODO: do it without importing
+                # TODO: check if there is some main
+                # otherwise we can have successful test just because
+                # everything was loaded into Python
+                abspath = os.path.abspath(full)
+                sys.path.insert(0, abspath)
+                try:
+                    m = importlib.import_module(name)
+                    add = False
+                    if grass_location == all_locations_value:
+                        add = True
+                    else:
+                        try:
+                            locations = m.LOCATIONS
+                        except AttributeError:
+                            add = True  # test is universal
+                        else:
+                            if universal_location_value in locations:
+                                add = True  # cases when it is explicit
+                            if grass_location in locations:
+                                add = True  # standard case with given location
+                    if add:
+                        modules.append(GrassTestPythonModule(name=name,
+                                                             module=m,
+                                                             tested_dir=root,
+                                                             file_dir=full,
+                                                             abs_file_path=os.path.join(abspath, name + '.py')))
+                    # in else with some verbose we could tell about skiped test
+                except ImportError as e:
+                    raise ImportError('Cannot import module named %s in %s (%s)' % (name, full, e.message))
+                    # alternative is to create TestClass which will raise
+                    # see unittest.loader
+    return modules
+
+
+# TODO: find out if this is useful for us in some way
+# we are now using only discover_modules directly
+class GrassTestLoader(unittest.TestLoader):
+    """Class handles GRASS-specific loading of test modules."""
+
+    skip_dirs = ['.svn', 'dist.*', 'bin.*', 'OBJ.*']
+    testsuite_dir = 'testsuite'
+    files_in_testsuite = '*.py'
+    all_tests_value = 'all'
+    universal_tests_value = 'universal'
+
+    def __init__(self, grass_location):
+        self.grass_location = grass_location
+
+    # TODO: what is the purpose of top_level_dir, can it be useful?
+    # probably yes, we need to know grass src or dist root
+    # TODO: not using pattern here
+    def discover(self, start_dir, pattern='test*.py', top_level_dir=None):
+        """Load test modules from in GRASS testing framework way."""
+        modules = discover_modules(start_dir=start_dir,
+                                   file_pattern=self.files_in_testsuite,
+                                   skip_dirs=self.skip_dirs,
+                                   testsuite_dir=self.testsuite_dir,
+                                   grass_location=self.grass_location,
+                                   all_locations_value=self.all_tests_value,
+                                   universal_location_value=self.universal_tests_value,
+                                   import_modules=True)
+        tests = []
+        for module in modules:
+            tests.append(self.loadTestsFromModule(module.module))
+        return self.suiteClass(tests)
+
+
+if __name__ == '__main__':
+    GrassTestLoader().discover()

+ 143 - 0
lib/python/gunittest/main.py

@@ -0,0 +1,143 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.main
+
+@brief GRASS Python testing framework module for running from command line
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+
+import os
+import sys
+
+from unittest.main import TestProgram, USAGE_AS_MAIN
+TestProgram.USAGE = USAGE_AS_MAIN
+
+from .loader import GrassTestLoader
+from .runner import GrassTestRunner
+from .invoker import GrassTestFilesInvoker
+from .utils import silent_rmtree
+
+import grass.script.core as gcore
+
+
+class GrassTestProgram(TestProgram):
+    """A class to be used by individual test files (wrapped in the function)"""
+
+    def __init__(self, exit_at_end, grass_location, clean_outputs=True,
+                 unittest_argv=None, module=None,
+                 verbosity=1,
+                 failfast=None, catchbreak=None):
+        """Prepares the tests in GRASS way and then runs the tests.
+
+        :param bool clean_outputs: if outputs in mapset and in ?
+        """
+        self.test = None
+        self.grass_location = grass_location
+        # it is unclear what the exact behavior is in unittest
+        # buffer stdout and stderr during tests
+        buffer_stdout_stderr = False
+
+        grass_loader = GrassTestLoader(grass_location=self.grass_location)
+        grass_runner = GrassTestRunner(verbosity=verbosity,
+                                       failfast=failfast,
+                                       buffer=buffer_stdout_stderr)
+
+        super(GrassTestProgram, self).__init__(module=module,
+                                               argv=unittest_argv,
+                                               testLoader=grass_loader,
+                                               testRunner=grass_runner,
+                                               exit=exit_at_end,
+                                               verbosity=verbosity,
+                                               failfast=failfast,
+                                               catchbreak=catchbreak,
+                                               buffer=buffer_stdout_stderr)
+
+
+def test():
+    """Run a test of a module.
+    """
+    # TODO: put the link to to the report only if available
+    # TODO: how to disable Python code coverage for module and C tests?
+    doing_coverage = False
+    try:
+        import coverage
+        doing_coverage = True
+        cov = coverage.coverage(omit="*testsuite*")
+        cov.start()
+    except ImportError:
+        pass
+        # TODO: add some message somewhere
+
+    # TODO: enable passing omit to exclude also gunittest or nothing
+    program = GrassTestProgram(module='__main__', exit_at_end=False, grass_location='all')
+
+    if doing_coverage:
+        cov.stop()
+        cov.html_report(directory='testcodecoverage')
+
+    # TODO: is sys.exit the right thing here
+    sys.exit(not program.result.wasSuccessful())
+
+
+# TODO: test or main? test looks more general
+# unittest has main() but doctest has testmod()
+main = test
+
+
+def discovery():
+    """Recursively find all tests in testsuite directories and run them
+
+    Everything is imported and runs in this process.
+
+    Runs using::
+        python main.py discovery [start_directory]
+    """
+    doing_coverage = False
+    try:
+        import coverage
+        doing_coverage = True
+        cov = coverage.coverage(omit="*testsuite*")
+        cov.start()
+    except ImportError:
+        pass
+        # TODO: add some message somewhere
+
+    program = GrassTestProgram(grass_location='nc', exit_at_end=False)
+
+    if doing_coverage:
+        cov.stop()
+        cov.html_report(directory='testcodecoverage')
+
+    sys.exit(not program.result.wasSuccessful())
+
+
+# TODO: create a full interface (using grass parser or argparse)
+if __name__ == '__main__':
+    if len(sys.argv) == 4:
+        gisdbase = sys.argv[1]
+        location = sys.argv[2]
+        location_shortcut = sys.argv[3]
+    elif len(sys.argv) == 3:
+        location = sys.argv[1]
+        location_shortcut = sys.argv[2]
+        gisdbase = gcore.gisenv()['GISDBASE']
+    else:
+        sys.stderr.write("Usage: %s [gisdbase] location location_shortcut\n" % sys.argv[0])
+        sys.exit(1)
+    assert gisdbase
+    if not os.path.exists(gisdbase):
+        sys.stderr.write("GISDBASE <%s> does not exist\n" % gisdbase)
+        sys.exit(1)
+    results_dir = 'testreport'
+    silent_rmtree(results_dir)  # TODO: too brute force?
+
+    invoker = GrassTestFilesInvoker(start_dir='.')
+    invoker.run_in_location(gisdbase=gisdbase,
+                            location=location,
+                            location_shortcut=location_shortcut,
+                            results_dir=results_dir)

+ 231 - 0
lib/python/gunittest/reporters.py

@@ -0,0 +1,231 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.reporters
+
+@brief GRASS Python testing framework module for report generation
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+
+
+import os
+import sys
+import datetime
+import xml.sax.saxutils as saxutils
+import xml.etree.ElementTree as et
+import subprocess
+
+from .utils import ensure_dir
+
+
+def get_source_url(path, revision, line=None):
+    """
+
+    :param path: directory or file path relative to remote repository root
+    :param revision: SVN revision (should be a number)
+    :param line: line in the file (should be None for directories)
+    """
+    tracurl = 'http://trac.osgeo.org/grass/browser/'
+    if line:
+        return '{tracurl}{path}?rev={revision}#L{line}'.format(**locals())
+    else:
+        return '{tracurl}{path}?rev={revision}'.format(**locals())
+
+
+def html_escape(text):
+    """Escape ``'&'``, ``'<'``, and ``'>'`` in a string of data."""
+    return saxutils.escape(text)
+
+
+def html_unescape(text):
+    """Unescape ``'&amp;'``, ``'&lt;'``, and ``'&gt;'`` in a string of data."""
+    return saxutils.unescape(text)
+
+
+def color_error_line(line):
+    if line.startswith('ERROR: '):
+        # TODO: use CSS class
+        # ignoring the issue with \n at the end, HTML don't mind
+        line = '<span style="color: red">' + line + "</span>"
+    if line.startswith('FAIL: '):
+        # TODO: use CSS class
+        # ignoring the issue with \n at the end, HTML don't mind
+        line = '<span style="color: red">' + line + "</span>"
+    if line.startswith('WARNING: '):
+        # TODO: use CSS class
+        # ignoring the issue with \n at the end, HTML don't mind
+        line = '<span style="color: blue">' + line + "</span>"
+    #if line.startswith('Traceback ('):
+    #    line = '<span style="color: red">' + line + "</span>"
+    return line
+
+
+def get_svn_revision():
+    """Get SVN revision number
+
+    :returns: SVN revision number as string or None if it is not possible to get
+    """
+    # TODO: here should be starting directory
+    # but now we are using current as starting
+    p = subprocess.Popen(['svnversion', '.'],
+                         stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+    stdout, stderr = p.communicate()
+    rc = p.poll()
+    if not rc:
+        stdout = stdout.strip()
+        if stdout.endswith('M'):
+            stdout = stdout[:-1]
+        if ':' in stdout:
+            # the first one is the one of source code
+            stdout = stdout.split(':')[0]
+        return stdout
+    else:
+        return None
+
+
+def get_svn_info():
+    """Get important information from ``svn info``
+
+    :returns: SVN info as dictionary or None
+        if it is not possible to obtain it
+    """
+    try:
+        # TODO: introduce directory, not only current
+        p = subprocess.Popen(['svn', 'info', '.', '--xml'],
+                             stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+        stdout, stderr = p.communicate()
+        rc = p.poll()
+        info = {}
+        if not rc:
+            root = et.fromstring(stdout)
+            # TODO: get also date if this make sense
+            # expecting only one <entry>
+            entry = root.find('entry')
+            info['revision'] = entry.get('revision')
+            info['url'] = entry.find('url').text
+            relurl = entry.find('relative-url').text
+            # relative path has ^ at the beginning
+            if relurl.startswith('^'):
+                relurl = relurl[1:]
+            info['relative-url'] = relurl
+            return info
+    # TODO: add this to svnversion function
+    except OSError as e:
+        import errno
+        # ignore No such file or directory
+        if e.errno != errno.ENOENT:
+            raise
+    return None
+
+
+class GrassTestFilesReporter(object):
+
+    def __init__(self, results_dir):
+        # TODO: no directory cleaning (self.clean_before)? now cleaned by caller
+        ensure_dir(os.path.abspath(results_dir))
+
+        # having all variables public although not really part of API
+        self.main_index = open(os.path.join(results_dir, 'index.html'), 'w')
+        # this might be moved to some report start method
+        self.main_start_time = datetime.datetime.now()
+        svn_info = get_svn_info()
+        if not svn_info:
+            svn_text = ('<span style="font-size: 60%">'
+                        'SVN revision cannot be be obtained'
+                        '</span>')
+        else:
+            url = get_source_url(path=svn_info['relative-url'],
+                                 revision=svn_info['revision'])
+            svn_text = ('SVN revision'
+                        ' <a href="{url}">'
+                        '{rev}</a>'
+                        ).format(url=url, rev=svn_info['revision'])
+        self.main_index.write('<html><body>'
+                              '<h1>Test results</h1>'
+                              '{time:%Y-%m-%d %H:%M:%S}'
+                              ' ({svn})'
+                              '<table>'
+                              '<thead><tr>'
+                              '<th>Tested directory</th>'
+                              '<th>Test file</th>'
+                              '<th>Status</th>'
+                              '</tr></thead><tbody>'.format(
+                                  time=self.main_start_time,
+                                  svn=svn_text))
+        self.file_start_time = None
+        self._start_file_test_called = False
+
+    def finish(self):
+        self.main_index.write('<tbody></table>'
+                              '</body></html>')
+        self.main_index.close()
+
+    def start_file_test(self, module):
+        self.file_start_time = datetime.datetime.now()
+        self._start_file_test_called = True
+        self.main_index.flush()  # to get previous ones to the report
+
+    def wrap_stdstream_to_html(self, infile, outfile, module, stream):
+        before = '<html><body><h1>%s</h1><pre>' % (module.name + ' ' + stream)
+        after = '</pre></body></html>'
+        html = open(outfile, 'w')
+        html.write(before)
+        with open(infile) as text:
+            for line in text:
+                html.write(color_error_line(html_escape(line)))
+        html.write(after)
+        html.close()
+
+    def returncode_to_html_text(self, returncode):
+        if returncode:
+            return '<span style="color: red">FAILED</span>'
+        else:
+            return '<span style="color: green">succeeded</span>'  # SUCCEEDED
+
+    def returncode_to_html_sentence(self, returncode):
+        if returncode:
+            return '<span style="color: red">&#x274c;</span> Test failed (return code %d)' % (returncode)
+        else:
+            return '<span style="color: green">&#x2713;</span> Test succeeded (return code %d)' % (returncode)
+
+    def end_file_test(self, module, cwd, returncode, stdout, stderr):
+        assert self._start_file_test_called
+        file_time = datetime.datetime.now() - self.file_start_time
+        self.main_index.write(
+            '<tr><td>{d}</td>'
+            '<td><a href="{d}/{m}/index.html">{m}</a></td><td>{sf}</td>'
+            '<tr>'.format(
+                d=module.tested_dir, m=module.name,
+                sf=self.returncode_to_html_text(returncode)))
+        self.wrap_stdstream_to_html(infile=stdout,
+                                    outfile=os.path.join(cwd, 'stdout.html'),
+                                    module=module, stream='stdout')
+        self.wrap_stdstream_to_html(infile=stderr,
+                                    outfile=os.path.join(cwd, 'stderr.html'),
+                                    module=module, stream='stderr')
+        file_index_path = os.path.join(cwd, 'index.html')
+        file_index = open(file_index_path, 'w')
+        file_index.write('<html><body>'
+                         '<h1>{m.name}</h1>'
+                         '<h2>{m.tested_dir} &ndash; {m.name}</h2>'
+                         '<p>{status}'
+                         '<p>Test duration: {dur}'
+                         '<ul>'
+                         '<li><a href="stdout.html">standard output (stdout)</a>'
+                         '<li><a href="stderr.html">standard error output (stderr)</a>'
+                         '<li><a href="testcodecoverage/index.html">code coverage</a>'
+                         '</ul>'
+                         '</body></html>'.format(
+                             dur=file_time, m=module,
+                             status=self.returncode_to_html_sentence(returncode)))
+        file_index.close()
+
+        if returncode:
+            sys.stderr.write('{d}/{m} failed (see {f})\n'.format(d=module.tested_dir,
+                                                                 m=module.name,
+                                                                 f=file_index_path))
+        self._start_file_test_called = False

+ 211 - 0
lib/python/gunittest/runner.py

@@ -0,0 +1,211 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.runner
+
+@brief Testing framework module for running tests in Python unittest fashion
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+
+File content taken from Python's  ``unittest.runner``, it will be used as
+a template. It is not expected that something will left.
+"""
+
+
+import sys
+import time
+
+from unittest import result
+from unittest.signals import registerResult
+
+__unittest = True
+
+
+class _WritelnDecorator(object):
+    """Used to decorate file-like objects with a handy 'writeln' method"""
+    def __init__(self,stream):
+        self.stream = stream
+
+    def __getattr__(self, attr):
+        if attr in ('stream', '__getstate__'):
+            raise AttributeError(attr)
+        return getattr(self.stream,attr)
+
+    def writeln(self, arg=None):
+        if arg:
+            self.write(arg)
+        self.write('\n') # text-mode streams translate to \r\n if needed
+
+
+class TextTestResult(result.TestResult):
+    """A test result class that can print formatted text results to a stream.
+
+    Used by TextTestRunner.
+    """
+    separator1 = '=' * 70
+    separator2 = '-' * 70
+
+    def __init__(self, stream, descriptions, verbosity):
+        super(TextTestResult, self).__init__(stream, descriptions, verbosity)
+        self.stream = stream
+        self.showAll = verbosity > 1
+        self.dots = verbosity == 1
+        self.descriptions = descriptions
+
+    def getDescription(self, test):
+        doc_first_line = test.shortDescription()
+        if self.descriptions and doc_first_line:
+            return '\n'.join((str(test), doc_first_line))
+        else:
+            return str(test)
+
+    def startTest(self, test):
+        super(TextTestResult, self).startTest(test)
+        if self.showAll:
+            self.stream.write(self.getDescription(test))
+            self.stream.write(" ... ")
+            self.stream.flush()
+
+    def addSuccess(self, test):
+        super(TextTestResult, self).addSuccess(test)
+        if self.showAll:
+            self.stream.writeln("ok")
+        elif self.dots:
+            self.stream.write('.')
+            self.stream.flush()
+
+    def addError(self, test, err):
+        super(TextTestResult, self).addError(test, err)
+        if self.showAll:
+            self.stream.writeln("ERROR")
+        elif self.dots:
+            self.stream.write('E')
+            self.stream.flush()
+
+    def addFailure(self, test, err):
+        super(TextTestResult, self).addFailure(test, err)
+        if self.showAll:
+            self.stream.writeln("FAIL")
+        elif self.dots:
+            self.stream.write('F')
+            self.stream.flush()
+
+    def addSkip(self, test, reason):
+        super(TextTestResult, self).addSkip(test, reason)
+        if self.showAll:
+            self.stream.writeln("skipped {0!r}".format(reason))
+        elif self.dots:
+            self.stream.write("s")
+            self.stream.flush()
+
+    def addExpectedFailure(self, test, err):
+        super(TextTestResult, self).addExpectedFailure(test, err)
+        if self.showAll:
+            self.stream.writeln("expected failure")
+        elif self.dots:
+            self.stream.write("x")
+            self.stream.flush()
+
+    def addUnexpectedSuccess(self, test):
+        super(TextTestResult, self).addUnexpectedSuccess(test)
+        if self.showAll:
+            self.stream.writeln("unexpected success")
+        elif self.dots:
+            self.stream.write("u")
+            self.stream.flush()
+
+    def printErrors(self):
+        if self.dots or self.showAll:
+            self.stream.writeln()
+        self.printErrorList('ERROR', self.errors)
+        self.printErrorList('FAIL', self.failures)
+
+    def printErrorList(self, flavour, errors):
+        for test, err in errors:
+            self.stream.writeln(self.separator1)
+            self.stream.writeln("%s: %s" % (flavour,self.getDescription(test)))
+            self.stream.writeln(self.separator2)
+            self.stream.writeln("%s" % err)
+
+
+class GrassTestRunner(object):
+    """A test runner class that displays results in textual form.
+
+    It prints out the names of tests as they are run, errors as they
+    occur, and a summary of the results at the end of the test run.
+    """
+    resultclass = TextTestResult
+
+    def __init__(self, stream=sys.stderr, descriptions=True, verbosity=1,
+                 failfast=False, buffer=False, resultclass=None):
+        self.stream = _WritelnDecorator(stream)
+        self.descriptions = descriptions
+        self.verbosity = verbosity
+        self.failfast = failfast
+        self.buffer = buffer
+        if resultclass is not None:
+            self.resultclass = resultclass
+
+    def _makeResult(self):
+        return self.resultclass(self.stream, self.descriptions, self.verbosity)
+
+    def run(self, test):
+        "Run the given test case or test suite."
+        result = self._makeResult()
+        registerResult(result)
+        result.failfast = self.failfast
+        result.buffer = self.buffer
+        startTime = time.time()
+        startTestRun = getattr(result, 'startTestRun', None)
+        if startTestRun is not None:
+            startTestRun()
+        try:
+            test(result)
+        finally:
+            stopTestRun = getattr(result, 'stopTestRun', None)
+            if stopTestRun is not None:
+                stopTestRun()
+        stopTime = time.time()
+        timeTaken = stopTime - startTime
+        result.printErrors()
+        if hasattr(result, 'separator2'):
+            self.stream.writeln(result.separator2)
+        run = result.testsRun
+        self.stream.writeln("Ran %d test%s in %.3fs" %
+                            (run, run != 1 and "s" or "", timeTaken))
+        self.stream.writeln()
+
+        expectedFails = unexpectedSuccesses = skipped = 0
+        try:
+            results = map(len, (result.expectedFailures,
+                                result.unexpectedSuccesses,
+                                result.skipped))
+        except AttributeError:
+            pass
+        else:
+            expectedFails, unexpectedSuccesses, skipped = results
+
+        infos = []
+        if not result.wasSuccessful():
+            self.stream.write("FAILED")
+            failed, errored = map(len, (result.failures, result.errors))
+            if failed:
+                infos.append("failures=%d" % failed)
+            if errored:
+                infos.append("errors=%d" % errored)
+        else:
+            self.stream.write("OK")
+        if skipped:
+            infos.append("skipped=%d" % skipped)
+        if expectedFails:
+            infos.append("expected failures=%d" % expectedFails)
+        if unexpectedSuccesses:
+            infos.append("unexpected successes=%d" % unexpectedSuccesses)
+        if infos:
+            self.stream.writeln(" (%s)" % (", ".join(infos),))
+        else:
+            self.stream.write("\n")
+        return result

+ 298 - 0
lib/python/gunittest/testing.rst

@@ -0,0 +1,298 @@
+Introduction
+=============
+
+For the testing we will be using system based on Python `unittest`_ package.
+The system is not finished yet.
+
+For now, the best way is to use just Python unittest package as is.
+Additionally, it is possible to use Python `doctest`_ package
+which is compatible with unittest at certain level.
+Both packages are part of the standard  Python distribution.
+
+The content of this document may become part of submitting files and
+the documentation of testing framework classes and scripts.
+
+
+Testing with gunittest
+======================
+
+The tests should be in files in a ``testsuite`` directory which is a subdirectory
+of the directory with tested files (module, package, library). Each testing file
+(test file) can have can have several testing classes (test cases).
+All test files names should have pattern ``test*.py``.
+
+GRASS GIS `gunittest` package and testing framework is similar to the standard
+Python ``unittest`` package, so the ways to build tests are very similar.
+
+    ::
+    import guinttest
+
+    class TestPython(gunittest.TestCase):
+
+        def test_counting(self):
+            """Test that Python can count to two"""
+            self.assertEqual(1 + 1, 2)
+
+    if __name__ == '__main__':
+        gunittest.test()
+
+Each test file should be able to run by itself and accept certain set of command
+line parameters. This is ensured using `gunittest.test()`.
+
+To run (invoke) all tests in the source tree run::
+
+    export PYTHONPATH=.../sandbox/wenzeslaus:$PYTHONPATH
+    python python -m gunittest.main [gisdbase] location test_data_category
+
+All test files in all ``testsuite`` directories will be executed and
+a report will be created in a newly created ``testreport`` directory.
+Open the file ``testreport/index.html`` to browse though the results.
+You need to be in GRASS session to run the tests.
+
+The test_data_category parameter serves to filter tests accoring to data
+they can run successfully with. It is ignored for tests which does not have
+this specified.
+
+Each running test file gets its own mapset and current working directory
+but all run are in one location.
+
+.. warning:
+    The current location is ignored but you should run not invoke tests
+    in the location which is precious to you for the case that something fails.
+
+To run individual tests file you should be in GRASS session in GRASS NC sample
+location in a mapset of arbitrary name (except for the predefined mapsets).
+
+Your tests can rely on maps which are present in the GRASS NC sample location.
+But if you can provide tests which are independent on location it is better.
+
+Read the documentation of Python ``unittest`` package for a list of assert
+methods which you can use to test your results. For test of more complex
+GRASS-specific results refer to `TestCase` class documentation.
+
+
+Tests of GRASS modules
+----------------------
+
+::
+
+    class TestRInfo(gunittest.TestCase):
+
+        def test_elevation(self):
+            rinfo = Module('r.info', map='elevation', flags='g',
+                           stdout_=subprocess.PIPE, run_=False)
+            self.assertModule(self.rinfo)
+            ...
+
+.. todo:
+    Add example of assertions of key-value results.
+
+.. todo:
+    Add example with module producing a map.
+
+::
+
+    class TestRInfoInputHandling(gunittest.TestCase):
+
+        def test_rinfo_wrong_map(self):
+            map_name = 'does_not_exist'
+            rinfo = Module('r.info', map=, flags='g',
+                           stdout_=subprocess.PIPE, run_=False)
+            self.assertModuleFail(rinfo)
+            self.assertTrue(rinfo.outputs.stderr)
+            self.assertIn(map_name, stderr)
+
+.. todo:
+    Create ``SimpleModule`` or ``TestModule`` class which will have the right
+    parameters for ``assertModule()`` and ``assertModuleFail()`` functions.
+
+
+Tests of C and C++ code
+-----------------------
+
+Tests of Python code
+--------------------
+
+
+Testing Python code with doctest
+--------------------------------
+
+In Python, the easiest thing to test are functions which performs some computations
+or string manipulations, i.e. they have sum numbers or strings on the input and
+some others on the output.
+
+At the beginning you can use doctest for this purpose. The syntax is as follows::
+
+    def sum_list(list_to_sum):
+        """Here is some documentation in docstring.
+
+        And here is the test::
+
+        >>> sum_list([2, 5, 3])
+        10
+        """
+
+In case of GRASS modules which are Python scripts, you can add something like
+this to your script::
+
+    if __name__ == "__main__":
+        if len(sys.argv) == 2 and sys.argv[1] == '--doctest':
+            import doctest
+            doctest.testmod()
+        else:
+           grass.parser()
+           main()
+
+No output means that everything was successful. Note that you cannot use all
+the ways of running doctest since doctest will fail don the module file due
+to the dot or dots in the file name. Moreover, it is sometimes required that
+the file is accessible through sys.path which is not true for case of GRASS modules.
+
+Do not use use doctest for tests of edge cases, for tests which require
+generate complex data first, etc. In these cases use `gunittest`.
+
+
+Data
+----
+
+Most of the tests requires some input data. However, it is good to write
+a test in the way that it is independent on the available data.
+In case of GRASS, we have we can have tests of functions where
+some numbers or strings are input and some numbers or string are output.
+These tests does not require any data to be provided since the numbers
+can be part of the test. Then we have another category of tests, typically
+tests of GRASS modules, which require some maps to be on the input
+and thus the output (and test) depends on the specific data.
+Again, it it best to have tests which does not require any special data
+or generally environment settings (e.g. geographic projection)
+but it is much easier to write good tests with a given set of data.
+So, an compromises must be made and tests of different types should be written.
+
+In the GRASS testing framework, each test file should be marked according to
+category it belongs to. Each category corresponds to GRASS location or locations
+where the test file can run successfully.
+
+Universal tests
+    First category is *universal*. The tests in this category use some some
+    hard coded constants, generated data, random data, or their own imported
+    data as in input to function and GRASS modules. All the tests, input data
+    and reference results should be projection independent. These tests will
+    runs always regardless of available locations.
+
+Standard names tests
+    Second category are tests using *standard names*. Tests rely on a
+    certain set of maps with particular names to be present in the location.
+    Moreover, the tests can rely also on the (semantic) meaning of the
+    names, i.e. raster map named elevation will always contain some kind of
+    digital elevation model of some area, so raster map elevation can be
+    used to compute aspect. In other words, these tests should be able to
+    (successfully) run in any location with a maps named in the same way as
+    in the standard testing location(s).
+
+Standard data tests
+    Third category of tests rely on *standard data*. These tests expect that the
+    GRASS location they run in not only contains the maps with particular names
+    as in the *standard names* but the tests rely also on the data being the
+    same as in the standard testing location(s). However, the (geographic)
+    projection or data storage can be different. This is expected to be the
+    most common case but it is much better if the tests is one of the previous
+    categories (*universal* or *standard names*). If it is possible the
+    functions or modules with tests in this category should have also tests
+    which will fit into one of the previous categories, even though these
+    additional tests will not be as precise as the other tests.
+
+Location specific tests
+    Finally, there are tests which requires certain concrete location. There
+    is (or will be) a set of standard testing locations each will have the same
+    data (maps) but the projections and data storage types will be different.
+    The suggested locations are: NC sample location in SPM projection,
+    NC in SPF, NC in LL, NC in XY, and perhaps NC in UTM, and NC in some
+    custom projection (in case of strange not-fitting projection, there is
+    a danger that the results of analyses can differer significantly).
+    Moreover, the set can be extened by GRASS locations which are using
+    different storage backends, e.g. PostGIS for vectors and PostgreSQL for
+    temporal database. Tests can specify one or preferably more of these
+    standard locations.
+
+Specialized location tests
+    Additionally, an specialized location with a collection of strange,
+    incorrect, or generally extreme data will be provided. In theory, more
+    than one location like this can be created if the data cannot be
+    together in one location or if the location itself is somehow special,
+    e.g. because of projection.
+
+Each category, or perhaps each location (will) have a set of external data
+available for import or other purposes. The standardization of this data
+is in question and thus this may be specific to each location or this
+can be a separate resource common to all tests using one of the standardized
+locations, or alternatively this data can be associated with the location
+with special data.
+
+.. note::
+    The more general category you choose for your tests the more testing data
+    can applied to your tests and the more different circumstances can be tried
+    with your tests.
+
+
+
+
+
+.. note::
+    gunittest is under development but, so some things can change, however
+    this should not stop you from writing tests since the actual differences
+    in your code will be only subtle.
+
+.. note::
+    gunittest is not part of GRASS GIS source code yet, it is available
+    separately. If you don't want to deal with some other code now,
+    just write tests based only on Python ``unittest``. This will limit
+    your possibilities of convenient testing but should not stop you
+    from writing tests, especially if you will write tests of Python functions,
+    and C functions exposed to Python through ctypes API. (Note that it might
+    be a good idea to write tests for library function you rely on in your
+    GRASS module).
+
+
+Analyzing quality of source code
+================================
+
+Besides testing, you can also use some tools to check the quality of your code
+according to various standards and occurrence of certain code patterns.
+
+For C/C++ code use third party solution `Coverity Scan`_ where GRASS GIS
+is registered as project number `1038`_. Also you can use `Cppcheck`_
+which will show a lot of errors which compilers do not check.
+In any case, set your compiler to high error and warning levels,
+check them and fix them in your code.
+
+For Python, we recommend pylint and then for style issues pep8 tool
+(and perhaps also pep257 tool). However, there is more tools available
+you can use them together with the recommend ones.
+
+To provide a way to evaluate the Python source code in the whole GRASS source
+tree there is a Python script ``grass_py_static_check.py`` which uses
+pylint and pep8 with GRASS-specific settings. Run the tool in GRASS session
+in the source code root directory. A HTML report will be created in
+``pylint_report`` directory.
+
+::
+
+    grass_py_static_check.py
+
+Additionally, if you are invoking your Python code manually using python command,
+e.g. when testing, use parameters::
+
+    python -Qwarn -tt -3 some_module.py
+
+This will warn you about usage of old division semantics for integers
+and about incompatibilities with Python 3 (if you are using Python 2)
+which 2to3 tool cannot fix. Finally, it will issue errors if are using tabs
+for indentation inconsistently (note that you should not use tabs for
+indentation at all).
+
+
+.. _unittest: https://docs.python.org/2/library/unittest.html
+.. _doctest: https://docs.python.org/2/library/doctest.html
+.. _Coverity Scan: https://scan.coverity.com/
+.. _1038: https://scan.coverity.com/projects/1038
+.. _Cppcheck: http://cppcheck.sourceforge.net/

+ 1 - 0
lib/python/gunittest/testsuite/data/samplecode/fake_code.py

@@ -0,0 +1 @@
+print "This is file (%s) should not run." % __file__

+ 1 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/fake_code.py

@@ -0,0 +1 @@
+print "This is file (%s) should not run." % __file__

+ 1 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/fake_code.py

@@ -0,0 +1 @@
+print "This is file (%s) should not run." % __file__

+ 59 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/testsuite/test_error.py

@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestError(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        raise RuntimeError('Error in test function')
+        self.assertTrue(True)
+
+
+class TestErrorSetUp(TestCase):
+    # pylint: disable=R0904
+
+    def setUp(self):
+        raise RuntimeError('Error in setUp')
+
+    def test_something(self):
+        self.assertTrue(True)
+
+
+class TestErrorTearDown(TestCase):
+    # pylint: disable=R0904
+
+    def tearDown(self):
+        raise RuntimeError('Error in tearDown')
+
+    def test_something(self):
+        self.assertTrue(True)
+
+
+class TestErrorClassSetUp(TestCase):
+    # pylint: disable=R0904
+
+    @classmethod
+    def setUpClass(cls):
+        raise RuntimeError('Error in setUpClass')
+
+    def test_something(self):
+        self.assertTrue(True)
+
+
+class TestErrorClassTearDown(TestCase):
+    # pylint: disable=R0904
+
+    @classmethod
+    def tearDownClass(cls):
+        raise RuntimeError('Error in tearDownClass')
+
+    def test_something(self):
+        self.assertTrue(True)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 24 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_errors/testsuite/test_import_error.py

@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+# comment to get rid of the wrong import
+# (if it is imported before all tests start)
+#import this_module_or_package_does_not_exists__testing_import_error
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestNeverCalled(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        self.assertFalse("This should not be called"
+                         " if we are testing failed import."
+                         " But it is good OK if one wrong import"
+                         " would prevent other tests from running"
+                         " due to the implementation of test invocation.")
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 1 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/fake_code.py

@@ -0,0 +1 @@
+print "This is file (%s) should not run." % __file__

+ 18 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_gfatalerror.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+import grass.lib.gis as libgis
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestGFatalError(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        libgis.G_fatal_error("Testing G_fatal_error() function call")
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 18 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_osexit_one.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+import os
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestOsExit(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        os._exit(1)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 18 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_osexit_zero.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+import os
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestOsExit(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        os._exit(0)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 25 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_segfaut.py

@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+
+import ctypes
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestSegfault(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        """Crash the Python interpreter"""
+        i = ctypes.c_char('a')
+        j = ctypes.pointer(i)
+        c = 0
+        while True:
+                j[c] = 'a'
+                c += 1
+        j
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 18 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_sysexit_one.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+import sys
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestSysExit(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        sys.exit(1)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 18 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_errors/subsubmodule_exiting/testsuite/test_sysexit_zero.py

@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+
+import sys
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestSysExit(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        sys.exit(0)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 1 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_test_fail/fake_code.py

@@ -0,0 +1 @@
+print "This is file (%s) should not run." % __file__

+ 16 - 0
lib/python/gunittest/testsuite/data/samplecode/submodule_test_fail/testsuite/test_fail.py

@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestFail(TestCase):
+    # pylint: disable=R0904
+
+    def test_something(self):
+        self.assertTrue(False)
+
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 36 - 0
lib/python/gunittest/testsuite/data/samplecode/testsuite/test_success.py

@@ -0,0 +1,36 @@
+# -*- coding: utf-8 -*-
+
+# TODO: change to GrassTestCase
+from unittest import TestCase
+
+
+class TestSuccessVerboseSetUp(TestCase):
+    # pylint: disable=R0904
+
+    def setUp(self):
+        print "print from setUp"
+
+    def tearDown(self):
+        print "print from tearDown"
+
+    def test_something(self):
+        self.assertTrue(True)
+
+
+class TestSuccessVerboseClassSetUp(TestCase):
+    # pylint: disable=R0904
+
+    @classmethod
+    def setUpClass(cls):
+        print "print from setUpClass"
+
+    @classmethod
+    def tearDownClass(cls):
+        print "print from tearDownClass"
+
+    def test_something(self):
+        self.assertTrue(True)
+
+if __name__ == '__main__':
+    import unittest
+    unittest.main()

+ 280 - 0
lib/python/gunittest/testsuite/test_assertions.py

@@ -0,0 +1,280 @@
+# -*- coding: utf-8 -*-
+
+"""
+Tests assertion methods.
+"""
+
+
+import os
+
+import grass.script.core as gcore
+from grass.pygrass.modules import Module
+
+import gunittest
+from gunittest.gmodules import SimpleModule
+
+
+class TestTextAssertions(gunittest.TestCase):
+    # pylint: disable=R0904
+    def test_assertLooksLike(self):
+        self.assertLooksLike("Generated map is <elevation>",
+                             "Generated map is <...>")
+        self.assertRaises(self.failureException,
+                          self.assertLooksLike,
+                          "Generated map is elevation.",
+                          "Generated map is <...>")
+        self.assertLooksLike("Projection string: '+proj=longlat +datum=WGS84'",
+                             "Projection string: ...")
+
+    def test_assertLooksLike_multiline(self):
+        self.assertLooksLike("a=123\nb=456\nc=789",
+                             "a=...\nb=...\nc=...")
+
+    def test_assertLooksLike_numbers(self):
+        self.assertLooksLike("abc = 125521",
+                             "abc = 125...")
+        self.assertLooksLike("abc = 689.156",
+                             "abc = 689...")
+        self.assertLooksLike("abc = 689.159589",
+                             "abc = 689.15...")
+        # this should fail accoring to the implementation
+        # first three dots are considered as ellipses
+        self.assertRaises(self.failureException,
+                          self.assertLooksLike,
+                          "abc = 689.159589",
+                          "abc = 689....")
+
+
+R_UNIVAR_ELEVATION_SUBSET = """n=2025000
+null_cells=0
+min=55.5787925720215
+max=156.329864501953
+"""
+
+RANDOM_KEYVALUES = """abc=2025000
+aaa=55.5787925720215
+bbb=156.329864501953
+"""
+
+R_INFO_ELEVATION_SUBSET = """rows=1350
+cols=1500
+cells=2025000
+datatype=FCELL
+"""
+
+# r.info -gre map=elevation
+ELEVATION_MAPSET_DICT = {'mapset': 'PERMANENT'}
+
+# r.univar map=elevation
+ELEVATION_MINMAX = """min=55.5787925720215
+max=156.329864501953
+"""
+
+# values rounded manually to maximal expected perecision
+ELEVATION_MINMAX_DICT = {'min': 55.58, 'max': 156.33}
+
+V_UNIVAR_BRIDGES_WIDTH_SUBSET = """n=10938
+nmissing=0
+nnull=0
+min=0
+max=1451
+range=1451
+sum=2.6299e+06
+mean=240.437
+"""
+
+
+class TestAssertCommandKeyValue(gunittest.TestCase):
+    """Test usage of `.assertCommandKeyValue` method."""
+    # pylint: disable=R0904
+
+    @classmethod
+    def setUpClass(cls):
+        cls.use_temp_region()
+        cls.runModule(Module('g.region', rast='elevation', run_=False))
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.del_temp_region()
+
+    def test_pygrass_module(self):
+        """Test syntax with Module as module"""
+        module = Module('r.info', map='elevation', flags='gr', run_=False)
+        self.assertCommandKeyValue(module,
+                                   reference=dict(min=55.58, max=156.33),
+                                   precision=0.01, sep='=')
+
+    def test_pygrass_simple_module(self):
+        """Test syntax with SimpleModule as module"""
+        module = SimpleModule('r.info', map='elevation', flags='gr')
+        self.assertCommandKeyValue(module,
+                                   reference=dict(min=55.58, max=156.33),
+                                   precision=0.01, sep='=')
+
+    def test_direct_parameters(self):
+        """Test syntax with module and its parameters as fnction parameters"""
+        self.assertCommandKeyValue('r.info', map='elevation', flags='gr',
+                                   reference=dict(min=55.58, max=156.33),
+                                   precision=0.01, sep='=')
+
+    def test_parameters_parameter(self):
+        """Test syntax with module parameters in one parameters dictionary"""
+        self.assertCommandKeyValue(module='r.info',
+                                   parameters=dict(map='elevation', flags='gr'),
+                                   reference=dict(min=55.58, max=156.33),
+                                   precision=0.01, sep='=')
+
+
+class TestRasterMapAssertations(gunittest.TestCase):
+    # pylint: disable=R0904
+
+    @classmethod
+    def setUpClass(cls):
+        cls.use_temp_region()
+        # TODO: here we should actually not call self.runModule but call_module
+        cls.runModule(Module('g.region', rast='elevation', run_=False))
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.del_temp_region()
+
+    def test_assertRasterFitsUnivar(self):
+        self.assertRasterFitsUnivar('elevation', R_UNIVAR_ELEVATION_SUBSET,
+                                    precision=0.01)
+        self.assertRaises(self.failureException,
+                          self.assertRasterFitsUnivar,
+                          'aspect', R_UNIVAR_ELEVATION_SUBSET, precision=0.01)
+        self.assertRaises(ValueError,
+                          self.assertRasterFitsUnivar,
+                          'elevation', RANDOM_KEYVALUES)
+
+    def test_assertRasterFitsInfo(self):
+        self.assertRasterFitsInfo('elevation', R_INFO_ELEVATION_SUBSET)
+        self.assertRaises(self.failureException,
+                          self.assertRasterFitsInfo,
+                          'elev_lid792_1m', R_INFO_ELEVATION_SUBSET)
+        self.assertRaises(ValueError,
+                          self.assertRasterFitsInfo,
+                          'elevation', RANDOM_KEYVALUES)
+
+    def test_common_values_info_univar(self):
+        self.assertRasterFitsUnivar('elevation',
+                                    ELEVATION_MINMAX, precision=0.01)
+        self.assertRasterFitsInfo('elevation',
+                                  ELEVATION_MINMAX, precision=0.01)
+
+    def test_dict_as_parameter(self):
+        # this also tests if we are using r.info -e flag
+        self.assertRasterFitsInfo('elevation', ELEVATION_MAPSET_DICT)
+
+    def test_assertRastersNoDifference(self):
+        """Test basic usage of assertRastersNoDifference"""
+        self.assertRastersNoDifference(actual='elevation',
+                                       reference='elevation',
+                                       precision=0,  # this might need to be increased
+                                       msg="The same maps should have no difference")
+        self.assertRaises(self.failureException,
+                          self.assertRastersNoDifference,
+                          actual='elevation',
+                          reference='aspect',
+                          precision=1,
+                          msg="Different maps should have difference")
+
+    def test_assertRastersNoDifference_mean(self):
+        """Test usage of assertRastersNoDifference with mean"""
+        self.assertRastersNoDifference(actual='elevation',
+                                       reference='elevation',
+                                       precision=0,  # this might need to be increased
+                                       statistics=dict(mean=0),
+                                       msg="The difference of same maps should have small mean")
+        self.assertRaises(self.failureException,
+                          self.assertRastersNoDifference,
+                          actual='elevation',
+                          reference='aspect',
+                          precision=1,
+                          statistics=dict(mean=0),
+                          msg="The difference of different maps should have huge mean")
+
+
+class TestVectorMapAssertations(gunittest.TestCase):
+    # pylint: disable=R0904
+    def test_assertVectorFitsUnivar(self):
+        self.assertVectorFitsUnivar(map='bridges', column='WIDTH',
+                                    reference=V_UNIVAR_BRIDGES_WIDTH_SUBSET,
+                                    precision=0.01)
+        self.assertRaises(self.failureException,
+                          self.assertVectorFitsUnivar,
+                          map='bridges', column='YEAR_BUILT',
+                          reference=V_UNIVAR_BRIDGES_WIDTH_SUBSET,
+                          precision=0.01)
+        self.assertRaises(ValueError,
+                          self.assertVectorFitsUnivar,
+                          map='bridges', column='WIDTH',
+                          reference=RANDOM_KEYVALUES)
+
+
+class TestFileAssertations(gunittest.TestCase):
+    # pylint: disable=R0904
+
+    @classmethod
+    def setUpClass(cls):
+        # we expect WIND to be always present
+        gisenv = gcore.gisenv()
+        cls.existing_file = os.path.join(gisenv['GISDBASE'],
+                                         gisenv['LOCATION_NAME'],
+                                         'PERMANENT', 'WIND')
+        cls.emtpy_file = cls.__name__ + '_this_is_an_empty_file'
+        open(cls.emtpy_file, 'w').close()
+        cls.file_with_md5 = cls.__name__ + '_this_is_a_file_with_known_md5'
+        file_content = 'Content of the file with known MD5.\n'
+        with open(cls.file_with_md5, 'w') as f:
+            f.write(file_content)
+        # MD5 sum created using:
+        # echo 'Content of the file with known MD5.' > some_file.txt
+        # md5sum some_file.txt
+        cls.file_md5 = '807bba4ffac4bb351bc3f27853009949'
+
+        cls.file_with_same_content = cls.__name__ + '_file_with_same_content'
+        with open(cls.file_with_same_content, 'w') as f:
+            f.write(file_content)
+
+        cls.file_with_different_content = cls.__name__ + '_file_with_different_content'
+        with open(cls.file_with_different_content, 'w') as f:
+            f.write(file_content + ' Something else here.')
+
+    @classmethod
+    def tearDownClass(cls):
+        os.remove(cls.emtpy_file)
+        os.remove(cls.file_with_md5)
+        os.remove(cls.file_with_same_content)
+        os.remove(cls.file_with_different_content)
+
+    def test_assertFileExists(self):
+        self.assertFileExists(filename=self.existing_file)
+        self.assertRaises(self.failureException,
+                          self.assertFileExists,
+                          filename='this_one_does_not_exists')
+
+    def test_assertFileExists_empty_file(self):
+        self.assertFileExists(filename=self.emtpy_file, skip_size_check=True)
+        self.assertRaises(self.failureException,
+                          self.assertFileExists,
+                          filename=self.emtpy_file)
+
+    def test_assertFileMd5(self):
+        self.assertFileMd5(filename=self.file_with_md5, md5=self.file_md5)
+        self.assertRaises(self.failureException,
+                          self.assertFileMd5,
+                          filename=self.file_with_md5, md5='wrongmd5')
+
+    def test_assertFilesEqualMd5(self):
+        self.assertFilesEqualMd5(filename=self.file_with_md5,
+                                 reference=self.file_with_same_content)
+        self.assertRaises(self.failureException,
+                          self.assertFilesEqualMd5,
+                          filename=self.file_with_md5,
+                          reference=self.file_with_different_content)
+
+
+if __name__ == '__main__':
+    gunittest.test()

+ 314 - 0
lib/python/gunittest/testsuite/test_checkers.py

@@ -0,0 +1,314 @@
+# -*- coding: utf-8 -*-
+
+"""
+Tests checkers functions
+
+@brief Test of GRASS Python testing framework checkers
+
+(C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS
+for details.
+
+@author Vaclav Petras
+"""
+
+
+from grass.script.core import parse_key_val
+
+import gunittest
+from gunittest.checkers import (values_equal, text_to_keyvalue,
+                                keyvalue_equals,
+                                proj_info_equals, proj_units_equals)
+
+
+class TestValuesEqual(gunittest.TestCase):
+
+    def test_floats(self):
+        self.assertTrue(values_equal(5.0, 5.0))
+        self.assertTrue(values_equal(5.1, 5.19, precision=0.1))
+        self.assertTrue(values_equal(5.00005, 5.000059, precision=0.00001))
+        self.assertFalse(values_equal(5.125, 5.280))
+        self.assertFalse(values_equal(5.00005, 5.00006, precision=0.00001))
+        self.assertFalse(values_equal(2.5, 15.5, precision=5))
+
+    def test_ints(self):
+        self.assertTrue(values_equal(5, 5, precision=0.01))
+        self.assertFalse(values_equal(5, 6, precision=0.01))
+        self.assertTrue(values_equal(5, 8, precision=3))
+        self.assertFalse(values_equal(3600, 3623, precision=20))
+        self.assertTrue(values_equal(5, 5))
+        self.assertFalse(values_equal(5, 6))
+
+    def test_floats_and_ints(self):
+        self.assertTrue(values_equal(5.1, 5, precision=0.2))
+        self.assertFalse(values_equal(5.1, 5, precision=0.01))
+
+    def test_strings(self):
+        self.assertTrue(values_equal('hello', 'hello'))
+        self.assertFalse(values_equal('Hello', 'hello'))
+
+    def test_lists(self):
+        self.assertTrue(values_equal([1, 2, 3], [1, 2, 3]))
+        self.assertTrue(values_equal([1.1, 2.0, 3.9],
+                                     [1.1, 1.95, 4.0],
+                                     precision=0.2))
+        self.assertFalse(values_equal([1, 2, 3, 4, 5],
+                                      [1, 22, 3, 4, 5],
+                                      precision=1))
+
+    def test_mixed_lists(self):
+        self.assertTrue(values_equal([1, 'abc', 8], [1, 'abc', 8.2],
+                                     precision=0.5))
+
+    def test_recursive_lists(self):
+        self.assertTrue(values_equal([1, 'abc', [5, 9.6, 9.0]],
+                                     [1, 'abc', [4.9, 9.2, 9.3]],
+                                     precision=0.5))
+
+KEYVAL_TEXT = '''s: Hello
+str: Hello world!
+f: 1.0
+l: 1,2,3,4,5
+mixed: hello,8,-25,world!,4-1,5:2,0.1,-9.6
+'''
+
+# file location/PERMANENT/PROJ_INFO
+PROJ_INFO_TEXT_1 = """name: Lambert Conformal Conic
+proj: lcc
+datum: nad83
+a: 6378137.0
+es: 0.006694380022900787
+lat_1: 36.16666666666666
+lat_2: 34.33333333333334
+lat_0: 33.75
+lon_0: -79
+x_0: 609601.22
+y_0: 0
+no_defs: defined
+"""
+
+# file location/PERMANENT/PROJ_UNITS
+PROJ_UNITS_TEXT_1 = """unit: Meter
+units: Meters
+meters: 1
+"""
+
+PROJ_INFO_TEXT_2 = """name: Lambert Conformal Conic
+proj: lcc
+datum: nad83
+a: 6378137.0000000002
+es: 0.006694380022900787
+lat_1:  36.166666667
+lat_2: 34.333333333
+lat_0:   33.75
+lon_0:   -79
+x_0: 609601.22
+y_0: 0
+no_defs: defined
+"""
+
+PROJ_UNITS_TEXT_2 = """unit: Metre
+units: Metres
+meters: 1
+"""
+# what about keys and lower/upper case letters
+
+
+class TestTextToKeyValue(gunittest.TestCase):
+    def test_conversion(self):
+        keyvals = text_to_keyvalue(KEYVAL_TEXT, sep=':', val_sep=',')
+        expected = {'s': 'Hello',
+                    'str': 'Hello world!',
+                    'f': 1.0,
+                    'l': [1, 2, 3, 4, 5],
+                    'mixed': ['hello', 8, -25, 'world!',
+                              '4-1', '5:2', 0.1, -9.6]}
+        self.assertDictEqual(expected, keyvals)
+
+    def test_single_values(self):
+        keyvals = text_to_keyvalue("a: 1.5", sep=':')
+        self.assertDictEqual({'a': 1.5}, keyvals)
+        keyvals = text_to_keyvalue("abc=1", sep='=')
+        self.assertDictEqual({'abc': 1}, keyvals)
+        keyvals = text_to_keyvalue("abc=hello", sep='=')
+        self.assertDictEqual({'abc': 'hello'}, keyvals)
+
+    def test_strip(self):
+        keyvals = text_to_keyvalue("a:   2.8  ", sep=':')
+        self.assertDictEqual({'a': 2.8}, keyvals)
+        keyvals = text_to_keyvalue("a:  2  ; 2.8 ; ab cd ",
+                                   sep=':', val_sep=';')
+        self.assertDictEqual({'a': [2, 2.8, 'ab cd']}, keyvals)
+        keyvals = text_to_keyvalue("a  :  2  ; 2.8", sep=':', val_sep=';')
+        self.assertDictEqual({'a': [2, 2.8]}, keyvals)
+        keyvals = text_to_keyvalue("a  : \t 2  ;\t2.8", sep=':', val_sep=';')
+        self.assertDictEqual({'a': [2, 2.8]}, keyvals)
+
+    def test_empty_list_item(self):
+        keyvals = text_to_keyvalue("a: 1, ,5,,", sep=':', val_sep=',')
+        self.assertDictEqual({'a': [1, '', 5, '', '']}, keyvals)
+
+    def test_empty_value(self):
+        keyvals = text_to_keyvalue("a: ", sep=':')
+        self.assertDictEqual({'a': ''}, keyvals)
+        keyvals = text_to_keyvalue("a:", sep=':')
+        self.assertDictEqual({'a': ''}, keyvals)
+
+    def test_wrong_lines(self):
+        # we consider no key-value separator as invalid line
+        # and we silently ignore these
+        keyvals = text_to_keyvalue("a", sep=':',
+                                   skip_invalid=True, skip_empty=False)
+        self.assertDictEqual({}, keyvals)
+
+        self.assertRaises(ValueError, text_to_keyvalue, "a", sep=':',
+                          skip_invalid=False, skip_empty=False)
+
+        # text_to_keyvalue considers the empty string as valid input
+        keyvals = text_to_keyvalue("", sep=':',
+                                   skip_invalid=False, skip_empty=False)
+        self.assertDictEqual({}, keyvals)
+
+        self.assertRaises(ValueError, text_to_keyvalue, "\n", sep=':',
+                          skip_invalid=True, skip_empty=False)
+
+        keyvals = text_to_keyvalue("a\n\n", sep=':',
+                                   skip_invalid=True, skip_empty=True)
+        self.assertDictEqual({}, keyvals)
+
+    def test_separators(self):
+        keyvals = text_to_keyvalue("a=a;b;c", sep='=', val_sep=';')
+        self.assertDictEqual({'a': ['a', 'b', 'c']}, keyvals)
+        keyvals = text_to_keyvalue("a 1;2;3", sep=' ', val_sep=';')
+        self.assertDictEqual({'a': [1, 2, 3]}, keyvals)
+        # spaces as key-value separator and values separators
+        # this should work (e.g. because of : in DMS),
+        # although it does not support stripping (we don't merge separators)
+        keyvals = text_to_keyvalue("a 1 2 3", sep=' ', val_sep=' ')
+        self.assertDictEqual({'a': [1, 2, 3]}, keyvals)
+        
+    #def test_projection_files(self):
+        
+# obtained by r.univar elevation -g
+# floats removed
+R_UNIVAR_KEYVAL = """n=2025000
+null_cells=57995100
+cells=60020100
+min=55.5787925720215
+max=156.329864501953
+range=100.751071929932
+mean=110.375440275606
+mean_of_abs=110.375440275606
+stddev=20.3153233205981
+variance=412.712361620436
+coeff_var=18.4056555243368
+sum=223510266.558102
+"""
+
+# obtained by r.univar elevation -g
+# floats removed
+R_UNIVAR_KEYVAL_INT = """n=2025000
+null_cells=57995100
+cells=60020100
+"""
+
+R_UNIVAR_KEYVAL_INT_DICT = {'n': 2025000,
+                            'null_cells': 57995100, 'cells': 60020100}
+
+
+class TestComapreProjections(gunittest.TestCase):
+
+    def test_compare_proj_info(self):
+        self.assertTrue(proj_info_equals(PROJ_INFO_TEXT_1, PROJ_INFO_TEXT_2))
+        self.assertTrue(proj_units_equals(PROJ_UNITS_TEXT_1, PROJ_UNITS_TEXT_2))
+
+
+class TestParseKeyvalue(gunittest.TestCase):
+
+    def test_shell_script_style(self):
+
+        self.assertDictEqual(parse_key_val(R_UNIVAR_KEYVAL_INT, val_type=int),
+                             R_UNIVAR_KEYVAL_INT_DICT)
+
+
+R_UNIVAR_ELEVATION = """n=2025000
+null_cells=57995100
+cells=60020100
+min=55.5787925720215
+max=156.329864501953
+range=100.751071929932
+mean=110.375440275606
+mean_of_abs=110.375440275606
+stddev=20.3153233205981
+variance=412.712361620436
+coeff_var=18.4056555243368
+sum=223510266.558102
+first_quartile=94.79
+median=108.88
+third_quartile=126.792
+percentile_90=138.66
+"""
+
+R_UNIVAR_ELEVATION_ROUNDED = """n=2025000
+null_cells=57995100
+cells=60020100
+min=55.5788
+max=156.33
+range=100.751
+mean=110.375
+mean_of_abs=110.375
+stddev=20.3153
+variance=412.712
+coeff_var=18.4057
+sum=223510266.558
+first_quartile=94.79
+median=108.88
+third_quartile=126.792
+percentile_90=138.66
+"""
+
+R_UNIVAR_ELEVATION_SUBSET = """n=2025000
+null_cells=57995100
+cells=60020100
+min=55.5787925720215
+max=156.329864501953
+"""
+
+
+class TestRasterMapComparisons(gunittest.TestCase):
+
+    def test_compare_univars(self):
+        self.assertTrue(keyvalue_equals(text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                          sep='='),
+                                         text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                          sep='='),
+                                         precision=0))
+        self.assertFalse(keyvalue_equals(text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                           sep='='),
+                                          text_to_keyvalue(R_UNIVAR_ELEVATION_SUBSET,
+                                                           sep='='),
+                                          precision=0))
+
+    def test_compare_univars_subset(self):
+        self.assertTrue(keyvalue_equals(text_to_keyvalue(R_UNIVAR_ELEVATION_SUBSET,
+                                                          sep='='),
+                                         text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                          sep='='),
+                                         a_is_subset=True, precision=0))
+        self.assertFalse(keyvalue_equals(text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                           sep='='),
+                                          text_to_keyvalue(R_UNIVAR_ELEVATION_SUBSET,
+                                                           sep='='),
+                                          a_is_subset=True, precision=0))
+
+    def test_compare_univars_rounded(self):
+        self.assertTrue(keyvalue_equals(text_to_keyvalue(R_UNIVAR_ELEVATION,
+                                                          sep='='),
+                                         text_to_keyvalue(R_UNIVAR_ELEVATION_ROUNDED,
+                                                          sep='='),
+                                         precision=0.001))
+
+
+if __name__ == '__main__':
+    gunittest.test()

+ 37 - 0
lib/python/gunittest/testsuite/test_doctests.py

@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+"""
+Tests checkers
+"""
+
+import doctest
+
+import gunittest
+import gunittest.utils
+
+
+# doctest does not allow changing the base classes of test case, skip test case
+# and test suite, so we need to create a new type which inherits from our class
+# and contains doctest's methods
+# the alternative is to copy 500 from doctest and change what is needed
+# (this might be necessary anyway beacuse of the reports and stdout and stderr)
+doctest.DocFileCase = type('DocFileCase',
+                           (gunittest.TestCase,),
+                           dict(doctest.DocFileCase.__dict__))
+doctest.SkipDocTestCase = type('SkipDocTestCase',
+                               (gunittest.TestCase,),
+                               dict(doctest.SkipDocTestCase.__dict__))
+
+
+def load_tests(loader, tests, ignore):
+    # TODO: this must be somewhere when doctest is called, not here
+    # TODO: ultimate solution is not to use _ as a buildin in lib/python
+    # for now it is the only place where it works
+    gunittest.utils.do_doctest_gettext_workaround()
+    # this should be called at some top level
+    tests.addTests(doctest.DocTestSuite(gunittest.gmodules))
+    tests.addTests(doctest.DocTestSuite(gunittest.checkers))
+    return tests
+
+
+if __name__ == '__main__':
+    gunittest.test()

+ 92 - 0
lib/python/gunittest/testsuite/test_gmodules.py

@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+import subprocess
+
+import gunittest
+from gunittest.gmodules import (call_module, CalledModuleError)
+
+G_REGION_OUTPUT = """n=...
+s=...
+w=...
+e=...
+nsres=...
+ewres=...
+rows=...
+cols=...
+cells=...
+"""
+
+
+class TestCallModuleFunction(gunittest.TestCase):
+
+    def test_output(self):
+        output = call_module('g.region', flags='pg')
+        self.assertLooksLike(output, G_REGION_OUTPUT)
+
+    def test_input_output(self):
+        output = call_module('m.proj', flags='i', input='-', stdin="50.0 41.5")
+        self.assertLooksLike(output, '...|...\n')
+
+    def test_no_output(self):
+        output = call_module('m.proj', flags='i', input='-', stdin="50.0 41.5",
+                             capture_stdout=False)
+        self.assertIsNone(output)
+
+    def test_merge_stderr(self):
+        output = call_module('m.proj', flags='i', input='-', stdin="50.0 41.5",
+                             verbose=True,
+                             merge_stderr=True)
+        self.assertLooksLike(output, '...+proj=longlat +datum=WGS84...')
+        self.assertLooksLike(output, '...|...\n')
+
+    def test_merge_stderr_with_wrong_stdin_stderr(self):
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='-', stdin="50.0 41.5",
+                          verbose=True,
+                          merge_stderr=True, capture_stdout=False)
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='-', stdin="50.0 41.5",
+                          verbose=True,
+                          merge_stderr=True, capture_stderr=False)
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='-', stdin="50.0 41.5",
+                          verbose=True,
+                          merge_stderr=True,
+                          capture_stdout=False, capture_stderr=False)
+
+    def test_wrong_module_params(self):
+        self.assertRaises(CalledModuleError,
+                          call_module,
+                          'g.region', aabbbccc='notexist')
+
+    def test_module_input_param_wrong(self):
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='does_not_exist',
+                          stdin="50.0 41.5")
+
+    def test_missing_stdin_with_input_param(self):
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='-')
+
+    def test_wrong_usage_of_popen_like_interface(self):
+        self.assertRaises(ValueError,
+                          call_module,
+                          'm.proj', flags='i', input='-',
+                          stdin=subprocess.PIPE)
+        self.assertRaises(TypeError,
+                          call_module,
+                          'm.proj', flags='i', input='-', stdin="50.0 41.5",
+                          stdout='any_value_or_type_here')
+        self.assertRaises(TypeError,
+                          call_module,
+                          'm.proj', flags='i', input='-', stdin="50.0 41.5",
+                          stderr='any_value_or_type_here')
+
+
+if __name__ == '__main__':
+    gunittest.test()

+ 41 - 0
lib/python/gunittest/testsuite/test_module_assertions.py

@@ -0,0 +1,41 @@
+# -*- coding: utf-8 -*-
+
+import copy
+import subprocess
+
+from grass.pygrass.modules import Module
+
+import gunittest
+from gunittest.gmodules import CalledModuleError
+
+
+class TestModuleAssertions(gunittest.TestCase):
+    # pylint: disable=R0904
+
+    def setUp(self):
+        self.rinfo = Module('r.info', map='elevation', flags='g',
+                       stdout_=subprocess.PIPE, run_=False)
+        self.rinfo_wrong = copy.deepcopy(self.rinfo)
+        self.wrong_map = 'does_not_exists'
+        self.rinfo_wrong.inputs['map'].value = self.wrong_map
+
+    def test_runModule(self):
+        self.runModule(self.rinfo)
+        self.assertTrue(self.rinfo.outputs['stdout'].value)
+        self.assertRaises(CalledModuleError, self.runModule, self.rinfo_wrong)
+
+    def test_assertModule(self):
+        self.assertModule(self.rinfo)
+        self.assertTrue(self.rinfo.outputs['stdout'].value)
+        self.assertRaises(self.failureException, self.assertModule, self.rinfo_wrong)
+
+    def test_assertModuleFail(self):
+        self.assertModuleFail(self.rinfo_wrong)
+        stderr = self.rinfo_wrong.outputs['stderr'].value
+        self.assertTrue(stderr)
+        self.assertIn(self.wrong_map, stderr)
+        self.assertRaises(self.failureException, self.assertModuleFail, self.rinfo)
+
+
+if __name__ == '__main__':
+    gunittest.test()

+ 60 - 0
lib/python/gunittest/utils.py

@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+"""!@package grass.gunittest.utils
+
+@brief GRASS Python testing framework utilities (general and test-specific)
+
+Copyright (C) 2014 by the GRASS Development Team
+This program is free software under the GNU General Public
+License (>=v2). Read the file COPYING that comes with GRASS GIS
+for details.
+
+@author Vaclav Petras
+"""
+
+import os
+import sys
+import shutil
+import errno
+
+
+def ensure_dir(directory):
+    """Create all directories in the given path if needed."""
+    if not os.path.exists(directory):
+        os.makedirs(directory)
+
+
+def silent_rmtree(filename):
+    """Remove the file but do nothing if file does not exist."""
+    try:
+        shutil.rmtree(filename)
+    except OSError as e:
+        # errno.ENOENT is "No such file or directory"
+        # re-raise if a different error occured
+        if e.errno != errno.ENOENT:
+            raise
+
+
+def do_doctest_gettext_workaround():
+    """Setups environment for doing a doctest with gettext usage.
+
+    When using gettext with dynamically defined underscore function
+    (``_("For translation")``), doctest does not work properly. One option is
+    to use `import as` instead of dynamically defined underscore function but
+    this would require change all modules which are used by tested module.
+    This should be considered for the future. The second option is to define
+    dummy underscore function and one other function which creates the right
+    environment to satisfy all. This is done by this function.
+    """
+    def new_displayhook(string):
+        """A replacement for default `sys.displayhook`"""
+        if string is not None:
+            sys.stdout.write("%r\n" % (string,))
+
+    def new_translator(string):
+        """A fake gettext underscore function."""
+        return string
+
+    sys.displayhook = new_displayhook
+
+    import __builtin__
+    __builtin__._ = new_translator