# Copyright 2008 Google Inc. All Rights Reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are # met: # # * Redistributions of source code must retain the above copyright # notice, this list of conditions and the following disclaimer. # * Redistributions in binary form must reproduce the above # copyright notice, this list of conditions and the following disclaimer # in the documentation and/or other materials provided with the # distribution. # * Neither the name of Google Inc. nor the names of its # contributors may be used to endorse or promote products derived from # this software without specific prior written permission. # # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR # A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT # OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT # LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, # DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY # THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. """Provides facilities for running SCons-built Google Test/Mock tests.""" import optparse import os import re import sets import sys try: # subrocess module is a preferable way to invoke subprocesses but it may # not be available on MacOS X 10.4. # Suppresses the 'Import not at the top of the file' lint complaint. # pylint: disable-msg=C6204 import subprocess except ImportError: subprocess = None HELP_MSG = """Runs the specified tests for %(proj)s. SYNOPSIS run_tests.py [OPTION]... [BUILD_DIR]... [TEST]... DESCRIPTION Runs the specified tests (either binary or Python), and prints a summary of the results. BUILD_DIRS will be used to search for the binaries. If no TESTs are specified, all binary tests found in BUILD_DIRs and all Python tests found in the directory test/ (in the %(proj)s root) are run. TEST is a name of either a binary or a Python test. A binary test is an executable file named *_test or *_unittest (with the .exe extension on Windows) A Python test is a script named *_test.py or *_unittest.py. OPTIONS -h, --help Print this help message. -c CONFIGURATIONS Specify build directories via build configurations. CONFIGURATIONS is either a comma-separated list of build configurations or 'all'. Each configuration is equivalent to adding 'scons/build/<configuration>/%(proj)s/scons' to BUILD_DIRs. Specifying -c=all is equivalent to providing all directories listed in KNOWN BUILD DIRECTORIES section below. -a Equivalent to -c=all -b Equivalent to -c=all with the exception that the script will not fail if some of the KNOWN BUILD DIRECTORIES do not exists; the script will simply not run the tests there. 'b' stands for 'built directories'. RETURN VALUE Returns 0 if all tests are successful; otherwise returns 1. EXAMPLES run_tests.py Runs all tests for the default build configuration. run_tests.py -a Runs all tests with binaries in KNOWN BUILD DIRECTORIES. run_tests.py -b Runs all tests in KNOWN BUILD DIRECTORIES that have been built. run_tests.py foo/ Runs all tests in the foo/ directory and all Python tests in the directory test. The Python tests are instructed to look for binaries in foo/. run_tests.py bar_test.exe test/baz_test.exe foo/ bar/ Runs foo/bar_test.exe, bar/bar_test.exe, foo/baz_test.exe, and bar/baz_test.exe. run_tests.py foo bar test/foo_test.py Runs test/foo_test.py twice instructing it to look for its test binaries in the directories foo and bar, correspondingly. KNOWN BUILD DIRECTORIES run_tests.py knows about directories where the SCons build script deposits its products. These are the directories where run_tests.py will be looking for its binaries. Currently, %(proj)s's SConstruct file defines them as follows (the default build directory is the first one listed in each group): On Windows: <%(proj)s root>/scons/build/win-dbg8/%(proj)s/scons/ <%(proj)s root>/scons/build/win-opt8/%(proj)s/scons/ On Mac: <%(proj)s root>/scons/build/mac-dbg/%(proj)s/scons/ <%(proj)s root>/scons/build/mac-opt/%(proj)s/scons/ On other platforms: <%(proj)s root>/scons/build/dbg/%(proj)s/scons/ <%(proj)s root>/scons/build/opt/%(proj)s/scons/""" IS_WINDOWS = os.name == 'nt' IS_MAC = os.name == 'posix' and os.uname()[0] == 'Darwin' IS_CYGWIN = os.name == 'posix' and 'CYGWIN' in os.uname()[0] # Definition of CONFIGS must match that of the build directory names in the # SConstruct script. The first list item is the default build configuration. if IS_WINDOWS: CONFIGS = ('win-dbg8', 'win-opt8') elif IS_MAC: CONFIGS = ('mac-dbg', 'mac-opt') else: CONFIGS = ('dbg', 'opt') if IS_WINDOWS or IS_CYGWIN: PYTHON_TEST_REGEX = re.compile(r'_(unit)?test\.py$', re.IGNORECASE) BINARY_TEST_REGEX = re.compile(r'_(unit)?test(\.exe)?$', re.IGNORECASE) BINARY_TEST_SEARCH_REGEX = re.compile(r'_(unit)?test\.exe$', re.IGNORECASE) else: PYTHON_TEST_REGEX = re.compile(r'_(unit)?test\.py$') BINARY_TEST_REGEX = re.compile(r'_(unit)?test$') BINARY_TEST_SEARCH_REGEX = BINARY_TEST_REGEX def _GetGtestBuildDir(injected_os, script_dir, config): """Calculates path to the Google Test SCons build directory.""" return injected_os.path.normpath(injected_os.path.join(script_dir, 'scons/build', config, 'gtest/scons')) def _GetConfigFromBuildDir(build_dir): """Extracts the configuration name from the build directory.""" # We don't want to depend on build_dir containing the correct path # separators. m = re.match(r'.*[\\/]([^\\/]+)[\\/][^\\/]+[\\/]scons[\\/]?$', build_dir) if m: return m.group(1) else: print >>sys.stderr, ('%s is an invalid build directory that does not ' 'correspond to any configuration.' % (build_dir,)) return '' # All paths in this script are either absolute or relative to the current # working directory, unless otherwise specified. class TestRunner(object): """Provides facilities for running Python and binary tests for Google Test.""" def __init__(self, script_dir, build_dir_var_name='GTEST_BUILD_DIR', injected_os=os, injected_subprocess=subprocess, injected_build_dir_finder=_GetGtestBuildDir): """Initializes a TestRunner instance. Args: script_dir: File path to the calling script. build_dir_var_name: Name of the env variable used to pass the the build directory path to the invoked tests. injected_os: standard os module or a mock/stub for testing. injected_subprocess: standard subprocess module or a mock/stub for testing injected_build_dir_finder: function that determines the path to the build directory. """ self.os = injected_os self.subprocess = injected_subprocess self.build_dir_finder = injected_build_dir_finder self.build_dir_var_name = build_dir_var_name self.script_dir = script_dir def _GetBuildDirForConfig(self, config): """Returns the build directory for a given configuration.""" return self.build_dir_finder(self.os, self.script_dir, config) def _Run(self, args): """Runs the executable with given args (args[0] is the executable name). Args: args: Command line arguments for the process. Returns: Process's exit code if it exits normally, or -signal if the process is killed by a signal. """ if self.subprocess: return self.subprocess.Popen(args).wait() else: return self.os.spawnv(self.os.P_WAIT, args[0], args) def _RunBinaryTest(self, test): """Runs the binary test given its path. Args: test: Path to the test binary. Returns: Process's exit code if it exits normally, or -signal if the process is killed by a signal. """ return self._Run([test]) def _RunPythonTest(self, test, build_dir): """Runs the Python test script with the specified build directory. Args: test: Path to the test's Python script. build_dir: Path to the directory where the test binary is to be found. Returns: Process's exit code if it exits normally, or -signal if the process is killed by a signal. """ old_build_dir = self.os.environ.get(self.build_dir_var_name) try: self.os.environ[self.build_dir_var_name] = build_dir # If this script is run on a Windows machine that has no association # between the .py extension and a python interpreter, simply passing # the script name into subprocess.Popen/os.spawn will not work. print 'Running %s . . .' % (test,) return self._Run([sys.executable, test]) finally: if old_build_dir is None: del self.os.environ[self.build_dir_var_name] else: self.os.environ[self.build_dir_var_name] = old_build_dir def _FindFilesByRegex(self, directory, regex): """Returns files in a directory whose names match a regular expression. Args: directory: Path to the directory to search for files. regex: Regular expression to filter file names. Returns: The list of the paths to the files in the directory. """ return [self.os.path.join(directory, file_name) for file_name in self.os.listdir(directory) if re.search(regex, file_name)] # TODO(vladl@google.com): Implement parsing of scons/SConscript to run all # tests defined there when no tests are specified. # TODO(vladl@google.com): Update the docstring after the code is changed to # try to test all builds defined in scons/SConscript. def GetTestsToRun(self, args, named_configurations, built_configurations, available_configurations=CONFIGS, python_tests_to_skip=None): """Determines what tests should be run. Args: args: The list of non-option arguments from the command line. named_configurations: The list of configurations specified via -c or -a. built_configurations: True if -b has been specified. available_configurations: a list of configurations available on the current platform, injectable for testing. python_tests_to_skip: a collection of (configuration, python test name)s that need to be skipped. Returns: A tuple with 2 elements: the list of Python tests to run and the list of binary tests to run. """ if named_configurations == 'all': named_configurations = ','.join(available_configurations) normalized_args = [self.os.path.normpath(arg) for arg in args] # A final list of build directories which will be searched for the test # binaries. First, add directories specified directly on the command # line. build_dirs = filter(self.os.path.isdir, normalized_args) # Adds build directories specified via their build configurations using # the -c or -a options. if named_configurations: build_dirs += [self._GetBuildDirForConfig(config) for config in named_configurations.split(',')] # Adds KNOWN BUILD DIRECTORIES if -b is specified. if built_configurations: build_dirs += [self._GetBuildDirForConfig(config) for config in available_configurations if self.os.path.isdir(self._GetBuildDirForConfig(config))] # If no directories were specified either via -a, -b, -c, or directly, use # the default configuration. elif not build_dirs: build_dirs = [self._GetBuildDirForConfig(available_configurations[0])] # Makes sure there are no duplications. build_dirs = sets.Set(build_dirs) errors_found = False listed_python_tests = [] # All Python tests listed on the command line. listed_binary_tests = [] # All binary tests listed on the command line. test_dir = self.os.path.normpath(self.os.path.join(self.script_dir, 'test')) # Sifts through non-directory arguments fishing for any Python or binary # tests and detecting errors. for argument in sets.Set(normalized_args) - build_dirs: if re.search(PYTHON_TEST_REGEX, argument): python_path = self.os.path.join(test_dir, self.os.path.basename(argument)) if self.os.path.isfile(python_path): listed_python_tests.append(python_path) else: sys.stderr.write('Unable to find Python test %s' % argument) errors_found = True elif re.search(BINARY_TEST_REGEX, argument): # This script also accepts binary test names prefixed with test/ for # the convenience of typing them (can use path completions in the # shell). Strips test/ prefix from the binary test names. listed_binary_tests.append(self.os.path.basename(argument)) else: sys.stderr.write('%s is neither test nor build directory' % argument) errors_found = True if errors_found: return None user_has_listed_tests = listed_python_tests or listed_binary_tests if user_has_listed_tests: selected_python_tests = listed_python_tests else: selected_python_tests = self._FindFilesByRegex(test_dir, PYTHON_TEST_REGEX) # TODO(vladl@google.com): skip unbuilt Python tests when -b is specified. python_test_pairs = [] for directory in build_dirs: for test in selected_python_tests: config = _GetConfigFromBuildDir(directory) file_name = os.path.basename(test) if python_tests_to_skip and (config, file_name) in python_tests_to_skip: print ('NOTE: %s is skipped for configuration %s, as it does not ' 'work there.' % (file_name, config)) else: python_test_pairs.append((directory, test)) binary_test_pairs = [] for directory in build_dirs: if user_has_listed_tests: binary_test_pairs.extend( [(directory, self.os.path.join(directory, test)) for test in listed_binary_tests]) else: tests = self._FindFilesByRegex(directory, BINARY_TEST_SEARCH_REGEX) binary_test_pairs.extend([(directory, test) for test in tests]) return (python_test_pairs, binary_test_pairs) def RunTests(self, python_tests, binary_tests): """Runs Python and binary tests and reports results to the standard output. Args: python_tests: List of Python tests to run in the form of tuples (build directory, Python test script). binary_tests: List of binary tests to run in the form of tuples (build directory, binary file). Returns: The exit code the program should pass into sys.exit(). """ if python_tests or binary_tests: results = [] for directory, test in python_tests: results.append((directory, test, self._RunPythonTest(test, directory) == 0)) for directory, test in binary_tests: results.append((directory, self.os.path.basename(test), self._RunBinaryTest(test) == 0)) failed = [(directory, test) for (directory, test, success) in results if not success] print print '%d tests run.' % len(results) if failed: print 'The following %d tests failed:' % len(failed) for (directory, test) in failed: print '%s in %s' % (test, directory) return 1 else: print 'All tests passed!' else: # No tests defined print 'Nothing to test - no tests specified!' return 0 def ParseArgs(project_name, argv=None, help_callback=None): """Parses the options run_tests.py uses.""" # Suppresses lint warning on unused arguments. These arguments are # required by optparse, even though they are unused. # pylint: disable-msg=W0613 def PrintHelp(option, opt, value, parser): print HELP_MSG % {'proj': project_name} sys.exit(1) parser = optparse.OptionParser() parser.add_option('-c', action='store', dest='configurations', default=None) parser.add_option('-a', action='store_const', dest='configurations', default=None, const='all') parser.add_option('-b', action='store_const', dest='built_configurations', default=False, const=True) # Replaces the built-in help with ours. parser.remove_option('-h') parser.add_option('-h', '--help', action='callback', callback=help_callback or PrintHelp) return parser.parse_args(argv)