| """ |
| TestCommon.py: a testing framework for commands and scripts |
| with commonly useful error handling |
| |
| The TestCommon module provides a simple, high-level interface for writing |
| tests of executable commands and scripts, especially commands and scripts |
| that interact with the file system. All methods throw exceptions and |
| exit on failure, with useful error messages. This makes a number of |
| explicit checks unnecessary, making the test scripts themselves simpler |
| to write and easier to read. |
| |
| The TestCommon class is a subclass of the TestCmd class. In essence, |
| TestCommon is a wrapper that handles common TestCmd error conditions in |
| useful ways. You can use TestCommon directly, or subclass it for your |
| program and add additional (or override) methods to tailor it to your |
| program's specific needs. Alternatively, the TestCommon class serves |
| as a useful example of how to define your own TestCmd subclass. |
| |
| As a subclass of TestCmd, TestCommon provides access to all of the |
| variables and methods from the TestCmd module. Consequently, you can |
| use any variable or method documented in the TestCmd module without |
| having to explicitly import TestCmd. |
| |
| A TestCommon environment object is created via the usual invocation: |
| |
| import TestCommon |
| test = TestCommon.TestCommon() |
| |
| You can use all of the TestCmd keyword arguments when instantiating a |
| TestCommon object; see the TestCmd documentation for details. |
| |
| Here is an overview of the methods and keyword arguments that are |
| provided by the TestCommon class: |
| |
| test.must_be_writable('file1', ['file2', ...]) |
| |
| test.must_contain('file', 'required text\n') |
| |
| test.must_contain_all_lines(output, lines, ['title', find]) |
| |
| test.must_contain_any_line(output, lines, ['title', find]) |
| |
| test.must_exist('file1', ['file2', ...]) |
| |
| test.must_match('file', "expected contents\n") |
| |
| test.must_not_be_writable('file1', ['file2', ...]) |
| |
| test.must_not_contain('file', 'banned text\n') |
| |
| test.must_not_contain_any_line(output, lines, ['title', find]) |
| |
| test.must_not_exist('file1', ['file2', ...]) |
| |
| test.run(options = "options to be prepended to arguments", |
| stdout = "expected standard output from the program", |
| stderr = "expected error output from the program", |
| status = expected_status, |
| match = match_function) |
| |
| The TestCommon module also provides the following variables |
| |
| TestCommon.python_executable |
| TestCommon.exe_suffix |
| TestCommon.obj_suffix |
| TestCommon.shobj_prefix |
| TestCommon.shobj_suffix |
| TestCommon.lib_prefix |
| TestCommon.lib_suffix |
| TestCommon.dll_prefix |
| TestCommon.dll_suffix |
| |
| """ |
| |
| # Copyright 2000-2010 Steven Knight |
| # This module is free software, and you may redistribute it and/or modify |
| # it under the same terms as Python itself, so long as this copyright message |
| # and disclaimer are retained in their original form. |
| # |
| # IN NO EVENT SHALL THE AUTHOR BE LIABLE TO ANY PARTY FOR DIRECT, INDIRECT, |
| # SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OF |
| # THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH |
| # DAMAGE. |
| # |
| # THE AUTHOR SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING, BUT NOT |
| # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A |
| # PARTICULAR PURPOSE. THE CODE PROVIDED HEREUNDER IS ON AN "AS IS" BASIS, |
| # AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE, |
| # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. |
| |
| __author__ = "Steven Knight <knight at baldmt dot com>" |
| __revision__ = "TestCommon.py 0.37.D001 2010/01/11 16:55:50 knight" |
| __version__ = "0.37" |
| |
| import copy |
| import os |
| import os.path |
| import stat |
| import string |
| import sys |
| import types |
| import UserList |
| |
| from TestCmd import * |
| from TestCmd import __all__ |
| |
| __all__.extend([ 'TestCommon', |
| 'exe_suffix', |
| 'obj_suffix', |
| 'shobj_prefix', |
| 'shobj_suffix', |
| 'lib_prefix', |
| 'lib_suffix', |
| 'dll_prefix', |
| 'dll_suffix', |
| ]) |
| |
| # Variables that describe the prefixes and suffixes on this system. |
| if sys.platform == 'win32': |
| exe_suffix = '.exe' |
| obj_suffix = '.obj' |
| shobj_suffix = '.obj' |
| shobj_prefix = '' |
| lib_prefix = '' |
| lib_suffix = '.lib' |
| dll_prefix = '' |
| dll_suffix = '.dll' |
| elif sys.platform == 'cygwin': |
| exe_suffix = '.exe' |
| obj_suffix = '.o' |
| shobj_suffix = '.os' |
| shobj_prefix = '' |
| lib_prefix = 'lib' |
| lib_suffix = '.a' |
| dll_prefix = '' |
| dll_suffix = '.dll' |
| elif string.find(sys.platform, 'irix') != -1: |
| exe_suffix = '' |
| obj_suffix = '.o' |
| shobj_suffix = '.o' |
| shobj_prefix = '' |
| lib_prefix = 'lib' |
| lib_suffix = '.a' |
| dll_prefix = 'lib' |
| dll_suffix = '.so' |
| elif string.find(sys.platform, 'darwin') != -1: |
| exe_suffix = '' |
| obj_suffix = '.o' |
| shobj_suffix = '.os' |
| shobj_prefix = '' |
| lib_prefix = 'lib' |
| lib_suffix = '.a' |
| dll_prefix = 'lib' |
| dll_suffix = '.dylib' |
| elif string.find(sys.platform, 'sunos') != -1: |
| exe_suffix = '' |
| obj_suffix = '.o' |
| shobj_suffix = '.os' |
| shobj_prefix = 'so_' |
| lib_prefix = 'lib' |
| lib_suffix = '.a' |
| dll_prefix = 'lib' |
| dll_suffix = '.dylib' |
| else: |
| exe_suffix = '' |
| obj_suffix = '.o' |
| shobj_suffix = '.os' |
| shobj_prefix = '' |
| lib_prefix = 'lib' |
| lib_suffix = '.a' |
| dll_prefix = 'lib' |
| dll_suffix = '.so' |
| |
| def is_List(e): |
| return type(e) is types.ListType \ |
| or isinstance(e, UserList.UserList) |
| |
| def is_writable(f): |
| mode = os.stat(f)[stat.ST_MODE] |
| return mode & stat.S_IWUSR |
| |
| def separate_files(flist): |
| existing = [] |
| missing = [] |
| for f in flist: |
| if os.path.exists(f): |
| existing.append(f) |
| else: |
| missing.append(f) |
| return existing, missing |
| |
| if os.name == 'posix': |
| def _failed(self, status = 0): |
| if self.status is None or status is None: |
| return None |
| return _status(self) != status |
| def _status(self): |
| return self.status |
| elif os.name == 'nt': |
| def _failed(self, status = 0): |
| return not (self.status is None or status is None) and \ |
| self.status != status |
| def _status(self): |
| return self.status |
| |
| class TestCommon(TestCmd): |
| |
| # Additional methods from the Perl Test::Cmd::Common module |
| # that we may wish to add in the future: |
| # |
| # $test->subdir('subdir', ...); |
| # |
| # $test->copy('src_file', 'dst_file'); |
| |
| def __init__(self, **kw): |
| """Initialize a new TestCommon instance. This involves just |
| calling the base class initialization, and then changing directory |
| to the workdir. |
| """ |
| apply(TestCmd.__init__, [self], kw) |
| os.chdir(self.workdir) |
| |
| def must_be_writable(self, *files): |
| """Ensures that the specified file(s) exist and are writable. |
| An individual file can be specified as a list of directory names, |
| in which case the pathname will be constructed by concatenating |
| them. Exits FAILED if any of the files does not exist or is |
| not writable. |
| """ |
| files = map(lambda x: is_List(x) and apply(os.path.join, x) or x, files) |
| existing, missing = separate_files(files) |
| unwritable = filter(lambda x, iw=is_writable: not iw(x), existing) |
| if missing: |
| print "Missing files: `%s'" % string.join(missing, "', `") |
| if unwritable: |
| print "Unwritable files: `%s'" % string.join(unwritable, "', `") |
| self.fail_test(missing + unwritable) |
| |
| def must_contain(self, file, required, mode = 'rb'): |
| """Ensures that the specified file contains the required text. |
| """ |
| file_contents = self.read(file, mode) |
| contains = (string.find(file_contents, required) != -1) |
| if not contains: |
| print "File `%s' does not contain required string." % file |
| print self.banner('Required string ') |
| print required |
| print self.banner('%s contents ' % file) |
| print file_contents |
| self.fail_test(not contains) |
| |
| def must_contain_all_lines(self, output, lines, title=None, find=None): |
| """Ensures that the specified output string (first argument) |
| contains all of the specified lines (second argument). |
| |
| An optional third argument can be used to describe the type |
| of output being searched, and only shows up in failure output. |
| |
| An optional fourth argument can be used to supply a different |
| function, of the form "find(line, output), to use when searching |
| for lines in the output. |
| """ |
| if find is None: |
| find = lambda o, l: string.find(o, l) != -1 |
| missing = [] |
| for line in lines: |
| if not find(output, line): |
| missing.append(line) |
| |
| if missing: |
| if title is None: |
| title = 'output' |
| sys.stdout.write("Missing expected lines from %s:\n" % title) |
| for line in missing: |
| sys.stdout.write(' ' + repr(line) + '\n') |
| sys.stdout.write(self.banner(title + ' ')) |
| sys.stdout.write(output) |
| self.fail_test() |
| |
| def must_contain_any_line(self, output, lines, title=None, find=None): |
| """Ensures that the specified output string (first argument) |
| contains at least one of the specified lines (second argument). |
| |
| An optional third argument can be used to describe the type |
| of output being searched, and only shows up in failure output. |
| |
| An optional fourth argument can be used to supply a different |
| function, of the form "find(line, output), to use when searching |
| for lines in the output. |
| """ |
| if find is None: |
| find = lambda o, l: string.find(o, l) != -1 |
| for line in lines: |
| if find(output, line): |
| return |
| |
| if title is None: |
| title = 'output' |
| sys.stdout.write("Missing any expected line from %s:\n" % title) |
| for line in lines: |
| sys.stdout.write(' ' + repr(line) + '\n') |
| sys.stdout.write(self.banner(title + ' ')) |
| sys.stdout.write(output) |
| self.fail_test() |
| |
| def must_contain_lines(self, lines, output, title=None): |
| # Deprecated; retain for backwards compatibility. |
| return self.must_contain_all_lines(output, lines, title) |
| |
| def must_exist(self, *files): |
| """Ensures that the specified file(s) must exist. An individual |
| file be specified as a list of directory names, in which case the |
| pathname will be constructed by concatenating them. Exits FAILED |
| if any of the files does not exist. |
| """ |
| files = map(lambda x: is_List(x) and apply(os.path.join, x) or x, files) |
| missing = filter(lambda x: not os.path.exists(x), files) |
| if missing: |
| print "Missing files: `%s'" % string.join(missing, "', `") |
| self.fail_test(missing) |
| |
| def must_match(self, file, expect, mode = 'rb'): |
| """Matches the contents of the specified file (first argument) |
| against the expected contents (second argument). The expected |
| contents are a list of lines or a string which will be split |
| on newlines. |
| """ |
| file_contents = self.read(file, mode) |
| try: |
| self.fail_test(not self.match(file_contents, expect)) |
| except KeyboardInterrupt: |
| raise |
| except: |
| print "Unexpected contents of `%s'" % file |
| self.diff(expect, file_contents, 'contents ') |
| raise |
| |
| def must_not_contain(self, file, banned, mode = 'rb'): |
| """Ensures that the specified file doesn't contain the banned text. |
| """ |
| file_contents = self.read(file, mode) |
| contains = (string.find(file_contents, banned) != -1) |
| if contains: |
| print "File `%s' contains banned string." % file |
| print self.banner('Banned string ') |
| print banned |
| print self.banner('%s contents ' % file) |
| print file_contents |
| self.fail_test(contains) |
| |
| def must_not_contain_any_line(self, output, lines, title=None, find=None): |
| """Ensures that the specified output string (first argument) |
| does not contain any of the specified lines (second argument). |
| |
| An optional third argument can be used to describe the type |
| of output being searched, and only shows up in failure output. |
| |
| An optional fourth argument can be used to supply a different |
| function, of the form "find(line, output), to use when searching |
| for lines in the output. |
| """ |
| if find is None: |
| find = lambda o, l: string.find(o, l) != -1 |
| unexpected = [] |
| for line in lines: |
| if find(output, line): |
| unexpected.append(line) |
| |
| if unexpected: |
| if title is None: |
| title = 'output' |
| sys.stdout.write("Unexpected lines in %s:\n" % title) |
| for line in unexpected: |
| sys.stdout.write(' ' + repr(line) + '\n') |
| sys.stdout.write(self.banner(title + ' ')) |
| sys.stdout.write(output) |
| self.fail_test() |
| |
| def must_not_contain_lines(self, lines, output, title=None): |
| return self.must_not_contain_any_line(output, lines, title) |
| |
| def must_not_exist(self, *files): |
| """Ensures that the specified file(s) must not exist. |
| An individual file be specified as a list of directory names, in |
| which case the pathname will be constructed by concatenating them. |
| Exits FAILED if any of the files exists. |
| """ |
| files = map(lambda x: is_List(x) and apply(os.path.join, x) or x, files) |
| existing = filter(os.path.exists, files) |
| if existing: |
| print "Unexpected files exist: `%s'" % string.join(existing, "', `") |
| self.fail_test(existing) |
| |
| |
| def must_not_be_writable(self, *files): |
| """Ensures that the specified file(s) exist and are not writable. |
| An individual file can be specified as a list of directory names, |
| in which case the pathname will be constructed by concatenating |
| them. Exits FAILED if any of the files does not exist or is |
| writable. |
| """ |
| files = map(lambda x: is_List(x) and apply(os.path.join, x) or x, files) |
| existing, missing = separate_files(files) |
| writable = filter(is_writable, existing) |
| if missing: |
| print "Missing files: `%s'" % string.join(missing, "', `") |
| if writable: |
| print "Writable files: `%s'" % string.join(writable, "', `") |
| self.fail_test(missing + writable) |
| |
| def _complete(self, actual_stdout, expected_stdout, |
| actual_stderr, expected_stderr, status, match): |
| """ |
| Post-processes running a subcommand, checking for failure |
| status and displaying output appropriately. |
| """ |
| if _failed(self, status): |
| expect = '' |
| if status != 0: |
| expect = " (expected %s)" % str(status) |
| print "%s returned %s%s" % (self.program, str(_status(self)), expect) |
| print self.banner('STDOUT ') |
| print actual_stdout |
| print self.banner('STDERR ') |
| print actual_stderr |
| self.fail_test() |
| if not expected_stdout is None and not match(actual_stdout, expected_stdout): |
| self.diff(expected_stdout, actual_stdout, 'STDOUT ') |
| if actual_stderr: |
| print self.banner('STDERR ') |
| print actual_stderr |
| self.fail_test() |
| if not expected_stderr is None and not match(actual_stderr, expected_stderr): |
| print self.banner('STDOUT ') |
| print actual_stdout |
| self.diff(expected_stderr, actual_stderr, 'STDERR ') |
| self.fail_test() |
| |
| def start(self, program = None, |
| interpreter = None, |
| arguments = None, |
| universal_newlines = None, |
| **kw): |
| """ |
| Starts a program or script for the test environment. |
| |
| This handles the "options" keyword argument and exceptions. |
| """ |
| try: |
| options = kw['options'] |
| del kw['options'] |
| except KeyError: |
| pass |
| else: |
| if options: |
| if arguments is None: |
| arguments = options |
| else: |
| arguments = options + " " + arguments |
| try: |
| return apply(TestCmd.start, |
| (self, program, interpreter, arguments, universal_newlines), |
| kw) |
| except KeyboardInterrupt: |
| raise |
| except Exception, e: |
| print self.banner('STDOUT ') |
| try: |
| print self.stdout() |
| except IndexError: |
| pass |
| print self.banner('STDERR ') |
| try: |
| print self.stderr() |
| except IndexError: |
| pass |
| cmd_args = self.command_args(program, interpreter, arguments) |
| sys.stderr.write('Exception trying to execute: %s\n' % cmd_args) |
| raise e |
| |
| def finish(self, popen, stdout = None, stderr = '', status = 0, **kw): |
| """ |
| Finishes and waits for the process being run under control of |
| the specified popen argument. Additional arguments are similar |
| to those of the run() method: |
| |
| stdout The expected standard output from |
| the command. A value of None means |
| don't test standard output. |
| |
| stderr The expected error output from |
| the command. A value of None means |
| don't test error output. |
| |
| status The expected exit status from the |
| command. A value of None means don't |
| test exit status. |
| """ |
| apply(TestCmd.finish, (self, popen,), kw) |
| match = kw.get('match', self.match) |
| self._complete(self.stdout(), stdout, |
| self.stderr(), stderr, status, match) |
| |
| def run(self, options = None, arguments = None, |
| stdout = None, stderr = '', status = 0, **kw): |
| """Runs the program under test, checking that the test succeeded. |
| |
| The arguments are the same as the base TestCmd.run() method, |
| with the addition of: |
| |
| options Extra options that get appended to the beginning |
| of the arguments. |
| |
| stdout The expected standard output from |
| the command. A value of None means |
| don't test standard output. |
| |
| stderr The expected error output from |
| the command. A value of None means |
| don't test error output. |
| |
| status The expected exit status from the |
| command. A value of None means don't |
| test exit status. |
| |
| By default, this expects a successful exit (status = 0), does |
| not test standard output (stdout = None), and expects that error |
| output is empty (stderr = ""). |
| """ |
| if options: |
| if arguments is None: |
| arguments = options |
| else: |
| arguments = options + " " + arguments |
| kw['arguments'] = arguments |
| try: |
| match = kw['match'] |
| del kw['match'] |
| except KeyError: |
| match = self.match |
| apply(TestCmd.run, [self], kw) |
| self._complete(self.stdout(), stdout, |
| self.stderr(), stderr, status, match) |
| |
| def skip_test(self, message="Skipping test.\n"): |
| """Skips a test. |
| |
| Proper test-skipping behavior is dependent on the external |
| TESTCOMMON_PASS_SKIPS environment variable. If set, we treat |
| the skip as a PASS (exit 0), and otherwise treat it as NO RESULT. |
| In either case, we print the specified message as an indication |
| that the substance of the test was skipped. |
| |
| (This was originally added to support development under Aegis. |
| Technically, skipping a test is a NO RESULT, but Aegis would |
| treat that as a test failure and prevent the change from going to |
| the next step. Since we ddn't want to force anyone using Aegis |
| to have to install absolutely every tool used by the tests, we |
| would actually report to Aegis that a skipped test has PASSED |
| so that the workflow isn't held up.) |
| """ |
| if message: |
| sys.stdout.write(message) |
| sys.stdout.flush() |
| pass_skips = os.environ.get('TESTCOMMON_PASS_SKIPS') |
| if pass_skips in [None, 0, '0']: |
| # skip=1 means skip this function when showing where this |
| # result came from. They only care about the line where the |
| # script called test.skip_test(), not the line number where |
| # we call test.no_result(). |
| self.no_result(skip=1) |
| else: |
| # We're under the development directory for this change, |
| # so this is an Aegis invocation; pass the test (exit 0). |
| self.pass_test() |
| |
| # Local Variables: |
| # tab-width:4 |
| # indent-tabs-mode:nil |
| # End: |
| # vim: set expandtab tabstop=4 shiftwidth=4: |