From 2e2136d39ca64dc686c81f8611cf2e10aefb7d1c Mon Sep 17 00:00:00 2001 From: Christine Lytwynec <clytwynec@edx.org> Date: Mon, 12 May 2014 22:19:59 -0400 Subject: [PATCH] Deprecated unit tests from rake to paver --- docs/en_us/internal/testing.md | 71 ++++---- pavelib/__init__.py | 3 +- pavelib/js_test.py | 72 ++++++++ pavelib/quality.py | 22 +-- pavelib/tests.py | 187 +++++++++++++++++++++ pavelib/utils/envs.py | 33 ++++ pavelib/utils/test/__init__.py | 0 pavelib/utils/test/suites/__init__.py | 8 + pavelib/utils/test/suites/i18n_suite.py | 40 +++++ pavelib/utils/test/suites/js_suite.py | 61 +++++++ pavelib/utils/test/suites/nose_suite.py | 169 +++++++++++++++++++ pavelib/utils/test/suites/python_suite.py | 48 ++++++ pavelib/utils/test/suites/suite.py | 123 ++++++++++++++ pavelib/utils/test/utils.py | 45 +++++ rakelib/i18n.rake | 10 -- rakelib/js_test.rake | 97 ----------- rakelib/js_test_deprecated.rake | 26 +++ rakelib/tests.rake | 192 ---------------------- rakelib/tests_deprecated.rake | 45 +++++ 19 files changed, 903 insertions(+), 349 deletions(-) create mode 100644 pavelib/js_test.py create mode 100644 pavelib/tests.py create mode 100644 pavelib/utils/test/__init__.py create mode 100644 pavelib/utils/test/suites/__init__.py create mode 100644 pavelib/utils/test/suites/i18n_suite.py create mode 100644 pavelib/utils/test/suites/js_suite.py create mode 100644 pavelib/utils/test/suites/nose_suite.py create mode 100644 pavelib/utils/test/suites/python_suite.py create mode 100644 pavelib/utils/test/suites/suite.py create mode 100644 pavelib/utils/test/utils.py delete mode 100644 rakelib/js_test.rake create mode 100644 rakelib/js_test_deprecated.rake delete mode 100644 rakelib/tests.rake create mode 100644 rakelib/tests_deprecated.rake diff --git a/docs/en_us/internal/testing.md b/docs/en_us/internal/testing.md index 5682f657128..39ecc37b89c 100644 --- a/docs/en_us/internal/testing.md +++ b/docs/en_us/internal/testing.md @@ -93,7 +93,7 @@ because the `capa` package handles problem XML. You can run all of the unit-level tests using the command - rake test + paver test This includes python, javascript, and documentation tests. It does not, however, run any acceptance tests. @@ -104,44 +104,54 @@ We use [nose](https://nose.readthedocs.org/en/latest/) through the [django-nose plugin](https://pypi.python.org/pypi/django-nose) to run the test suite. -You can run all the python tests using `rake` commands. For example, +You can run all the python tests using `paver` commands. For example, - rake test:python + paver test_python runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). You can re-run all failed python tests by running: (see note at end of section) - rake test:python[--failed] + paver test_python --failed -You can also run the tests without `collectstatic`, which tends to be faster: +To test lms or cms python, use: - rake fasttest_lms + paver test_system -s lms or - rake fasttest_cms + paver test_system -s cms -xmodule can be tested independently, with this: +You can also run these tests without `collectstatic`, which is faster: - rake test_common/lib/xmodule + paver test_system -s lms --fasttest -other module level tests include +or -* `rake test_common/lib/capa` -* `rake test_common/lib/calc` + paver test_system -s cms --fasttest To run a single django test class: - rake test_lms[lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest] + paver test_system -t lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest To run a single django test: - rake test_lms[lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest.test_activate_login] + paver test_system -t lms/djangoapps/courseware/tests/tests.py:ActivateLoginTest.test_activate_login + +To re-run all failing django tests from lms or cms, use the `--failed`,`-f` flag (see note at end of section) + + paver test_system -s lms --failed + paver test_system -s cms --failed -To re-run all failing django tests from lms or cms: (see note at end of section) +There is also a `--fail_fast`, `-x` option that will stop nosetests after the first failure. - rake test_lms[--failed] +common/lib tests are tested with the `test_lib` task, which also accepts the `--failed` and `--fail_fast` options. For example: + + paver test_lib -l common/lib/calc + +or + + paver test_lib -l common/lib/xmodule --failed To run a single nose test file: @@ -174,7 +184,7 @@ To run tests for stub servers, for example for [YouTube stub server](https://github.com/edx/edx-platform/blob/master/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py), you can do one of: - rake fasttest_cms[common/djangoapps/terrain/stubs/tests/test_youtube_stub.py] + paver test_system -s cms -t common/djangoapps/terrain/stubs/tests/test_youtube_stub.py python -m coverage run --rcfile=cms/.coveragerc `which ./manage.py` cms --settings test test --traceback common/djangoapps/terrain/stubs/tests/test_youtube_stub.py @@ -183,28 +193,31 @@ Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you i Note: More on the `--failed` functionality * In order to use this, you must run the tests first. If you haven't already run the tests, or if no tests failed in the previous run, then using the `--failed` switch will result in **all** of the tests being run. See more about this in the [nose documentation](http://nose.readthedocs.org/en/latest/plugins/testid.html#looping-over-failed-tests). -* Note that `rake test:python` calls nosetests separately for cms and lms. This means that if tests failed only in lms on the previous run, then calling `rake test:python[--failed]` will run **all of the tests for cms** in addition to the previously failing lms tests. If you want it to run only the failing tests for lms or cms, use the `rake test_lms[--failed]` or `rake test_cms[--failed]` commands. +* Note that `paver test_python` calls nosetests separately for cms and lms. This means that if tests failed only in lms on the previous run, then calling `paver test_python --failed` will run **all of the tests for cms** in addition to the previously failing lms tests. If you want it to run only the failing tests for lms or cms, use the `paver test_system -s lms --failed` or `paver test_system -s cms --failed` commands. ### Running Javascript Unit Tests We use Jasmine to run JavaScript unit tests. To run all the JavaScript tests: - rake test:js + paver test_js To run a specific set of JavaScript tests and print the results to the console: - rake test:js:run[lms] - rake test:js:run[cms] - rake test:js:run[xmodule] - rake test:js:run[common] + paver test_js_run -s lms + paver test_js_run -s cms + paver test_js_run -s cms-squire + paver test_js_run -s xmodule + paver test_js_run -s common To run JavaScript tests in your default browser: - rake test:js:dev[lms] - rake test:js:dev[cms] - rake test:js:dev[xmodule] - rake test:js:dev[common] + paver test_js_dev -s lms + paver test_js_dev -s cms + paver test_js_dev -s cms-squire + paver test_js_dev -s xmodule + paver test_js_dev -s common + These rake commands call through to a custom test runner. For more info, see [js-test-tool](https://github.com/edx/js-test-tool). @@ -334,11 +347,11 @@ To view test coverage: 1. Run the test suite: - rake test + paver test 2. Generate reports: - rake coverage + paver coverage 3. Reports are located in the `reports` folder. The command generates HTML and XML (Cobertura format) reports. diff --git a/pavelib/__init__.py b/pavelib/__init__.py index 1f1af5c3af9..d2edaa42a22 100644 --- a/pavelib/__init__.py +++ b/pavelib/__init__.py @@ -1,5 +1,4 @@ """ paver commands """ -__all__ = ["assets", "servers", "docs", "prereqs", "quality"] -from . import assets, servers, docs, prereqs, quality +from . import assets, servers, docs, prereqs, quality, tests, js_test diff --git a/pavelib/js_test.py b/pavelib/js_test.py new file mode 100644 index 00000000000..d69e60da3db --- /dev/null +++ b/pavelib/js_test.py @@ -0,0 +1,72 @@ +""" +Javascript test tasks +""" +import sys +from paver.easy import task, cmdopts, needs +from pavelib.utils.test.suites import JsTestSuite +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +@task +@needs( + 'pavelib.prereqs.install_node_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +@cmdopts([ + ("suite=", "s", "Test suite to run"), + ("mode=", "m", "dev or run"), + ("coverage", "c", "Run test under coverage"), +]) +def test_js(options): + """ + Run the JavaScript tests + """ + mode = getattr(options, 'mode', 'run') + + if mode == 'run': + suite = getattr(options, 'suite', 'all') + coverage = getattr(options, 'coverage', False) + elif mode == 'dev': + suite = getattr(options, 'suite', None) + coverage = False + else: + sys.stderr.write("Invalid mode. Please choose 'dev' or 'run'.") + return + + if (suite != 'all') and (suite not in Env.JS_TEST_ID_KEYS): + sys.stderr.write( + "Unknown test suite. Please choose from ({suites})\n".format( + suites=", ".join(Env.JS_TEST_ID_KEYS) + ) + ) + return + + test_suite = JsTestSuite(suite, mode=mode, with_coverage=coverage) + test_suite.run() + + +@task +@cmdopts([ + ("suite=", "s", "Test suite to run"), + ("coverage", "c", "Run test under coverage"), +]) +def test_js_run(options): + """ + Run the JavaScript tests and print results to the console + """ + setattr(options, 'mode', 'run') + test_js(options) + + +@task +@cmdopts([ + ("suite=", "s", "Test suite to run"), +]) +def test_js_dev(options): + """ + Run the JavaScript tests in your default browsers + """ + setattr(options, 'mode', 'dev') + test_js(options) diff --git a/pavelib/quality.py b/pavelib/quality.py index c99d012bc76..4558db37f3f 100644 --- a/pavelib/quality.py +++ b/pavelib/quality.py @@ -6,22 +6,6 @@ import os import errno from .utils.envs import Env - -def get_or_make_dir(directory_path): - """ - Ensure that a directory exists, and return its path - """ - try: - os.makedirs(directory_path) - except OSError as err: - if err.errno != errno.EEXIST: - # If we get an error other than one that says - # that the file already exists - raise - - return directory_path - - @task @needs('pavelib.prereqs.install_python_prereqs') @cmdopts([ @@ -38,7 +22,7 @@ def run_pylint(options): for system in systems: # Directory to put the pylint report in. # This makes the folder if it doesn't already exist. - report_dir = get_or_make_dir(os.path.join(Env.REPORT_DIR, system)) + report_dir = (Env.REPORT_DIR / system).makedirs_p() flags = '-E' if errors else '' @@ -82,7 +66,7 @@ def run_pep8(options): for system in systems: # Directory to put the pep8 report in. # This makes the folder if it doesn't already exist. - report_dir = get_or_make_dir(os.path.join(Env.REPORT_DIR, system)) + report_dir = (Env.REPORT_DIR / system).makedirs_p() sh('pep8 {system} | tee {report_dir}/pep8.report'.format(system=system, report_dir=report_dir)) @@ -96,7 +80,7 @@ def run_quality(): # Directory to put the diff reports in. # This makes the folder if it doesn't already exist. - dquality_dir = get_or_make_dir(os.path.join(Env.REPORT_DIR, "diff_quality")) + dquality_dir = (Env.REPORT_DIR / "diff_quality").makedirs_p() # Generage diff-quality html report for pep8, and print to console # If pep8 reports exist, use those diff --git a/pavelib/tests.py b/pavelib/tests.py new file mode 100644 index 00000000000..c992d4f2773 --- /dev/null +++ b/pavelib/tests.py @@ -0,0 +1,187 @@ +""" +Unit test tasks +""" +import os +import sys +from paver.easy import sh, task, cmdopts, needs +from pavelib.utils.test import suites +from pavelib.utils.envs import Env + +try: + from pygments.console import colorize +except ImportError: + colorize = lambda color, text: text # pylint: disable-msg=invalid-name + +__test__ = False # do not collect + + +@task +@needs( + 'pavelib.prereqs.install_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +@cmdopts([ + ("system=", "s", "System to act on"), + ("test_id=", "t", "Test id"), + ("failed", "f", "Run only failed tests"), + ("fail_fast", "x", "Run only failed tests"), + ("fasttest", "a", "Run without collectstatic") +]) +def test_system(options): + """ + Run tests on our djangoapps for lms and cms + """ + system = getattr(options, 'system', None) + test_id = getattr(options, 'test_id', None) + + opts = { + 'failed_only': getattr(options, 'failed', None), + 'fail_fast': getattr(options, 'fail_fast', None), + 'fasttest': getattr(options, 'fasttest', None), + } + + if test_id: + if not system: + system = test_id.split('/')[0] + opts['test_id'] = test_id + + if test_id or system: + system_tests = [suites.SystemTestSuite(system, **opts)] + else: + system_tests = [] + for syst in ('cms', 'lms'): + system_tests.append(suites.SystemTestSuite(syst, **opts)) + + test_suite = suites.PythonTestSuite('python tests', subsuites=system_tests, **opts) + test_suite.run() + + +@task +@needs( + 'pavelib.prereqs.install_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +@cmdopts([ + ("lib=", "l", "lib to test"), + ("test_id=", "t", "Test id"), + ("failed", "f", "Run only failed tests"), + ("fail_fast", "x", "Run only failed tests"), +]) +def test_lib(options): + """ + Run tests for common/lib/ + """ + lib = getattr(options, 'lib', None) + test_id = getattr(options, 'test_id', lib) + + opts = { + 'failed_only': getattr(options, 'failed', None), + 'fail_fast': getattr(options, 'fail_fast', None), + } + + if test_id: + lib = '/'.join(test_id.split('/')[0:3]) + opts['test_id'] = test_id + lib_tests = [suites.LibTestSuite(lib, **opts)] + else: + lib_tests = [suites.LibTestSuite(d, **opts) for d in Env.LIB_TEST_DIRS] + + test_suite = suites.PythonTestSuite('python tests', subsuites=lib_tests, **opts) + test_suite.run() + + +@task +@needs( + 'pavelib.prereqs.install_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +@cmdopts([ + ("failed", "f", "Run only failed tests"), + ("fail_fast", "x", "Run only failed tests"), +]) +def test_python(options): + """ + Run all python tests + """ + opts = { + 'failed_only': getattr(options, 'failed', None), + 'fail_fast': getattr(options, 'fail_fast', None), + } + + python_suite = suites.PythonTestSuite('Python Tests', **opts) + python_suite.run() + + +@task +@needs( + 'pavelib.prereqs.install_python_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +def test_i18n(): + """ + Run all i18n tests + """ + i18n_suite = suites.I18nTestSuite('i18n') + i18n_suite.run() + + +@task +@needs( + 'pavelib.prereqs.install_prereqs', + 'pavelib.utils.test.utils.clean_reports_dir', +) +def test(): + """ + Run all tests + """ + # Subsuites to be added to the main suite + python_suite = suites.PythonTestSuite('Python Tests') + i18n_suite = suites.I18nTestSuite('i18n') + js_suite = suites.JsTestSuite('JS Tests', mode='run', with_coverage=True) + + # Main suite to be run + all_unittests_suite = suites.TestSuite('All Tests', subsuites=[i18n_suite, js_suite, python_suite]) + all_unittests_suite.run() + + +@task +@needs('pavelib.prereqs.install_prereqs') +def coverage(): + """ + Build the html, xml, and diff coverage reports + """ + for directory in Env.LIB_TEST_DIRS + ['cms', 'lms']: + report_dir = Env.REPORT_DIR / directory + + if (report_dir / '.coverage').isfile(): + # Generate the coverage.py HTML report + sh("coverage html --rcfile={dir}/.coveragerc".format(dir=directory)) + + # Generate the coverage.py XML report + sh("coverage xml -o {report_dir}/coverage.xml --rcfile={dir}/.coveragerc".format( + report_dir=report_dir, + dir=directory + )) + + # Find all coverage XML files (both Python and JavaScript) + xml_reports = [] + + for filepath in Env.REPORT_DIR.walk(): + if filepath.basename() == 'coverage.xml': + xml_reports.append(filepath) + + if not xml_reports: + err_msg = colorize( + 'red', + "No coverage info found. Run `paver test` before running `paver coverage`.\n" + ) + sys.stderr.write(err_msg) + else: + xml_report_str = ' '.join(xml_reports) + diff_html_path = os.path.join(Env.REPORT_DIR, 'diff_coverage_combined.html') + + # Generate the diff coverage reports (HTML and console) + sh("diff-cover {xml_report_str} --html-report {diff_html_path}".format( + xml_report_str=xml_report_str, diff_html_path=diff_html_path)) + sh("diff-cover {xml_report_str}".format(xml_report_str=xml_report_str)) + print("\n") diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py index 2e4b96e5cd6..4aa9f977d3c 100644 --- a/pavelib/utils/envs.py +++ b/pavelib/utils/envs.py @@ -20,6 +20,39 @@ class Env(object): # Reports Directory REPORT_DIR = REPO_ROOT / 'reports' + # Test Ids Directory + TEST_DIR = REPO_ROOT / ".testids" + + # Files used to run each of the js test suites + # TODO: Store this as a dict. Order seems to matter for some + # reason. See issue TE-415. + JS_TEST_ID_FILES = [ + REPO_ROOT / 'lms/static/js_test.yml', + REPO_ROOT / 'cms/static/js_test.yml', + REPO_ROOT / 'cms/static/js_test_squire.yml', + REPO_ROOT / 'common/lib/xmodule/xmodule/js/js_test.yml', + REPO_ROOT / 'common/static/js_test.yml', + ] + + JS_TEST_ID_KEYS = [ + 'lms', + 'cms', + 'cms-squire', + 'xmodule', + 'common', + ] + + JS_REPORT_DIR = REPORT_DIR / 'javascript' + + # Directories used for common/lib/ tests + LIB_TEST_DIRS = [] + for item in (REPO_ROOT / "common/lib").listdir(): + if (REPO_ROOT / 'common/lib' / item).isdir(): + LIB_TEST_DIRS.append(path("common/lib") / item.basename()) + + # Directory for i18n test reports + I18N_REPORT_DIR = REPORT_DIR / 'i18n' + # Service variant (lms, cms, etc.) configured with an environment variable # We use this to determine which envs.json file to load. SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None) diff --git a/pavelib/utils/test/__init__.py b/pavelib/utils/test/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/pavelib/utils/test/suites/__init__.py b/pavelib/utils/test/suites/__init__.py new file mode 100644 index 00000000000..3aa94bbbfd3 --- /dev/null +++ b/pavelib/utils/test/suites/__init__.py @@ -0,0 +1,8 @@ +""" +TestSuite class and subclasses +""" +from .suite import TestSuite +from .nose_suite import NoseTestSuite, SystemTestSuite, LibTestSuite +from .python_suite import PythonTestSuite +from .js_suite import JsTestSuite +from .i18n_suite import I18nTestSuite diff --git a/pavelib/utils/test/suites/i18n_suite.py b/pavelib/utils/test/suites/i18n_suite.py new file mode 100644 index 00000000000..9c079aad9ba --- /dev/null +++ b/pavelib/utils/test/suites/i18n_suite.py @@ -0,0 +1,40 @@ +""" +Classes used for defining and running i18n test suites +""" +from pavelib.utils.test.suites import TestSuite +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +class I18nTestSuite(TestSuite): + """ + Run tests for the internationalization library + """ + def __init__(self, *args, **kwargs): + super(I18nTestSuite, self).__init__(*args, **kwargs) + self.report_dir = Env.I18N_REPORT_DIR + self.xunit_report = self.report_dir / 'nosetests.xml' + + def __enter__(self): + super(I18nTestSuite, self).__enter__() + self.report_dir.makedirs_p() + + @property + def cmd(self): + pythonpath_prefix = ( + "PYTHONPATH={repo_root}/i18n:$PYTHONPATH".format( + repo_root=Env.REPO_ROOT + ) + ) + + cmd = ( + "{pythonpath_prefix} nosetests {repo_root}/i18n/tests " + "--with-xunit --xunit-file={xunit_report}".format( + pythonpath_prefix=pythonpath_prefix, + repo_root=Env.REPO_ROOT, + xunit_report=self.xunit_report, + ) + ) + + return cmd diff --git a/pavelib/utils/test/suites/js_suite.py b/pavelib/utils/test/suites/js_suite.py new file mode 100644 index 00000000000..2da6ee96759 --- /dev/null +++ b/pavelib/utils/test/suites/js_suite.py @@ -0,0 +1,61 @@ +""" +Javascript test tasks +""" +from pavelib import assets +from pavelib.utils.test import utils as test_utils +from pavelib.utils.test.suites import TestSuite +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +class JsTestSuite(TestSuite): + """ + A class for running JavaScript tests. + """ + def __init__(self, *args, **kwargs): + super(JsTestSuite, self).__init__(*args, **kwargs) + self.run_under_coverage = kwargs.get('with_coverage', True) + self.mode = kwargs.get('mode', 'run') + + try: + self.test_id = (Env.JS_TEST_ID_FILES[Env.JS_TEST_ID_KEYS.index(self.root)]) + except ValueError: + self.test_id = ' '.join(Env.JS_TEST_ID_FILES) + + self.root = self.root + ' javascript' + self.report_dir = Env.JS_REPORT_DIR + self.coverage_report = self.report_dir / 'coverage.xml' + self.xunit_report = self.report_dir / 'javascript_xunit.xml' + + def __enter__(self): + super(JsTestSuite, self).__enter__() + self.report_dir.makedirs_p() + test_utils.clean_test_files() + + if self.mode == 'run' and not self.run_under_coverage: + test_utils.clean_dir(self.report_dir) + + assets.compile_coffeescript("`find lms cms common -type f -name \"*.coffee\"`") + + @property + def cmd(self): + """ + Run the tests using js-test-tool. See js-test-tool docs for + description of different command line arguments. + """ + cmd = ( + "js-test-tool {mode} {test_id} --use-firefox --timeout-sec " + "600 --xunit-report {xunit_report}".format( + mode=self.mode, + test_id=self.test_id, + xunit_report=self.xunit_report, + ) + ) + + if self.run_under_coverage: + cmd += " --coverage-xml {report_dir}".format( + report_dir=self.coverage_report + ) + + return cmd diff --git a/pavelib/utils/test/suites/nose_suite.py b/pavelib/utils/test/suites/nose_suite.py new file mode 100644 index 00000000000..4ea37497dbc --- /dev/null +++ b/pavelib/utils/test/suites/nose_suite.py @@ -0,0 +1,169 @@ +""" +Classes used for defining and running nose test suites +""" +import os +from paver.easy import call_task +from pavelib.utils.test import utils as test_utils +from pavelib.utils.test.suites import TestSuite +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +class NoseTestSuite(TestSuite): + """ + A subclass of TestSuite with extra methods that are specific + to nose tests + """ + def __init__(self, *args, **kwargs): + super(NoseTestSuite, self).__init__(*args, **kwargs) + self.failed_only = kwargs.get('failed_only', False) + self.fail_fast = kwargs.get('fail_fast', False) + self.run_under_coverage = kwargs.get('with_coverage', True) + self.report_dir = Env.REPORT_DIR / self.root + self.test_id_dir = Env.TEST_DIR / self.root + self.test_ids = self.test_id_dir / 'noseids' + + def __enter__(self): + super(NoseTestSuite, self).__enter__() + self.report_dir.makedirs_p() + self.test_id_dir.makedirs_p() + + def __exit__(self, exc_type, exc_value, traceback): + """ + Cleans mongo afer the tests run. + """ + super(NoseTestSuite, self).__exit__(exc_type, exc_value, traceback) + test_utils.clean_mongo() + + def _under_coverage_cmd(self, cmd): + """ + If self.run_under_coverage is True, it returns the arg 'cmd' + altered to be run under coverage. It returns the command + unaltered otherwise. + """ + if self.run_under_coverage: + cmd0, cmd_rest = cmd.split(" ", 1) + # We use "python -m coverage" so that the proper python + # will run the importable coverage rather than the + # coverage that OS path finds. + + cmd = ( + "python -m coverage run --rcfile={root}/.coveragerc " + "`which {cmd0}` {cmd_rest}".format( + root=self.root, + cmd0=cmd0, + cmd_rest=cmd_rest, + ) + ) + + return cmd + + @property + def test_options_flags(self): + """ + Takes the test options and returns the appropriate flags + for the command. + """ + opts = " " + + # Handle "--failed" as a special case: we want to re-run only + # the tests that failed within our Django apps + # This sets the --failed flag for the nosetests command, so this + # functionality is the same as described in the nose documentation + if self.failed_only: + opts += "--failed" + + # This makes it so we use nose's fail-fast feature in two cases. + # Case 1: --fail_fast is passed as an arg in the paver command + # Case 2: The environment variable TESTS_FAIL_FAST is set as True + env_fail_fast_set = ( + 'TESTS_FAIL_FAST' in os.environ and os.environ['TEST_FAIL_FAST'] + ) + + if self.fail_fast or env_fail_fast_set: + opts += " --stop" + + return opts + + +class SystemTestSuite(NoseTestSuite): + """ + TestSuite for lms and cms nosetests + """ + def __init__(self, *args, **kwargs): + super(SystemTestSuite, self).__init__(*args, **kwargs) + self.test_id = kwargs.get('test_id', self._default_test_id) + self.fasttest = kwargs.get('fasttest', False) + + def __enter__(self): + super(SystemTestSuite, self).__enter__() + args = [self.root, '--settings=test'] + + if self.fasttest: + # TODO: Fix the tests so that collectstatic isn't needed ever + # add --skip-collect to this when the tests are fixed + args.append('--skip-collect') + + call_task('pavelib.assets.update_assets', args=args) + + @property + def cmd(self): + cmd = ( + './manage.py {system} test {test_id} {test_opts} ' + '--traceback --settings=test'.format( + system=self.root, + test_id=self.test_id, + test_opts=self.test_options_flags, + ) + ) + + return self._under_coverage_cmd(cmd) + + @property + def _default_test_id(self): + """ + If no test id is provided, we need to limit the test runner + to the Djangoapps we want to test. Otherwise, it will + run tests on all installed packages. We do this by + using a default test id. + """ + # We need to use $DIR/*, rather than just $DIR so that + # django-nose will import them early in the test process, + # thereby making sure that we load any django models that are + # only defined in test files. + default_test_id = "{system}/djangoapps/* common/djangoapps/*".format( + system=self.root + ) + + if self.root in ('lms', 'cms'): + default_test_id += " {system}/lib/*".format(system=self.root) + + if self.root == 'lms': + default_test_id += " {system}/tests.py".format(system=self.root) + + return default_test_id + + +class LibTestSuite(NoseTestSuite): + """ + TestSuite for edx-platform/common/lib nosetests + """ + def __init__(self, *args, **kwargs): + super(LibTestSuite, self).__init__(*args, **kwargs) + self.test_id = kwargs.get('test_id', self.root) + self.xunit_report = self.report_dir / "nosetests.xml" + + @property + def cmd(self): + cmd = ( + "nosetests --id-file={test_ids} {test_id} {test_opts} " + "--with-xunit --xunit-file={xunit_report}".format( + test_ids=self.test_ids, + test_id=self.test_id, + test_opts=self.test_options_flags, + xunit_report=self.xunit_report, + ) + ) + + return self._under_coverage_cmd(cmd) diff --git a/pavelib/utils/test/suites/python_suite.py b/pavelib/utils/test/suites/python_suite.py new file mode 100644 index 00000000000..ba2d845d667 --- /dev/null +++ b/pavelib/utils/test/suites/python_suite.py @@ -0,0 +1,48 @@ +""" +Classes used for defining and running python test suites +""" +from pavelib.utils.test import utils as test_utils +from pavelib.utils.test.suites import TestSuite, LibTestSuite, SystemTestSuite +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +class PythonTestSuite(TestSuite): + """ + A subclass of TestSuite with extra setup for python tests + """ + def __init__(self, *args, **kwargs): + super(PythonTestSuite, self).__init__(*args, **kwargs) + self.fasttest = kwargs.get('fasttest', False) + self.failed_only = kwargs.get('failed_only', None) + self.fail_fast = kwargs.get('fail_fast', None) + self.subsuites = kwargs.get('subsuites', self._default_subsuites) + + def __enter__(self): + super(PythonTestSuite, self).__enter__() + if not self.fasttest: + test_utils.clean_test_files() + + @property + def _default_subsuites(self): + """ + The default subsuites to be run. They include lms, cms, + and all of the libraries in common/lib. + """ + opts = { + 'failed_only': self.failed_only, + 'fail_fast': self.fail_fast, + 'fasttest': self.fasttest, + } + + lib_suites = [ + LibTestSuite(d, **opts) for d in Env.LIB_TEST_DIRS + ] + + system_suites = [ + SystemTestSuite('cms', **opts), + SystemTestSuite('lms', **opts), + ] + + return system_suites + lib_suites diff --git a/pavelib/utils/test/suites/suite.py b/pavelib/utils/test/suites/suite.py new file mode 100644 index 00000000000..96409cb76fc --- /dev/null +++ b/pavelib/utils/test/suites/suite.py @@ -0,0 +1,123 @@ +""" +A class used for defining and running test suites +""" +import sys +import subprocess +from pavelib.utils.process import kill_process + +try: + from pygments.console import colorize +except ImportError: + colorize = lambda color, text: text # pylint: disable-msg=invalid-name + +__test__ = False # do not collect + + +class TestSuite(object): + """ + TestSuite is a class that defines how groups of tests run. + """ + def __init__(self, *args, **kwargs): + self.root = args[0] + self.subsuites = kwargs.get('subsuites', []) + self.failed_suites = [] + + def __enter__(self): + """ + This will run before the test suite is run with the run_suite_tests method. + If self.run_test is called directly, it should be run in a 'with' block to + ensure that the proper context is created. + + Specific setup tasks should be defined in each subsuite. + + i.e. Checking for and defining required directories. + """ + print("\nSetting up for {suite_name}".format(suite_name=self.root)) + self.failed_suites = [] + + def __exit__(self, exc_type, exc_value, traceback): + """ + This is run after the tests run with the run_suite_tests method finish. + Specific clean up tasks should be defined in each subsuite. + + If self.run_test is called directly, it should be run in a 'with' block + to ensure that clean up happens properly. + + i.e. Cleaning mongo after the lms tests run. + """ + print("\nCleaning up after {suite_name}".format(suite_name=self.root)) + + @property + def cmd(self): + """ + The command to run tests (as a string). For this base class there is none. + """ + return None + + def run_test(self): + """ + Runs a self.cmd in a subprocess and waits for it to finish. + It returns False if errors or failures occur. Otherwise, it + returns True. + """ + cmd = self.cmd + sys.stdout.write(cmd) + + msg = colorize( + 'green', + '\n{bar}\n Running tests for {suite_name} \n{bar}\n'.format(suite_name=self.root, bar='=' * 40), + ) + + sys.stdout.write(msg) + sys.stdout.flush() + + kwargs = {'shell': True, 'cwd': None} + process = None + + try: + process = subprocess.Popen(cmd, **kwargs) + process.communicate() + except KeyboardInterrupt: + kill_process(process) + sys.exit(1) + else: + return (process.returncode == 0) + + def run_suite_tests(self): + """ + Runs each of the suites in self.subsuites while tracking failures + """ + # Uses __enter__ and __exit__ for context + with self: + # run the tests for this class, and for all subsuites + if self.cmd: + passed = self.run_test() + if not passed: + self.failed_suites.append(self) + + for suite in self.subsuites: + suite.run_suite_tests() + if len(suite.failed_suites) > 0: + self.failed_suites.extend(suite.failed_suites) + + def report_test_results(self): + """ + Writes a list of failed_suites to sys.stderr + """ + if len(self.failed_suites) > 0: + msg = colorize('red', "\n\n{bar}\nTests failed in the following suites:\n* ".format(bar="=" * 48)) + msg += colorize('red', '\n* '.join([s.root for s in self.failed_suites]) + '\n\n') + else: + msg = colorize('green', "\n\n{bar}\nNo test failures ".format(bar="=" * 48)) + + print(msg) + + def run(self): + """ + Runs the tests in the suite while tracking and reporting failures. + """ + self.run_suite_tests() + self.report_test_results() + + if len(self.failed_suites) > 0: + sys.exit(1) diff --git a/pavelib/utils/test/utils.py b/pavelib/utils/test/utils.py new file mode 100644 index 00000000000..82ec70915af --- /dev/null +++ b/pavelib/utils/test/utils.py @@ -0,0 +1,45 @@ +""" +Helper functions for test tasks +""" +from paver.easy import sh, task +from pavelib.utils.envs import Env + +__test__ = False # do not collect + + +@task +def clean_test_files(): + """ + Clean fixture files used by tests and .pyc files + """ + sh("git clean -fqdx test_root/logs test_root/data test_root/staticfiles test_root/uploads") + sh("find . -type f -name \"*.pyc\" -delete") + sh("rm -rf test_root/log/auto_screenshots/*") + + +def clean_dir(directory): + """ + Clean coverage files, to ensure that we don't use stale data to generate reports. + """ + # We delete the files but preserve the directory structure + # so that coverage.py has a place to put the reports. + sh('find {dir} -type f -delete'.format(dir=directory)) + + +@task +def clean_reports_dir(): + """ + Clean coverage files, to ensure that we don't use stale data to generate reports. + """ + # We delete the files but preserve the directory structure + # so that coverage.py has a place to put the reports. + reports_dir = Env.REPORT_DIR.makedirs_p() + clean_dir(reports_dir) + + +@task +def clean_mongo(): + """ + Clean mongo test databases + """ + sh("mongo {repo_root}/scripts/delete-mongo-test-dbs.js".format(repo_root=Env.REPO_ROOT)) diff --git a/rakelib/i18n.rake b/rakelib/i18n.rake index 6f2241de18e..a29439e5209 100644 --- a/rakelib/i18n.rake +++ b/rakelib/i18n.rake @@ -73,12 +73,6 @@ namespace :i18n do end end - desc "Run tests for the internationalization library" - task :test => [:install_python_prereqs, I18N_REPORT_DIR, :clean_reports_dir] do - pythonpath_prefix = "PYTHONPATH=#{REPO_ROOT}/i18n:$PYTHONPATH" - test_sh("i18n", "#{pythonpath_prefix} nosetests #{REPO_ROOT}/i18n/tests --with-xunit --xunit-file=#{I18N_XUNIT_REPORT}") - end - # Commands for automating the process of including translations in edx-platform. # Will eventually be run by jenkins. namespace :robot do @@ -95,7 +89,3 @@ namespace :i18n do end end - - -# Add i18n tests to the main test command -task :test => :'i18n:test' diff --git a/rakelib/js_test.rake b/rakelib/js_test.rake deleted file mode 100644 index 3e15d43e8c6..00000000000 --- a/rakelib/js_test.rake +++ /dev/null @@ -1,97 +0,0 @@ -JS_TEST_SUITES = { - 'lms' => 'lms/static/js_test.yml', - 'cms' => 'cms/static/js_test.yml', - 'cms-squire' => 'cms/static/js_test_squire.yml', - 'xmodule' => 'common/lib/xmodule/xmodule/js/js_test.yml', - 'common' => 'common/static/js_test.yml', -} - -# Turn relative paths to absolute paths from the repo root. -JS_TEST_SUITES.each do |key, val| - JS_TEST_SUITES[key] = File.join(REPO_ROOT, val) -end - -# Define the directory for coverage reports -JS_REPORT_DIR = report_dir_path('javascript') -directory JS_REPORT_DIR - -# Given an environment (a key in `JS_TEST_SUITES`) -# return the path to the JavaScript test suite description -# If `env` is nil, return a string containing -# all available descriptions. -def suite_for_env(env) - if env.nil? - return JS_TEST_SUITES.map{|key, val| val}.join(' ') - else - return JS_TEST_SUITES[env] - end -end - -# Run the tests using js-test-tool -# See js-test-tool docs for description of different -# command line arguments -def js_test_tool(env, command, do_coverage) - suite = suite_for_env(env) - xunit_report = File.join(JS_REPORT_DIR, 'javascript_xunit.xml') - cmd = "js-test-tool #{command} #{suite} --use-firefox --timeout-sec 600 --xunit-report #{xunit_report}" - - if do_coverage - report_dir = File.join(JS_REPORT_DIR, 'coverage.xml') - cmd += " --coverage-xml #{report_dir}" - end - - test_sh("javascript", cmd) -end - -# Print a list of js_test commands for -# all available environments -def print_js_test_cmds(mode) - JS_TEST_SUITES.each do |key, val| - puts " rake test:js:#{mode}[#{key}]" - end -end - -# Paver migration hack: because the CoffeeScript-specific asset command has been deprecated, -# we compile CoffeeScript ourselves -def compile_coffeescript() - sh("node_modules/.bin/coffee --compile `find lms cms common -type f -name \"*.coffee\"`") -end - -namespace :'test:js' do - - desc "Run the JavaScript tests and print results to the console" - task :run, [:env] => [:clean_test_files, JS_REPORT_DIR, :install_node_prereqs] do |t, args| - compile_coffeescript() - - if args[:env].nil? - puts "Running all test suites. To run a specific test suite, try:" - print_js_test_cmds('run') - end - js_test_tool(args[:env], 'run', false) - end - - desc "Run the JavaScript tests in your default browser" - task :dev, [:env] => [:clean_test_files, :install_node_prereqs] do |t, args| - compile_coffeescript() - - if args[:env].nil? - puts "Error: No test suite specified. Try one of these instead:" - print_js_test_cmds('dev') - else - js_test_tool(args[:env], 'dev', false) - end - end - - desc "Run all JavaScript tests and collect coverage information" - task :coverage => [:clean_reports_dir, :clean_test_files, JS_REPORT_DIR, :install_node_prereqs] do - compile_coffeescript() - js_test_tool(nil, 'run', true) - end -end - -# Default js_test is js_test:run -desc "Run all JavaScript tests and print results the the console" -task :'test:js' => :'test:js:run' - -# Add the JS tests to the main test command -task :test => :'test:js:coverage' diff --git a/rakelib/js_test_deprecated.rake b/rakelib/js_test_deprecated.rake new file mode 100644 index 00000000000..eb136d35b5e --- /dev/null +++ b/rakelib/js_test_deprecated.rake @@ -0,0 +1,26 @@ +# js_test tasks deprecated to paver + +require 'colorize' + +def deprecated(deprecated, deprecated_by, *args) + + task deprecated, [:env] do |t,args| + + args.with_defaults(:env => nil) + + new_cmd = "#{deprecated_by}" + + if !args.env.nil? + new_cmd = "#{new_cmd} --suite=#{args.env}" + end + + puts("Task #{deprecated} has been deprecated. Using #{new_cmd} instead.".red) + sh(new_cmd) + end +end + +# deprecates all js_test.rake tasks +deprecated('test:js', 'paver test_js') +deprecated('test:js:coverage', 'paver test_js -c') +deprecated('test:js:dev', 'paver test_js_dev') +deprecated('test:js:run', 'paver test_js_run') diff --git a/rakelib/tests.rake b/rakelib/tests.rake deleted file mode 100644 index 49aabcaea0c..00000000000 --- a/rakelib/tests.rake +++ /dev/null @@ -1,192 +0,0 @@ -# Set up the clean and clobber tasks -CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') - -# Create the directory to hold coverage reports, if it doesn't already exist. -directory REPORT_DIR - -def test_id_dir(path) - return File.join(".testids", path.to_s) -end - -def run_under_coverage(cmd, root) - cmd0, cmd_rest = cmd.split(" ", 2) - # We use "python -m coverage" so that the proper python will run the importable coverage - # rather than the coverage that OS path finds. - cmd = "python -m coverage run --rcfile=#{root}/.coveragerc `which #{cmd0}` #{cmd_rest}" - return cmd -end - -def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) - - # If no test id is provided, we need to limit the test runner - # to the Djangoapps we want to test. Otherwise, it will - # run tests on all installed packages. - - # We need to use $DIR/*, rather than just $DIR so that - # django-nose will import them early in the test process, - # thereby making sure that we load any django models that are - # only defined in test files. - - default_test_id = "#{system}/djangoapps/* common/djangoapps/*" - - if system == :lms || system == :cms - default_test_id += " #{system}/lib/*" - end - - if system == :lms - default_test_id += " #{system}/tests.py" - end - - if test_id.nil? - test_id = default_test_id - - # Handle "--failed" as a special case: we want to re-run only - # the tests that failed within our Django apps - elsif test_id == '--failed' - test_id = "#{default_test_id} --failed" - end - - cmd = django_admin(system, :test, 'test', test_id) - test_sh(system, run_under_coverage(cmd, system)) -end - -task :clean_test_files do - desc "Clean fixture files used by tests, .pyc files, and automatic screenshots" - sh("git clean -fqdx test_root/logs test_root/data test_root/staticfiles test_root/uploads") - sh("find . -type f -name \"*.pyc\" -delete") - sh("rm -rf test_root/log/auto_screenshots/*") -end - -task :clean_reports_dir => REPORT_DIR do - desc "Clean coverage files, to ensure that we don't use stale data to generate reports." - - # We delete the files but preserve the directory structure - # so that coverage.py has a place to put the reports. - sh("find #{REPORT_DIR} -type f -delete") -end - -TEST_TASK_DIRS = [] - -[:lms, :cms].each do |system| - report_dir = report_dir_path(system) - test_id_dir = test_id_dir(system) - - directory test_id_dir - - # Per System tasks/ - desc "Run all django tests on our djangoapps for the #{system}" - task "test_#{system}", [:test_id] => [ - :clean_test_files, :install_prereqs, - "#{system}:gather_assets:test", "fasttest_#{system}" - ] - - # Have a way to run the tests without running collectstatic -- useful when debugging without - # messing with static files. - task "fasttest_#{system}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir] do |t, args| - args.with_defaults(:test_id => nil) - - - - begin - run_tests(system, report_dir, args.test_id) - ensure - Rake::Task[:'test:clean_mongo'].reenable - Rake::Task[:'test:clean_mongo'].invoke - end - end - - task :fasttest => "fasttest_#{system}" - - TEST_TASK_DIRS << system -end - -Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| - - report_dir = report_dir_path(lib) - test_id_dir = test_id_dir(lib) - test_ids = File.join(test_id_dir(lib), '.noseids') - - directory test_id_dir - - desc "Run tests for common lib #{lib}" - task "test_#{lib}", [:test_id] => [ - test_id_dir, report_dir, :clean_test_files, :clean_reports_dir, :install_prereqs - ] do |t, args| - - args.with_defaults(:test_id => lib) - ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - cmd = "nosetests --id-file=#{test_ids} #{args.test_id}" - begin - test_sh(lib, run_under_coverage(cmd, lib)) - ensure - Rake::Task[:'test:clean_mongo'].reenable - Rake::Task[:'test:clean_mongo'].invoke - end - end - TEST_TASK_DIRS << lib - - # There used to be a fasttest_#{lib} command that ran without coverage. - # However, this is an inconsistent usage of "fast": - # When running tests for lms and cms, "fast" means skipping - # staticfiles collection, but still running under coverage. - # We keep the fasttest_#{lib} command for backwards compatibility, - # but make it an alias to the normal test command. - task "fasttest_#{lib}" => "test_#{lib}" -end - -task :report_dirs - -TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - directory report_dir - task :report_dirs => [REPORT_DIR, report_dir] - task 'test:python' => "test_#{dir}" -end - -namespace :test do - desc "Run all python tests" - task :python, [:test_id] - - desc "Drop Mongo databases created by the test suite" - task :clean_mongo do - sh("mongo #{REPO_ROOT}/scripts/delete-mongo-test-dbs.js") - end -end - -desc "Build the html, xml, and diff coverage reports" -task :coverage => :report_dirs do - - # Generate coverage for Python sources - TEST_TASK_DIRS.each do |dir| - report_dir = report_dir_path(dir) - - if File.file?("#{report_dir}/.coverage") - - # Generate the coverage.py HTML report - sh("coverage html --rcfile=#{dir}/.coveragerc") - - # Generate the coverage.py XML report - sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") - - end - end - - # Find all coverage XML files (both Python and JavaScript) - xml_reports = FileList[File.join(REPORT_DIR, '**/coverage.xml')] - - if xml_reports.length < 1 - puts "No coverage info found. Run `rake test` before running `rake coverage`." - else - xml_report_str = xml_reports.join(' ') - diff_html_path = report_dir_path('diff_coverage_combined.html') - - # Generate the diff coverage reports (HTML and console) - sh("diff-cover #{xml_report_str} --html-report #{diff_html_path}") - sh("diff-cover #{xml_report_str}") - puts "\n" - end -end - -# Other Rake files append additional tests to the main test command. -desc "Run all unit tests" -task :test, [:test_id] => 'test:python' diff --git a/rakelib/tests_deprecated.rake b/rakelib/tests_deprecated.rake new file mode 100644 index 00000000000..36ee24ab7d6 --- /dev/null +++ b/rakelib/tests_deprecated.rake @@ -0,0 +1,45 @@ +# test tasks deprecated to paver + +require 'colorize' + +def deprecated(deprecated, deprecated_by, use_id, *args) + + task deprecated, [:test_id] do |t,args| + + args.with_defaults(:test_id => nil) + + new_cmd = "#{deprecated_by}" + + if !args.test_id.nil? && use_id + new_cmd = "#{new_cmd} --test_id=#{args.test_id}" + end + + puts("Task #{deprecated} has been deprecated. Using #{new_cmd} instead.".red) + sh(new_cmd) + end +end + +# deprecates all test.rake tasks +deprecated("test", "paver test", false) +deprecated('test:python', 'paver test_python', false) + +deprecated("test_cms", "paver test_system -s cms", true) +deprecated("test_lms", "paver test_system -s lms", true) +deprecated("fasttest_cms", "paver test_system -s cms --fasttest", true) +deprecated("fasttest_lms", "paver test_system -s lms --fasttest", true) + +Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| + + deprecated("test_#{lib}", "paver test_lib --lib=#{lib}", true) + deprecated("fasttest_#{lib}", "paver test_lib --lib=#{lib}", true) + +end + +deprecated("coverage", "paver coverage", false) + +# deprecates i18n:test from i18n.rake +deprecated("i18n:test", 'paver test_i18n', false) + +deprecated("clean_reports_dir", "paver clean_reports_dir", false) +deprecated("clean_test_files", "paver clean_test_files", false) +deprecated("test:clean_mongo", "paver clean_mongo", false) -- GitLab