invocation: support loading pytest module for pytest_name='x.y.z'
To make the naming of pytest more flexible, now LoadPytestModule
supports pytest_name containing dots. If pytest_name does not contain
any dot, will falls back to old implementation.
For example, we currently have a lot of ec_xxx tests, they can now become
ec/battery.py (pytest_name='ec.battery')
ec/lightbar.py (pytest_name='ec.lightbar')
etc...
BUG=None
TEST=test on DUT w/ old test list, run_pytest w/ and w/o goofy, make test
Change-Id: I3bc14cd8ea244d9febeaca8c4e8c6d7612984b8b
Reviewed-on: https://chromium-review.googlesource.com/320528
Commit-Ready: Wei-Han Chen <stimim@chromium.org>
Tested-by: Wei-Han Chen <stimim@chromium.org>
Reviewed-by: Hung-Te Lin <hungte@chromium.org>
diff --git a/doc/test_api.rst b/doc/test_api.rst
index 286b188..6c7dff1 100644
--- a/doc/test_api.rst
+++ b/doc/test_api.rst
@@ -36,10 +36,26 @@
JavaScript files), you may create a directory for it and put the code
in
- :samp:`py/test/pytests/{name}/{name}.py`.
+ :samp:`py/test/pytests/{name}/{name}.py`. [*]_
Within this file, there should be a single subclass of `unittest.TestCase`.
-There can be only one test class per module.
+There can be only one test class per module, so if you need to have different
+test cases, you need to separate them into different modules. But you can create
+a directory for them, for example:
+
+ - :samp:`py/test/pytests/{dir_name}/{test1}.py`
+ - :samp:`py/test/pytests/{dir_name}/{test2}.py`
+ - :samp:`py/test/pytests/{dir_name}/{test3}.py`.
+
+In this case, the name of your tests are :samp:`{dir_name}.{test1}`,
+:samp:`{dir_name}.{test2}` and :samp:`{dir_name}.{test3}`.
+
+To know more about how we load a pytest from name, please refer to
+:py:func:`cros.factory.test.utils.pytest_utils.LoadPytestModule`.
+
+.. [*] Since the ``__init__.py`` of a package will be loaded whenever its
+ submodule is imported. We recommand keeping ``__init__.py`` empty to
+ prevent longer loading time if ``__init__.py`` is too large.
Test implementation
-------------------
@@ -144,3 +160,7 @@
def runTest(self):
logging.info('path=%s, max_bytes=%d',
self.args.path, self.args.max_bytes)
+
+.. py:module:: cros.factory.test.utils.pytest_utils
+
+.. autofunction:: LoadPytestModule
diff --git a/py/goofy/invocation.py b/py/goofy/invocation.py
index b10b186..9ebd785 100755
--- a/py/goofy/invocation.py
+++ b/py/goofy/invocation.py
@@ -43,6 +43,7 @@
from cros.factory.test.test_lists.test_lists import BuildAllTestLists
from cros.factory.test.test_lists.test_lists import OldStyleTestList
from cros.factory.test.utils.service_manager import ServiceManager
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
from cros.factory.utils import file_utils
from cros.factory.utils import process_utils
from cros.factory.utils.string_utils import DecodeUTF8
@@ -816,43 +817,6 @@
return results[0]
-def LoadPytestModule(pytest_name):
- """Loads the given pytest module.
-
- This function tries to load the module with
-
- cros.factory.test.pytests.<pytest_base_name>.<pytest_name>
-
- first and falls back to
-
- cros.factory.test.pytests.<pytest_name>
-
- for backward compatibility.
-
- Args:
- pytest_name: The name of the pytest module.
-
- Returns:
- The loaded pytest module object.
- """
- from cros.factory.test import pytests
- base_pytest_name = pytest_name
- for suffix in ('_e2etest', '_automator', '_automator_private'):
- base_pytest_name = re.sub(suffix, '', base_pytest_name)
-
- try:
- __import__('cros.factory.test.pytests.%s.%s' %
- (base_pytest_name, pytest_name))
- return getattr(getattr(pytests, base_pytest_name), pytest_name)
- except ImportError:
- logging.info(
- ('Cannot import cros.factory.test.pytests.%s.%s. '
- 'Fall back to cros.factory.test.pytests.%s'),
- base_pytest_name, pytest_name, pytest_name)
- __import__('cros.factory.test.pytests.%s' % pytest_name)
- return getattr(pytests, pytest_name)
-
-
def RunPytest(test_info):
"""Runs a pytest, saving a pickled (status, error_msg) tuple to the
appropriate results file.
diff --git a/py/test/run_pytest.py b/py/test/run_pytest.py
index 3aebc3b..0949b81 100755
--- a/py/test/run_pytest.py
+++ b/py/test/run_pytest.py
@@ -16,58 +16,20 @@
import ast
import inspect
import logging
+import os
import pickle
-import re
import sys
import unittest
import factory_common # pylint: disable=W0611
-from cros.factory.test import dut
from cros.factory.test.args import Args
from cros.factory.test.dut import utils
-
-
-# Copied from goofy/invocation.py to minimize dependencies.
-def _LoadPytestModule(pytest_name):
- """Loads the given pytest module.
-
- This function tries to load the module with
-
- cros.factory.test.pytests.<pytest_base_name>.<pytest_name>
-
- first and falls back to
-
- cros.factory.test.pytests.<pytest_name>
-
- for backward compatibility.
-
- Args:
- pytest_name: The name of the pytest module.
-
- Returns:
- The loaded pytest module object.
- """
- from cros.factory.test import pytests
- base_pytest_name = pytest_name
- for suffix in ('_e2etest', '_automator', '_automator_private'):
- base_pytest_name = re.sub(suffix, '', base_pytest_name)
-
- try:
- __import__('cros.factory.test.pytests.%s.%s' %
- (base_pytest_name, pytest_name))
- return getattr(getattr(pytests, base_pytest_name), pytest_name)
- except ImportError:
- logging.info(
- ('Cannot import cros.factory.test.pytests.%s.%s. '
- 'Fall back to cros.factory.test.pytests.%s'),
- base_pytest_name, pytest_name, pytest_name)
- __import__('cros.factory.test.pytests.%s' % pytest_name)
- return getattr(pytests, pytest_name)
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
def _GetTestCase(pytest):
"""Returns the first test case class found in a given pytest."""
- module = _LoadPytestModule(pytest)
+ module = LoadPytestModule(pytest)
_, test_case = inspect.getmembers(module, lambda obj: (
inspect.isclass(obj) and issubclass(obj, unittest.TestCase)))[0]
return test_case
diff --git a/py/test/utils/pytest_utils.py b/py/test/utils/pytest_utils.py
new file mode 100644
index 0000000..4a82aca
--- /dev/null
+++ b/py/test/utils/pytest_utils.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import logging
+import os
+
+import factory_common # pylint: disable=W0611
+
+
+def LoadPytestModule(pytest_name):
+ """Loads the given pytest module.
+
+ This function tries to load the module
+
+ :samp:`cros.factory.test.pytests.{pytest_name}`.
+
+ For backward compatibility, this function also tries to load
+
+ :samp:`cros.factory.test.pytests.{pytest_base_name}.{pytest_name}`
+
+ if either
+
+ - :samp:`cros.factory.test.pytests.{pytest_name}` doesn't exist
+ - :samp:`cros.factory.test.pytests.{pytest_name}` is a package.
+
+ If both :samp:`{pytest_name}` and :samp:`{pytest_base_name}.{pytest_name}`
+ exist, :samp:`{pytest_base_name}.{pytest_name}` is returned.
+
+ Examples:
+
+ ============= ================== ==================
+ pytest_name what will be searched (in order)
+ ============= ======================================
+ x x x.x [1]_
+ x_automator x_automator x.x_automator [1]_
+ x_e2etest x_e2etest x.x_e2etest [1]_
+ x.y.z x.y.z
+ ============= ================== ==================
+
+ .. [1] this is for backward compatibility, will be deprecated, use ``x.y.z``
+ instead.
+
+ Args:
+ pytest_name: The name of the pytest module.
+
+ Returns:
+ The loaded pytest module object.
+ """
+
+ from cros.factory.test import pytests
+
+ if '.' in pytest_name:
+ __import__('cros.factory.test.pytests.%s' % pytest_name)
+ return reduce(getattr, pytest_name.split('.'), pytests)
+ else:
+
+ try:
+ __import__('cros.factory.test.pytests.%s' % pytest_name)
+ module = getattr(pytests, pytest_name)
+
+ if not os.path.basename(module.__file__).startswith('__init__.py'):
+ # <pytest_name> is not a package.
+ return module
+ except ImportError:
+ pass
+
+ # Cannot find <pytest_name> or <pytest_name> is a package,
+ # fallback to <pytest_base_name>.<pytest_name>.
+ pytest_base_name = pytest_name
+ for suffix in ('_e2etest', '_automator', '_automator_private'):
+ if pytest_base_name.endswith(suffix):
+ pytest_base_name = pytest_base_name[:-len(suffix)]
+
+ try:
+ __import__('cros.factory.test.pytests.%s.%s' %
+ (pytest_base_name, pytest_name))
+ logging.warn('recommend to use pytest_name=%r instead of pytest_name=%r',
+ ('%s.%s' % (pytest_base_name, pytest_name)), pytest_name)
+ return getattr(getattr(pytests, pytest_base_name), pytest_name)
+ except ImportError:
+ logging.error('cannot find any pytest module named %s or %s.%s',
+ pytest_name, pytest_base_name, pytest_name)
+ raise
diff --git a/py/test/utils/pytest_utils_unittest.py b/py/test/utils/pytest_utils_unittest.py
new file mode 100755
index 0000000..df96b22
--- /dev/null
+++ b/py/test/utils/pytest_utils_unittest.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python
+# Copyright 2016 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import factory_common # pylint: disable=W0611
+
+from cros.factory.test import pytests
+from cros.factory.test.utils.pytest_utils import LoadPytestModule
+from cros.factory.utils import file_utils
+
+
+class LoadPytestModuleTest(unittest.TestCase):
+
+ @staticmethod
+ def CreateScript(tmpdir, script_path):
+ """Create an python script under `tmpdir` with path=`script_path`.
+
+ The created script file will be empty, the `__init__.py`s will also be
+ created to make this script file importable.
+
+ Args:
+ tmpdir: the root directory.
+ script_path: path of script file relative to `tmpdir`.
+ """
+
+ # make sure there is no slash in the beginning.
+ if script_path.startswith('/'):
+ script_path = script_path.lstrip('/')
+
+ # create directories
+ if os.path.dirname(script_path):
+ os.makedirs(os.path.dirname(os.path.join(tmpdir, script_path)))
+
+ dirs = os.path.dirname(script_path).split('/')
+ for idx in xrange(len(dirs) + 1):
+ file_utils.TouchFile(
+ os.path.join(os.path.join(tmpdir, *dirs[:idx]), '__init__.py'))
+
+ # create python script
+ file_utils.TouchFile(os.path.join(tmpdir, script_path))
+
+
+ def setUp(self):
+ self.pytests_root = os.path.dirname(pytests.__file__)
+ self.tmpdir = tempfile.mkdtemp(dir=self.pytests_root)
+
+ def tearDown(self):
+ shutil.rmtree(self.tmpdir)
+
+ def testLoadX(self):
+ with file_utils.UnopenedTemporaryFile(
+ suffix='.py', dir=self.pytests_root) as script_file:
+ (pytest_name, _) = os.path.splitext(os.path.basename(script_file))
+ module = LoadPytestModule(pytest_name)
+ self.assertEquals(module.__file__, script_file)
+ # remove tmpXXXXXX.pyc
+ os.unlink(script_file + 'c')
+
+ def testLoadXYZ(self):
+ LoadPytestModuleTest.CreateScript(self.tmpdir, 'x/y/z.py')
+
+ basename = os.path.basename(self.tmpdir)
+ pytest_name = basename + '.x.y.z'
+ module = LoadPytestModule(pytest_name)
+ self.assertEquals(module.__file__, os.path.join(self.tmpdir, 'x/y/z.py'))
+
+ def testBackwardCompatibility(self):
+ basename = os.path.basename(self.tmpdir)
+
+ for suffix in ['', '_automator', '_e2etest', '_automator_private']:
+ LoadPytestModuleTest.CreateScript(
+ self.tmpdir, basename + suffix + '.py')
+
+ for suffix in ['', '_automator', '_e2etest', '_automator_private']:
+ module = LoadPytestModule(basename + suffix)
+ self.assertEquals(module.__file__,
+ os.path.join(self.tmpdir, basename + suffix + '.py'))
+
+
+if __name__ == '__main__':
+ unittest.main()