Repository: avocado-framework/avocado

Issue:
nrunner: avocado-runner-python-unittest fails to execute test
```python
#!/usr/bin/env python3
# import unittest
# or
from avocado import Test

class First(Test):
    def test_pass(self):
        pass


if __name__ == "__main__":
    unittest.main()
```
```
$ avocado-runner-python-unittest runnable-run -u /tmp/foo.py:First.test_pass 
{'status': 'started', 'time': 184728.461515632}
Process Process-1:
Traceback (most recent call last):
  File "/usr/lib64/python3.7/multiprocessing/process.py", line 297, in _bootstrap
    self.run()
  File "/usr/lib64/python3.7/multiprocessing/process.py", line 99, in run
    self._target(*self._args, **self._kwargs)
  File "/home/local/src/beraldo/avocado/avocado/core/nrunner.py", line 578, in _run_unittest
    suite = unittest.TestLoader().loadTestsFromName(unittest_name)
  File "/usr/lib64/python3.7/unittest/loader.py", line 154, in loadTestsFromName
    module = __import__(module_name)
ValueError: Empty module name
{'status': 'running', 'time': 184728.471846791}
{'status': 'running', 'time': 184728.978320814}
....
```


Discussion:

Resolving patch:
diff --git a/avocado/core/nrunner.py b/avocado/core/nrunner.py
--- a/avocado/core/nrunner.py
+++ b/avocado/core/nrunner.py
@@ -15,7 +15,7 @@
 import sys
 import tempfile
 import time
-import unittest
+from unittest import TestLoader, TextTestRunner
 from uuid import uuid1
 
 try:
@@ -570,13 +570,15 @@ class PythonUnittestRunner(BaseRunner):
 
      * kwargs: not used
     """
-    @staticmethod
-    def _uri_to_unittest_name(uri):
+
+    @property
+    def unittest(self):
+        uri = self.runnable.uri
         if ':' in uri:
             module, class_method = uri.rsplit(':', 1)
         else:
-            module = uri
-            class_method = None
+            return None
+
         if module.endswith('.py'):
             module = module[:-3]
         if module.startswith(os.path.curdir):
@@ -584,17 +586,61 @@ def _uri_to_unittest_name(uri):
             if module.startswith(os.path.sep):
                 module = module[1:]
         module = module.replace(os.path.sep, ".")
-        if class_method:
-            return '%s.%s' % (module, class_method)
-        return module
+        klass, method = class_method.rsplit('.', maxsplit=1)
+        return module, klass, method
+
+    @property
+    def unittest_name(self):
+        """Convert a test reference (uri) to an unittest name reference."""
+        unittest = self.unittest
+        if not unittest:
+            return None
+        return "{}.{}.{}".format(*unittest)
+
+    @property
+    def module_path(self):
+        unittest = self.unittest
+        if not unittest:
+            return None
+        module, _, _ = unittest
+        path = module.replace('.', os.path.sep)
+        # This will fix adding support to hidden directories
+        path = path.replace('//', '/.')
+        return os.path.dirname(path)
+
+    @property
+    def module_class_method(self):
+        """Return a dotted name with module + class + method.
+
+        Important to note here that module is only the module file without the
+        full path.
+        """
+        unittest = self.unittest
+        if not unittest:
+            return None
+
+        full_module, klass, method = unittest
+        # Lets split the full_module and get the module_path + module it self
+        # We care here only about the module (without the path)
+        _, module = full_module.rsplit(".", maxsplit=1)
+        return ".".join((module, klass, method))
 
     @classmethod
-    def _run_unittest(cls, uri, queue):
-        sys.path.insert(0, ".")
+    def _run_unittest(cls, module_path, module_class_method, queue):
+        sys.path.insert(0, module_path)
         stream = io.StringIO()
-        unittest_name = cls._uri_to_unittest_name(uri)
-        suite = unittest.TestLoader().loadTestsFromName(unittest_name)
-        runner = unittest.TextTestRunner(stream=stream, verbosity=0)
+
+        try:
+            loader = TestLoader()
+            suite = loader.loadTestsFromName(module_class_method)
+        except ValueError as ex:
+            msg = "loadTestsFromName error finding unittest {}: {}"
+            queue.put({'status': 'finished',
+                       'result': 'error',
+                       'output': msg.format(module_class_method, str(ex))})
+            return
+
+        runner = TextTestRunner(stream=stream, verbosity=0)
         unittest_result = runner.run(suite)
 
         if len(unittest_result.errors) > 0:
@@ -614,15 +660,18 @@ def _run_unittest(cls, uri, queue):
         queue.put(output)
 
     def run(self):
-        if not self.runnable.uri:
-            error_msg = 'uri is required but was not given'
+        if not self.unittest_name:
+            error_msg = ("Invalid URI: could not be converted to an unittest "
+                         "dotted name.")
             yield self.prepare_status('finished', {'result': 'error',
                                                    'output': error_msg})
             return
 
         queue = multiprocessing.SimpleQueue()
         process = multiprocessing.Process(target=self._run_unittest,
-                                          args=(self.runnable.uri, queue))
+                                          args=(self.module_path,
+                                                self.module_class_method,
+                                                queue))
         process.start()
         yield self.prepare_status('started')
 


Test patch:
diff --git a/selftests/unit/test_nrunner.py b/selftests/unit/test_nrunner.py
--- a/selftests/unit/test_nrunner.py
+++ b/selftests/unit/test_nrunner.py
@@ -250,75 +250,25 @@ def test_runner_exec_test_fail(self):
         self.assertEqual(last_result['returncode'], 1)
         self.assertIn('time', last_result)
 
-    def test_runner_python_unittest_ok(self):
-        runnable = nrunner.Runnable('python-unittest', 'unittest.TestCase')
-        runner_klass = runnable.pick_runner_class()
-        runner = runner_klass(runnable)
-        results = [status for status in runner.run()]
-        output1 = (b'----------------------------------------------------------'
-                   b'------------\nRan 0 tests in ')
-        output2 = b's\n\nOK\n'
-        output = results[-2]
-        result = results[-1]
-        self.assertEqual(result['status'], 'finished')
-        self.assertEqual(result['result'], 'pass')
-        self.assertTrue(output['log'].startswith(output1))
-        self.assertTrue(output['log'].endswith(output2))
-
-    def test_runner_python_unittest_fail(self):
-        runnable = nrunner.Runnable('python-unittest', 'unittest.TestCase.fail')
-        runner_klass = runnable.pick_runner_class()
-        runner = runner_klass(runnable)
-        results = [status for status in runner.run()]
-        output1 = (b'============================================================='
-                   b'=========\nFAIL: fail (unittest.case.TestCase)'
-                   b'\nFail immediately, with the given message.')
-        output2 = b'\n\nFAILED (failures=1)\n'
-        output = results[-2]
-        result = results[-1]
-        self.assertEqual(result['status'], 'finished')
-        self.assertEqual(result['result'], 'fail')
-        self.assertTrue(output['log'].startswith(output1))
-        self.assertTrue(output['log'].endswith(output2))
-
-    def test_runner_python_unittest_skip(self):
-        runnable = nrunner.Runnable(
-            'python-unittest',
-            'selftests.unit.test_test.TestClassTestUnit.DummyTest.skip')
-        runner_klass = runnable.pick_runner_class()
-        runner = runner_klass(runnable)
-        results = [status for status in runner.run()]
-        output1 = (b'----------------------------------------------------------'
-                   b'------------\nRan 1 test in ')
-        output2 = b's\n\nOK (skipped=1)\n'
-        output = results[-2]
-        result = results[-1]
-        self.assertEqual(result['status'], 'finished')
-        self.assertEqual(result['result'], 'skip')
-        self.assertTrue(output['log'].startswith(output1))
-        self.assertTrue(output['log'].endswith(output2))
-
     def test_runner_python_unittest_error(self):
         runnable = nrunner.Runnable('python-unittest', 'error')
         runner_klass = runnable.pick_runner_class()
         runner = runner_klass(runnable)
         results = [status for status in runner.run()]
-        output1 = (b'============================================================='
-                   b'=========\nERROR: error')
-        output2 = b'\n\nFAILED (errors=1)\n'
-        output = results[-2]
+        output = ("Invalid URI: could not be converted to an unittest "
+                  "dotted name.")
         result = results[-1]
         self.assertEqual(result['status'], 'finished')
         self.assertEqual(result['result'], 'error')
-        self.assertTrue(output['log'].startswith(output1))
-        self.assertTrue(output['log'].endswith(output2))
+        self.assertEqual(result['output'], output)
 
     def test_runner_python_unittest_empty_uri_error(self):
         runnable = nrunner.Runnable('python-unittest', '')
         runner_klass = runnable.pick_runner_class()
         runner = runner_klass(runnable)
         results = [status for status in runner.run()]
-        output = 'uri is required but was not given'
+        output = ("Invalid URI: could not be converted to an unittest "
+                  "dotted name.")
         result = results[-1]
         self.assertEqual(result['status'], 'finished')
         self.assertEqual(result['result'], 'error')