Repository: spyder-ide/spyder-unittest (language: python)

Issue:
StopIteration raised when parsing unittest output
## Description

### What steps will reproduce the problem?

<!--- You can use Markdown here --->

ran unit tests; otherwise damned if I know; see stack trace


### Traceback
```python-traceback
  File "/opt/anaconda3/lib/python3.8/site-packages/spyder_unittest/backend/unittestrunner.py", line 41, in finished
    testresults = self.load_data(output)
  File "/opt/anaconda3/lib/python3.8/site-packages/spyder_unittest/backend/unittestrunner.py", line 83, in load_data
    test_index = next(
StopIteration
```

## Versions

* Spyder version: 4.1.5 
* Python version: 3.8.1
* Qt version: 5.12.10
* PyQt5 version: 5.12.3
* Operating System: Darwin 17.7.0

### Dependencies

```

# Mandatory:
applaunchservices >=0.1.7      :  0.2.1 (OK)
atomicwrites >=1.2.0           :  1.4.0 (OK)
chardet >=2.0.0                :  3.0.4 (OK)
cloudpickle >=0.5.0            :  1.6.0 (OK)
diff_match_patch >=20181111    :  20200713 (OK)
intervaltree                   :  None (OK)
IPython >=4.0                  :  7.19.0 (OK)
jedi =0.17.1                   :  0.17.1 (OK)
nbconvert >=4.0                :  6.0.7 (OK)
numpydoc >=0.6.0               :  1.1.0 (OK)
parso =0.7.0                   :  0.7.0 (OK)
pexpect >=4.4.0                :  4.8.0 (OK)
pickleshare >=0.4              :  0.7.5 (OK)
psutil >=5.3                   :  5.7.2 (OK)
pygments >=2.0                 :  2.7.2 (OK)
pylint >=1.0                   :  2.6.0 (OK)
pyls >=0.34.0;<1.0.0           :  0.35.1 (OK)
qdarkstyle >=2.8               :  2.8.1 (OK)
qtawesome >=0.5.7              :  1.0.1 (OK)
qtconsole >=4.6.0              :  4.7.7 (OK)
qtpy >=1.5.0                   :  1.9.0 (OK)
rtree >=0.8.3                  :  0.9.4 (OK)
sphinx >=0.6.6                 :  3.2.1 (OK)
spyder_kernels >=1.9.4;<1.10.0 :  1.9.4 (OK)
watchdog                       :  None (OK)
zmq >=17                       :  19.0.2 (OK)

# Optional:
cython >=0.21                  :  0.29.21 (OK)
matplotlib >=2.0.0             :  3.3.2 (OK)
numpy >=1.7                    :  1.19.2 (OK)
pandas >=0.13.1                :  1.1.3 (OK)
scipy >=0.17.0                 :  1.5.2 (OK)
sympy >=0.7.3                  :  1.6.2 (OK)

# Spyder plugins:
spyder_unittest                :  0.4.1 (OK)
```



Discussion / hints:
Hi @elharo, thanks for your report. It looks like there is something in the unittest output when you are running the tests that it confusing Spyder. Would it be possible for you to find out which of your tests is causing problems? Failing that, can you send me the output if you run unittest by hand from the command line?
with some effort I reverted to a state where it occurs. here's the CLI output in that case:

```
~/60001/ps4$ python -m unittest tests
Loading word list from file...
('  ', 55901, 'words loaded.')
.Loading word list from file...
('  ', 55901, 'words loaded.')
E...
======================================================================
ERROR: test_get_valid_words (tests.MessageTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 22, in test_get_valid_words
    self.assertTrue( len(message.get_valid_words()) > 10)
TypeError: object of type 'NoneType' has no len()

----------------------------------------------------------------------
Ran 5 tests in 0.043s

FAILED (errors=1)
```


The bug in my code that caused a test to fail was that `message.get_valid_words()` returned `None` instead of a list. 
The code in question is not open source so I don't want to attach it here, but neither is it hugely confidential. If you send me your email I can forward you a zip of the project in question. 
I can reproduce the stack trace with the following simple test:
```
import sys
import unittest

class MyTest(unittest.TestCase):
    def test_fail(self): 
        print('text', file=sys.stderr)
        self.assertEqual(1+1, 3)
```
The messages to stderr get interspersed with the unittest output. If you can use stdout instead, that would probably fix the issue. Another workaround is to run your tests using pytest, which does run `unittest` tests so you don't need to change anything (but there may be incompatibilities). I think the proper solution is what issue #76 proposes, but I am afraid there are no immediate plans to do this.

Fix patch:
diff --git a/setup.py b/setup.py
index e440179..1d69cce 100644
--- a/setup.py
+++ b/setup.py
@@ -56,7 +56,7 @@ def get_package_data(name, extlist):
     packages=find_packages(),
     package_data={LIBNAME: get_package_data(LIBNAME, EXTLIST)},
     keywords=["Qt PyQt4 PyQt5 spyder plugins testing"],
-    python_requires='>=3.5',
+    python_requires='>=3.7',
     install_requires=REQUIREMENTS,
     url='https://github.com/spyder-ide/spyder-unittest',
     license='MIT',


Test patch:
diff --git a/spyder_unittest/backend/tests/test_unittestrunner.py b/spyder_unittest/backend/tests/test_unittestrunner.py
index 7df193a..9c4b15a 100644
--- a/spyder_unittest/backend/tests/test_unittestrunner.py
+++ b/spyder_unittest/backend/tests/test_unittestrunner.py
@@ -6,255 +6,176 @@
 """Tests for unittestrunner.py"""
 
 # Standard library imports
+import os.path as osp
 import sys
+from unittest.mock import Mock
 
 # Local imports
-from spyder_unittest.backend.runnerbase import Category
 from spyder_unittest.backend.unittestrunner import UnittestRunner
+from spyder_unittest.backend.runnerbase import Category, TestResult
+from spyder_unittest.widgets.configdialog import Config
+
+
+def test_unittestrunner_create_argument_list(monkeypatch):
+    """
+    Test that UnittestRunner.createArgumentList() returns the expected list.
+    """
+    config = Config()
+    cov_path = None
+    MockZMQStreamReader = Mock()
+    monkeypatch.setattr(
+        'spyder_unittest.backend.unittestrunner.ZmqStreamReader',
+        MockZMQStreamReader)
+    mock_reader = MockZMQStreamReader()
+    mock_reader.port = 42
+    runner = UnittestRunner(None, 'resultfile')
+    runner.reader = mock_reader
+    monkeypatch.setattr(
+        'spyder_unittest.backend.unittestrunner.osp.dirname',
+        lambda _: 'dir')
+
+    result = runner.create_argument_list(config, cov_path)
+
+    pyfile = osp.join('dir', 'workers', 'unittestworker.py')
+    assert result == [pyfile, '42']
+
+
+def test_unittestrunner_start(monkeypatch):
+    """
+    Test that UnittestRunner.start() sets the .config and .reader members
+    correctly, that it connects to the reader's sig_received, and that it
+    called the base class method.
+    """
+    MockZMQStreamReader = Mock()
+    monkeypatch.setattr(
+        'spyder_unittest.backend.unittestrunner.ZmqStreamReader',
+        MockZMQStreamReader)
+    mock_reader = MockZMQStreamReader()
+    mock_base_start = Mock()
+    monkeypatch.setattr('spyder_unittest.backend.unittestrunner.RunnerBase.start',
+                        mock_base_start)
+    runner = UnittestRunner(None, 'results')
+    config = Config()
+    cov_path = None
+
+    runner.start(config, cov_path, sys.executable, ['pythondir'])
+
+    assert runner.config is config
+    assert runner.reader is mock_reader
+    runner.reader.sig_received.connect.assert_called_once_with(
+        runner.process_output)
+    mock_base_start.assert_called_once_with(
+        config, cov_path, sys.executable, ['pythondir'])
+
+
+def test_unittestrunner_process_output_with_collected(qtbot):
+    """Test UnittestRunner.processOutput() with two `collected` events."""
+    runner = UnittestRunner(None)
+    output = [{'event': 'collected', 'id': 'spam.ham'},
+              {'event': 'collected', 'id': 'eggs.bacon'}]
 
+    with qtbot.waitSignal(runner.sig_collected) as blocker:
+        runner.process_output(output)
 
-# Up to Python 3.10, unittest output read:
-# test_fail (testing.test_unittest.MyTest) ... FAIL
-# but from Python 3.11, it reads:
-# test_fail (testing.test_unittest.MyTest.test_fail) ... FAIL
-# These tests only test the system executable; they do not test
-# the situation where the requested interpreter is different.
-IS_PY311_OR_GREATER = sys.version_info[:2] >= (3, 11)
+    expected = ['spam.ham', 'eggs.bacon']
+    assert blocker.args == [expected]
 
 
-def test_unittestrunner_load_data_with_two_tests():
-    output10 = """test_isupper (teststringmethods.TestStringMethods) ... ok
-test_split (teststringmethods.TestStringMethods) ... ok
+def test_unittestrunner_process_output_with_starttest(qtbot):
+    """Test UnittestRunner.processOutput() with two `startTest` events."""
+    runner = UnittestRunner(None)
+    output = [{'event': 'startTest', 'id': 'spam.ham'},
+              {'event': 'startTest', 'id': 'eggs.bacon'}]
 
-----------------------------------------------------------------------
-Ran 2 tests in 0.012s
+    with qtbot.waitSignal(runner.sig_starttest) as blocker:
+        runner.process_output(output)
 
-OK
-"""
-    output11 = """test_isupper (teststringmethods.TestStringMethods.test_isupper) ... ok
-test_split (teststringmethods.TestStringMethods.test_split) ... ok
+    expected = ['spam.ham', 'eggs.bacon']
+    assert blocker.args == [expected]
 
-----------------------------------------------------------------------
-Ran 2 tests in 0.012s
 
-OK
-"""
-    output = output11 if IS_PY311_OR_GREATER else output10
+def test_unittestrunner_process_output_with_addsuccess(qtbot):
+    """Test UnittestRunner.processOutput() with an `addSuccess` event."""
     runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    res = runner.load_data(output)
-    assert len(res) == 2
+    output = [{'event': 'addSuccess', 'id': 'spam.ham'}]
 
-    assert res[0].category == Category.OK
-    assert res[0].status == 'ok'
-    assert res[0].name == 'teststringmethods.TestStringMethods.test_isupper'
-    assert res[0].message == ''
-    assert res[0].extra_text == []
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
 
-    assert res[1].category == Category.OK
-    assert res[1].status == 'ok'
-    assert res[1].name == 'teststringmethods.TestStringMethods.test_split'
-    assert res[1].message == ''
-    assert res[1].extra_text == []
+    expected = [TestResult(Category.OK, 'success', 'spam.ham')]
+    assert blocker.args == [expected]
 
 
-def test_unittestrunner_load_data_with_one_test():
-    output10 = """test1 (test_foo.Bar) ... ok
+def test_unittestrunner_process_output_with_addfailure(qtbot):
+    """Test UnittestRunner.processOutput() with an `addFailure` event."""
+    runner = UnittestRunner(None)
+    output = [{'event': 'addFailure',
+               'id': 'spam.ham',
+               'reason': 'exception',
+               'err': 'traceback'}]
 
-----------------------------------------------------------------------
-Ran 1 test in 0.000s
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
 
-OK
-"""
-    output11 = """test1 (test_foo.Bar.test1) ... ok
+    expected = [TestResult(Category.FAIL, 'failure', 'spam.ham',
+                           message='exception', extra_text='traceback')]
+    assert blocker.args == [expected]
 
-----------------------------------------------------------------------
-Ran 1 test in 0.000s
 
-OK
-"""
-    output = output11 if IS_PY311_OR_GREATER else output10
-    runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    res = runner.load_data(output)
-    assert len(res) == 1
-    assert res[0].category == Category.OK
-    assert res[0].status == 'ok'
-    assert res[0].name == 'test_foo.Bar.test1'
-    assert res[0].extra_text == []
-
-
-def test_unittestrunner_load_data_with_exception():
-    output10 = """test1 (test_foo.Bar) ... FAIL
-test2 (test_foo.Bar) ... ok
-
-======================================================================
-FAIL: test1 (test_foo.Bar)
-----------------------------------------------------------------------
-Traceback (most recent call last):
-  File "/somepath/test_foo.py", line 5, in test1
-    self.assertEqual(1, 2)
-AssertionError: 1 != 2
-
-----------------------------------------------------------------------
-Ran 2 tests in 0.012s
-
-FAILED (failures=1)
-"""
-    output11 = """test1 (test_foo.Bar.test1) ... FAIL
-test2 (test_foo.Bar.test2) ... ok
-
-======================================================================
-FAIL: test1 (test_foo.Bar.test1)
-----------------------------------------------------------------------
-Traceback (most recent call last):
-  File "/somepath/test_foo.py", line 5, in test1
-    self.assertEqual(1, 2)
-AssertionError: 1 != 2
-
-----------------------------------------------------------------------
-Ran 2 tests in 0.012s
-
-FAILED (failures=1)
-"""
-    output = output11 if IS_PY311_OR_GREATER else output10
-    runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    res = runner.load_data(output)
-    assert len(res) == 2
-
-    assert res[0].category == Category.FAIL
-    assert res[0].status == 'FAIL'
-    assert res[0].name == 'test_foo.Bar.test1'
-    assert res[0].extra_text[0].startswith('Traceback')
-    assert res[0].extra_text[-1].endswith('AssertionError: 1 != 2')
-
-    assert res[1].category == Category.OK
-    assert res[1].status == 'ok'
-    assert res[1].name == 'test_foo.Bar.test2'
-    assert res[1].extra_text == []
-
-
-def test_unittestrunner_load_data_with_comment():
-    output10 = """test1 (test_foo.Bar)
-comment ... ok
-test2 (test_foo.Bar) ... ok
-
-----------------------------------------------------------------------
-Ran 2 tests in 0.000s
-
-OK
-"""
-    output11 = """test1 (test_foo.Bar.test1)
-comment ... ok
-test2 (test_foo.Bar.test2) ... ok
-
-----------------------------------------------------------------------
-Ran 2 tests in 0.000s
-
-OK
-"""
-    output = output11 if IS_PY311_OR_GREATER else output10
-    runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    res = runner.load_data(output)
-    assert len(res) == 2
-
-    assert res[0].category == Category.OK
-    assert res[0].status == 'ok'
-    assert res[0].name == 'test_foo.Bar.test1'
-    assert res[0].extra_text == []
-
-    assert res[1].category == Category.OK
-    assert res[1].status == 'ok'
-    assert res[1].name == 'test_foo.Bar.test2'
-    assert res[1].extra_text == []
-
-
-def test_unittestrunner_load_data_with_fail_and_comment():
-    output10 = """test1 (test_foo.Bar)
-comment ... FAIL
-
-======================================================================
-FAIL: test1 (test_foo.Bar)
-comment
-----------------------------------------------------------------------
-Traceback (most recent call last):
-  File "/somepath/test_foo.py", line 30, in test1
-    self.assertEqual(1, 2)
-AssertionError: 1 != 2
-
-----------------------------------------------------------------------
-Ran 1 test in 0.000s
-
-FAILED (failures=1)
-"""
-    output11 = """test1 (test_foo.Bar.test1)
-comment ... FAIL
-
-======================================================================
-FAIL: test1 (test_foo.Bar.test1)
-comment
-----------------------------------------------------------------------
-Traceback (most recent call last):
-  File "/somepath/test_foo.py", line 30, in test1
-    self.assertEqual(1, 2)
-AssertionError: 1 != 2
-
-----------------------------------------------------------------------
-Ran 1 test in 0.000s
-
-FAILED (failures=1)
-"""
-    output = output11 if IS_PY311_OR_GREATER else output10
+def test_unittestrunner_process_output_with_adderror(qtbot):
+    """Test UnittestRunner.processOutput() with an `addError` event."""
     runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    res = runner.load_data(output)
-    assert len(res) == 1
+    output = [{'event': 'addError',
+               'id': 'spam.ham',
+               'reason': 'exception',
+               'err': 'traceback'}]
 
-    assert res[0].category == Category.FAIL
-    assert res[0].status == 'FAIL'
-    assert res[0].name == 'test_foo.Bar.test1'
-    assert res[0].extra_text[0].startswith('Traceback')
-    assert res[0].extra_text[-1].endswith('AssertionError: 1 != 2')
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
 
+    expected = [TestResult(Category.FAIL, 'error', 'spam.ham',
+                           message='exception', extra_text='traceback')]
+    assert blocker.args == [expected]
 
-def test_try_parse_header_with_ok():
+
+def test_unittestrunner_process_output_with_addskip(qtbot):
+    """Test UnittestRunner.processOutput() with an `addSkip` event."""
     runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    lines10 = ['test_isupper (testfoo.TestStringMethods) ... ok']
-    lines11 = ['test_isupper (testfoo.TestStringMethods.test_isupper) ... ok']
-    lines = lines11 if IS_PY311_OR_GREATER else lines10
-    res = runner.try_parse_result(lines, 0)
-    assert res == (1, 'testfoo.TestStringMethods.test_isupper', 'ok', '')
+    output = [{'event': 'addSkip',
+               'id': 'spam.ham',
+               'reason': 'skip reason'}]
 
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
 
-def test_try_parse_header_with_xfail():
-    runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    lines10 = ['test_isupper (testfoo.TestStringMethods) ... expected failure']
-    lines11 = ['test_isupper (testfoo.TestStringMethods.test_isupper) ... expected failure']
-    lines = lines11 if IS_PY311_OR_GREATER else lines10
-    res = runner.try_parse_result(lines, 0)
-    assert res == (1, 'testfoo.TestStringMethods.test_isupper',
-                   'expected failure', '')
+    expected = [TestResult(Category.SKIP, 'skip', 'spam.ham',
+                           message='skip reason')]
+    assert blocker.args == [expected]
 
 
-def test_try_parse_header_with_message():
+def test_unittestrunner_process_output_with_addexpectedfailure(qtbot):
+    """Test UnittestRunner.processOutput() with an `addExpectedFailure` event."""
     runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    lines10 = ["test_nothing (testfoo.Tests) ... skipped 'msg'"]
-    lines11 = ["test_nothing (testfoo.Tests.test_nothing) ... skipped 'msg'"]
-    lines = lines11 if IS_PY311_OR_GREATER else lines10
-    res = runner.try_parse_result(lines, 0)
-    assert res == (1, 'testfoo.Tests.test_nothing', 'skipped', 'msg')
+    output = [{'event': 'addExpectedFailure',
+               'id': 'spam.ham',
+               'reason': 'exception',
+               'err': 'traceback'}]
+
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
 
+    expected = [TestResult(Category.OK, 'expectedFailure', 'spam.ham',
+                           message='exception', extra_text='traceback')]
+    assert blocker.args == [expected]
 
-def test_try_parse_header_starting_with_digit():
+
+def test_unittestrunner_process_output_with_addunexpectedsuccess(qtbot):
+    """Test UnittestRunner.processOutput() with an `addUnexpectedSuccess` event."""
     runner = UnittestRunner(None)
-    runner.set_fullname_version()
-    lines10 = ['0est_isupper (testfoo.TestStringMethods) ... ok']
-    lines11 = ['0est_isupper (testfoo.TestStringMethods.0est_isupper) ... ok']
-    lines = lines11 if IS_PY311_OR_GREATER else lines10
-    res = runner.try_parse_result(lines, 0)
-    assert res is None
+    output = [{'event': 'addUnexpectedSuccess', 'id': 'spam.ham'}]
+
+    with qtbot.waitSignal(runner.sig_testresult) as blocker:
+        runner.process_output(output)
+
+    expected = [TestResult(Category.FAIL, 'unexpectedSuccess', 'spam.ham')]
+    assert blocker.args == [expected]
diff --git a/spyder_unittest/backend/unittestrunner.py b/spyder_unittest/backend/unittestrunner.py
index 3839eac..1cb4cd5 100644
--- a/spyder_unittest/backend/unittestrunner.py
+++ b/spyder_unittest/backend/unittestrunner.py
@@ -5,12 +5,15 @@
 # (see LICENSE.txt for details)
 """Support for unittest framework."""
 
+from __future__ import annotations
+
 # Standard library imports
-import re
-import subprocess
+import os.path as osp
 
 # Local imports
+from spyder_unittest.widgets.configdialog import Config
 from spyder_unittest.backend.runnerbase import Category, RunnerBase, TestResult
+from spyder_unittest.backend.zmqreader import ZmqStreamReader
 
 
 class UnittestRunner(RunnerBase):
@@ -19,178 +22,73 @@ class UnittestRunner(RunnerBase):
     module = 'unittest'
     name = 'unittest'
 
-    def __init__(self, widget, resultfilename=None):
-        super().__init__(widget, resultfilename)
-        # Set a sensible default
-        self.fullname_version = 'pre311'
-
-    def create_argument_list(self, config, cov_path):
+    def create_argument_list(self, config: Config, cov_path: str) -> list[str]:
         """Create argument list for testing process."""
-        return ['-m', self.module, 'discover', '-v']
-
-    def finished(self):
+        dirname = osp.dirname(__file__)
+        pyfile = osp.join(dirname, 'workers', 'unittestworker.py')
+        arguments = [pyfile, str(self.reader.port)]
+        return arguments
+
+    def start(self, config: Config, cov_path: str, executable: str,
+              pythonpath: str) -> None:
+        """Start process which will run the unit test suite."""
+        self.config = config
+        self.reader = ZmqStreamReader()
+        self.reader.sig_received.connect(self.process_output)
+        super().start(config, cov_path, executable, pythonpath)
+
+    def finished(self) -> None:
         """
         Called when the unit test process has finished.
 
-        This function reads the results and emits `sig_finished`.
+        This function reads the process output and emits `sig_finished`.
         """
-        self.set_fullname_version()
+        self.reader.close()
         output = self.read_all_process_output()
-        testresults = self.load_data(output)
-        self.sig_finished.emit(testresults, output, True)
-
-    def load_data(self, output):
-        """
-        Read and parse output from unittest module.
-
-        Any parsing errors are silently ignored.
+        self.sig_finished.emit([], output, True)
 
-        Returns
-        -------
-        list of TestResult
-            Unit test results.
+    def process_output(self, output: list[dict]) -> None:
         """
-        res = []
-        lines = output.splitlines()
-        line_index = 0
-
-        try:
-            while lines[line_index]:
-                data = self.try_parse_result(lines, line_index)
-                if data:
-                    line_index = data[0]
-                    if data[2] == 'ok':
-                        cat = Category.OK
-                    elif data[2] == 'FAIL' or data[2] == 'ERROR':
-                        cat = Category.FAIL
-                    else:
-                        cat = Category.SKIP
-                    tr = TestResult(category=cat, status=data[2], name=data[1],
-                                    message=data[3])
-                    res.append(tr)
-                else:
-                    line_index += 1
-
-            line_index += 1
-            while not (lines[line_index]
-                       and all(c == '-' for c in lines[line_index])):
-                data = self.try_parse_exception_block(lines, line_index)
-                if data:
-                    line_index = data[0]
-                    test_index = next(
-                        i for i, tr in enumerate(res)
-                        if tr.name == data[1])
-                    res[test_index].extra_text = data[2]
-                else:
-                    line_index += 1
-        except IndexError:
-            pass
-
-        return res
-
-    def try_parse_result(self, lines, line_index):
-        """
-        Try to parse one or more lines of text as a test result.
-
-        Returns
-        -------
-        (int, str, str, str) or None
-            If a test result is parsed successfully then return a tuple with
-            the line index of the first line after the test result, the full
-            name of the test function (including the class), the test result,
-            and the reason (if no reason is given, the third string is empty).
-            Otherwise, return None.
-        """
-        regexp = r'([^\d\W]\w*) \(([^\d\W][\w.]*)\)'
-        match = re.match(regexp, lines[line_index])
-        if match:
-            function_fullname = self.get_fullname(match.group(1), match.group(2))
-        else:
-            return None
-        while lines[line_index]:
-            regexp = (r' \.\.\. (ok|FAIL|ERROR|skipped|expected failure|'
-                      r"unexpected success)( '([^']*)')?\Z")
-            match = re.search(regexp, lines[line_index])
-            if match:
-                result = match.group(1)
-                msg = match.group(3) or ''
-                return (line_index + 1, function_fullname, result, msg)
-            line_index += 1
-        return None
-
-    def try_parse_exception_block(self, lines, line_index):
-        """
-        Try to parse a block detailing an exception in unittest output.
-
-        Returns
-        -------
-        (int, str, list of str) or None
-            If an exception block is parsed successfully, then return a tuple
-            with the line index of the first line after the block, the full
-            name of the test function (including the class), and the text of
-            the exception. Otherwise, return None.
-        """
-        if not all(char == '=' for char in lines[line_index]):
-            return None
-        regexp = r'\w+: ([^\d\W]\w*) \(([^\d\W][\w.]*)\)\Z'
-        match = re.match(regexp, lines[line_index + 1])
-        if not match:
-            return None
-        line_index += 1
-        while not all(char == '-' for char in lines[line_index]):
-            if not lines[line_index]:
-                return None
-            line_index += 1
-        line_index += 1
-        exception_text = []
-        while lines[line_index]:
-            exception_text.append(lines[line_index])
-            line_index += 1
-        function_fullname = self.get_fullname(match.group(1), match.group(2))
-        return (line_index, function_fullname, exception_text)
-
-    def set_fullname_version(self):
-        script = 'import platform; print(platform.python_version())'
-        process = subprocess.run([self.executable, '-c', script],
-                                 capture_output=True, text=True)
-        if process.returncode != 0:
-            self.fullname_version = 'pre311'
-            return
-
-        # We only take the first two components as the third might
-        # be something like '0a3'.
-        exec_version_components = process.stdout.split('.')[:2]
-        exec_version = tuple(map(int, exec_version_components))
-        if exec_version < (3, 11):
-            self.fullname_version = 'pre311'
-        else:
-            self.fullname_version = '311'
-
-    def get_fullname(self, fn_name, parenthesised):
-        """
-        Determine the test name output by unittest.
-
-        Up to Python 3.10, unittest output read:
-        test_fail (testing.test_unittest.MyTest) ... FAIL
-        but from Python 3.11, it reads:
-        test_fail (testing.test_unittest.MyTest.test_fail) ... FAIL
+        Process output of test process.
 
         Parameters
         ----------
-        fn_name : str
-            Part prior to the parentheses (without spaces)
-        parenthesised : str
-            Part within the parentheses
-
-        Returns
-        -------
-        fullname : str
-            The full name of the class plus function,
-            "testing.test_unittest.MyTest.test_fail" in the above
-            example.
+        output : list
+            list of decoded Python object sent by test process.
         """
-        if self.fullname_version == 'pre311':
-            function_fullname = '{}.{}'.format(parenthesised, fn_name)
-        else:
-            function_fullname = parenthesised
-        return function_fullname
+        collected_list = []
+        starttest_list = []
+        result_list = []
+
+        for result_item in output:
+            if result_item['event'] == 'collected':
+                collected_list.append(result_item['id'])
+            elif result_item['event'] == 'startTest':
+                starttest_list.append(result_item['id'])
+            elif result_item['event'].startswith('add'):
+                testresult = add_event_to_testresult(result_item)
+                result_list.append(testresult)
+
+        if collected_list:
+            self.sig_collected.emit(collected_list)
+        if starttest_list:
+            self.sig_starttest.emit(starttest_list)
+        if result_list:
+            self.sig_testresult.emit(result_list)
+
+
+def add_event_to_testresult(event: dict) -> TestResult:
+    """Convert an addXXX event sent by test process to a TestResult."""
+    status = event['event'][3].lower() + event['event'][4:]
+    if status in ('error', 'failure', 'unexpectedSuccess'):
+        cat = Category.FAIL
+    elif status in ('success', 'expectedFailure'):
+        cat = Category.OK
+    else:
+        cat = Category.SKIP
+    testname = event['id']
+    message = event.get('reason', '')
+    extra_text = event.get('err', '')
+    result = TestResult(cat, status, testname, message=message,
+                        extra_text=extra_text)
+    return result
diff --git a/spyder_unittest/backend/workers/__init__.py b/spyder_unittest/backend/workers/__init__.py
index 20a2434..4b7e22a 100644
--- a/spyder_unittest/backend/workers/__init__.py
+++ b/spyder_unittest/backend/workers/__init__.py
@@ -14,4 +14,4 @@
 
 Dependencies should be kept to a minimum, because they need to be installed
 in each target environment.
-"""
\ No newline at end of file
+"""
diff --git a/spyder_unittest/backend/workers/pytestworker.py b/spyder_unittest/backend/workers/pytestworker.py
index 3553cec..ee0dad9 100644
--- a/spyder_unittest/backend/workers/pytestworker.py
+++ b/spyder_unittest/backend/workers/pytestworker.py
@@ -17,29 +17,10 @@
 # Third party imports
 import pytest
 
-# Local imports, needs to be relative otherwise it will fail if trying
-# to execute in a different env with only spyder-kernel installed
-try:
-    # this line is needed for the tests to succeed
-    from .zmqwriter import ZmqStreamWriter
-except:
-    # this line is needed for the plugin to work
-    from zmqwriter import ZmqStreamWriter
-
-class FileStub():
-    """Stub for ZmqStreamWriter which instead writes to a file."""
-
-    def __init__(self, filename):
-        """Constructor; connect to specified filename."""
-        self.file = open(filename, 'w')
-
-    def write(self, obj):
-        """Write Python object to file."""
-        self.file.write(str(obj) + '\n')
-
-    def close(self):
-        """Close file."""
-        self.file.close()
+# Local imports
+# Note that the script can be run in an environment that does not contain
+# spyder_unittest so `from spyder_unittest.xxx import xxx` does not work.
+from zmqwriter import FileStub, ZmqStreamWriter
 
 
 class SpyderPlugin():
diff --git a/spyder_unittest/backend/workers/tests/test_pytestworker.py b/spyder_unittest/backend/workers/tests/test_pytestworker.py
index 0f46343..d0ea79a 100644
--- a/spyder_unittest/backend/workers/tests/test_pytestworker.py
+++ b/spyder_unittest/backend/workers/tests/test_pytestworker.py
@@ -7,14 +7,22 @@
 
 # Standard library imports
 import os
+import os.path as osp
+import sys
 from unittest.mock import create_autospec, MagicMock, Mock
 
 # Third party imports
 import pytest
 
 # Local imports
+# Local imports
+# Modules in spyder_unittest.backend.workers assume that their directory
+# is in `sys.path`, so add that directory to the path.
+old_path = sys.path
+sys.path.insert(0, osp.join(osp.dirname(__file__), osp.pardir))
 from spyder_unittest.backend.workers.pytestworker import SpyderPlugin, main
 from spyder_unittest.backend.workers.zmqwriter import ZmqStreamWriter
+sys.path = old_path
 
 
 class EmptyClass:
diff --git a/spyder_unittest/backend/workers/tests/test_unittestworker.py b/spyder_unittest/backend/workers/tests/test_unittestworker.py
new file mode 100644
index 0000000..6dc1ec5
--- /dev/null
+++ b/spyder_unittest/backend/workers/tests/test_unittestworker.py
@@ -0,0 +1,176 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © 2017 Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see LICENSE.txt for details)
+"""Tests for pytestworker.py"""
+
+# Standard library imports
+import os
+import os.path as osp
+import sys
+import unittest
+from unittest.mock import call, create_autospec, Mock
+
+# Third-party imports
+import pytest
+
+# Local imports
+# Modules in spyder_unittest.backend.workers assume that their directory
+# is in `sys.path`, so add that directory to the path.
+old_path = sys.path
+sys.path.insert(0, osp.join(osp.dirname(__file__), osp.pardir))
+from spyder_unittest.backend.workers.unittestworker import (
+    main, report_collected, SpyderTestResult)
+from spyder_unittest.backend.workers.zmqwriter import ZmqStreamWriter
+sys.path = old_path
+
+
+class MyTest(unittest.TestCase):
+    """Simple test class."""
+    def first(): pass
+    def second(): pass
+
+
+@pytest.fixture
+def testresult():
+    mock_writer = create_autospec(ZmqStreamWriter)
+    my_testresult = SpyderTestResult(
+            stream=Mock(), descriptions=True, verbosity=2)
+    my_testresult.writer = mock_writer
+    my_testresult._exc_info_to_string = lambda err, test: 'some exception info'
+    return my_testresult
+
+
+def test_spydertestresult_starttest(testresult):
+    """Test that SpyderTestResult.startTest() writes the correct info."""
+    test = MyTest(methodName='first')
+    testresult.startTest(test)
+    expected = {'event': 'startTest', 'id': test.id()}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_addsuccess(testresult):
+    """Test that SpyderTestResult.addSuccess() writes the correct info."""
+    test = MyTest(methodName='first')
+    testresult.addSuccess(test)
+    expected = {'event': 'addSuccess', 'id': test.id()}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_addfailure(testresult):
+    """Test that SpyderTestResult.addFailure() writes the correct info."""
+    test = MyTest(methodName='first')
+    err = ('notused', AssertionError('xxx'), 'notused')
+    testresult.addFailure(test, err)
+    expected = {'event': 'addFailure',
+                'id': test.id(),
+                'reason': 'AssertionError: xxx',
+                'err': 'some exception info'}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_adderror(testresult):
+    """Test that SpyderTestResult.addError() writes the correct info."""
+    test = MyTest(methodName='first')
+    err = ('notused', AssertionError('xxx'), 'notused')
+    testresult.addError(test, err)
+    expected = {'event': 'addError',
+                'id': test.id(),
+                'reason': 'AssertionError: xxx',
+                'err': 'some exception info'}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_addskip(testresult):
+    """Test that SpyderTestResult.addSkip() writes the correct info."""
+    test = MyTest(methodName='first')
+    reason = 'my reason'
+    testresult.addSkip(test, reason)
+    expected = {'event': 'addSkip',
+                'id': test.id(),
+                'reason': reason}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_addexpectedfailure(testresult):
+    """Test that SpyderTestResult.addExpectedFailure() writes the correct info."""
+    test = MyTest(methodName='first')
+    err = ('notused', AssertionError('xxx'), 'notused')
+    testresult.addExpectedFailure(test, err)
+    expected = {'event': 'addExpectedFailure',
+                'id': test.id(),
+                'reason': 'AssertionError: xxx',
+                'err': 'some exception info'}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_spydertestresult_addunexpectedsuccess(testresult):
+    """Test that SpyderTestResult.addUnexpectedSuccess() writes the correct info."""
+    test = MyTest(methodName='first')
+    testresult.addUnexpectedSuccess(test)
+    expected = {'event': 'addUnexpectedSuccess', 'id': test.id()}
+    testresult.writer.write.assert_called_once_with(expected)
+
+
+def test_unittestworker_report_collected():
+    """
+    Test that report_collected() with a test suite containing two tests
+    writes two `collected` events to the ZMQ stream.
+    """
+    mock_writer = create_autospec(ZmqStreamWriter)
+    test1 = MyTest(methodName='first')
+    test2 = MyTest(methodName='second')
+    test_suite_inner = unittest.TestSuite([test1, test2])
+    test_suite = unittest.TestSuite([test_suite_inner])
+
+    report_collected(mock_writer, test_suite)
+
+    expected = [call({'event': 'collected', 'id': test1.id()}),
+                call({'event': 'collected', 'id': test2.id()})]
+    assert mock_writer.write.mock_calls == expected
+
+
+def test_unittestworker_main(monkeypatch, tmpdir):
+    """
+    Test that the main function with some tests writes the expected
+    output to the ZMQ stream.
+    """
+    os.chdir(tmpdir.strpath)
+    testfilename = tmpdir.join('test_foo_unittestworker.py').strpath
+    with open(testfilename, 'w') as f:
+        f.write("import unittest\n"
+                "class MyTest(unittest.TestCase):\n"
+                "   def test_ok(self): self.assertEqual(1+1, 2)\n"
+                "   def test_fail(self): self.assertEqual(1+1, 3)\n")
+
+    mock_writer = create_autospec(ZmqStreamWriter)
+    MockZmqStreamWriter = Mock(return_value=mock_writer)
+    monkeypatch.setattr(
+        'spyder_unittest.backend.workers.unittestworker.ZmqStreamWriter',
+        MockZmqStreamWriter)
+
+    main(['mockscriptname', '42'])
+
+    args = mock_writer.write.call_args_list
+    # args[N][0][0] is dict sent over ZMQ stream in function call N
+
+    assert args[0][0][0]['event'] == 'collected'
+    assert args[0][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_fail'
+
+    assert args[1][0][0]['event'] == 'collected'
+    assert args[1][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_ok'
+
+    assert args[2][0][0]['event'] == 'startTest'
+    assert args[2][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_fail'
+
+    assert args[3][0][0]['event'] == 'addFailure'
+    assert args[3][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_fail'
+    assert 'AssertionError' in args[3][0][0]['reason']
+    assert 'assertEqual(1+1, 3)' in args[3][0][0]['err']
+
+    assert args[4][0][0]['event'] == 'startTest'
+    assert args[4][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_ok'
+
+    assert args[5][0][0]['event'] == 'addSuccess'
+    assert args[5][0][0]['id'] == 'test_foo_unittestworker.MyTest.test_ok'
diff --git a/spyder_unittest/backend/workers/unittestworker.py b/spyder_unittest/backend/workers/unittestworker.py
new file mode 100644
index 0000000..f5d94e1
--- /dev/null
+++ b/spyder_unittest/backend/workers/unittestworker.py
@@ -0,0 +1,137 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see LICENSE.txt for details)
+"""
+Script for running unittest tests.
+
+This script is meant to be run in a separate process by a UnittestRunner.
+It runs tests via the unittest framework and transmits the results over a ZMQ
+socket so that the UnittestRunner can read them.
+
+Usage: python unittestworker.py port
+
+Here, port is the port number of the ZMQ socket. Use `file` to store the
+results in the file `unittestworker.json`.
+"""
+
+from __future__ import annotations
+
+# Standard library imports
+import sys
+from typing import ClassVar
+from unittest import (
+    defaultTestLoader, TestCase, TestSuite, TextTestResult, TextTestRunner)
+
+# Local imports
+# Note that the script can be run in an environment that does not contain
+# spyder_unittest so `from spyder_unittest.xxx import xxx` does not work.
+from zmqwriter import FileStub, ZmqStreamWriter
+
+
+class SpyderTestResult(TextTestResult):
+    """
+    Store test results and write them to a ZmqStreamWriter.
+
+    The member `.writer` should be set to a ZmqStreamWriter before
+    running any tests.
+    """
+
+    writer: ClassVar[ZmqStreamWriter]
+
+    def startTest(self, test: TestCase) -> None:
+        self.writer.write({
+            'event': 'startTest',
+            'id': test.id()
+        })
+        super().startTest(test)
+
+    def addSuccess(self, test: TestCase) -> None:
+        self.writer.write({
+            'event': 'addSuccess',
+            'id': test.id()
+        })
+        super().addSuccess(test)
+
+    def addError(self, test: TestCase, err) -> None:
+        (__, value, __) = err
+        first_line = str(value).splitlines()[0]
+        self.writer.write({
+            'event': 'addError',
+            'id': test.id(),
+            'reason': f'{type(value).__name__}: {first_line}',
+            'err': self._exc_info_to_string(err, test)
+        })
+        super().addError(test, err)
+
+    def addFailure(self, test: TestCase, err) -> None:
+        (__, value, __) = err
+        first_line = str(value).splitlines()[0]
+        self.writer.write({
+            'event': 'addFailure',
+            'id': test.id(),
+            'reason': f'{type(value).__name__}: {first_line}',
+            'err': self._exc_info_to_string(err, test)
+        })
+        super().addFailure(test, err)
+
+    def addSkip(self, test: TestCase, reason: str) -> None:
+        self.writer.write({
+            'event': 'addSkip',
+            'id': test.id(),
+            'reason': reason
+        })
+        super().addSkip(test, reason)
+
+    def addExpectedFailure(self, test: TestCase, err) -> None:
+        (__, value, __) = err
+        first_line = str(value).splitlines()[0]
+        self.writer.write({
+            'event': 'addExpectedFailure',
+            'id': test.id(),
+            'reason': f'{type(value).__name__}: {first_line}',
+            'err': self._exc_info_to_string(err, test)
+        })
+        super().addExpectedFailure(test, err)
+
+    def addUnexpectedSuccess(self, test: TestCase) -> None:
+        self.writer.write({
+            'event': 'addUnexpectedSuccess',
+            'id': test.id()
+        })
+        super().addUnexpectedSuccess(test)
+
+
+def report_collected(writer: ZmqStreamWriter, test_suite: TestSuite) -> None:
+    for test in test_suite:
+        if isinstance(test, TestSuite):
+            report_collected(writer, test)
+        else:
+            writer.write({
+                'event': 'collected',
+                'id': test.id()
+            })
+
+
+def main(args: list[str]) -> None:
+    """Run unittest tests."""
+    # Parse command line arguments and create writer
+    if args[1] != 'file':
+        writer = ZmqStreamWriter(args[1])
+    else:
+        writer = FileStub('unittestworker.log')
+    SpyderTestResult.writer = writer
+
+    # Gather tests
+    test_suite = defaultTestLoader.discover('.')
+    report_collected(writer, test_suite)
+
+    # Run tests
+    test_runner = TextTestRunner(verbosity=2, resultclass=SpyderTestResult)
+    test_runner.run(test_suite)
+    writer.close()
+
+
+if __name__ == '__main__':
+    main(sys.argv)
diff --git a/spyder_unittest/backend/workers/zmqwriter.py b/spyder_unittest/backend/workers/zmqwriter.py
index 13f983b..5e871f5 100644
--- a/spyder_unittest/backend/workers/zmqwriter.py
+++ b/spyder_unittest/backend/workers/zmqwriter.py
@@ -22,13 +22,13 @@
 class ZmqStreamWriter:
     """Writer for sending stream of Python object over a ZMQ stream."""
 
-    def __init__(self, port):
+    def __init__(self, port: str) -> None:
         """
         Constructor.
 
         Arguments
         ---------
-        port : int
+        port : str
             TCP port number to be used for the stream. This should equal the
             `port` attribute of the corresponding `ZmqStreamReader`.
         """
@@ -36,15 +36,31 @@ def __init__(self, port):
         self.socket = context.socket(zmq.PAIR)
         self.socket.connect('tcp://localhost:{}'.format(port))
 
-    def write(self, obj):
+    def write(self, obj: object) -> None:
         """Write arbitrary Python object to stream."""
         self.socket.send_pyobj(obj)
 
-    def close(self):
+    def close(self) -> None:
         """Close stream."""
         self.socket.close()
 
 
+class FileStub(ZmqStreamWriter):
+    """Stub for ZmqStreamWriter which instead writes to a file."""
+
+    def __init__(self, filename: str) -> None:
+        """Constructor; connect to specified filename."""
+        self.file = open(filename, 'w')
+
+    def write(self, obj: object) -> None:
+        """Write Python object to file."""
+        self.file.write(str(obj) + '\n')
+
+    def close(self) -> None:
+        """Close file."""
+        self.file.close()
+
+
 if __name__ == '__main__':
     # Usage: python zmqwriter.py <port>
     # Construct a ZMQ stream on the given port number and send the number 42
diff --git a/spyder_unittest/widgets/tests/test_unittestgui.py b/spyder_unittest/widgets/tests/test_unittestgui.py
index 946f21d..d50e1a9 100644
--- a/spyder_unittest/widgets/tests/test_unittestgui.py
+++ b/spyder_unittest/widgets/tests/test_unittestgui.py
@@ -206,15 +206,47 @@ def test_run_tests_using_unittest_and_display_results(
     MockQMessageBox.assert_not_called()
     model = widget.testdatamodel
     assert model.rowCount() == 2
-    assert model.index(0, 0).data(Qt.DisplayRole) == 'FAIL'
+    assert model.index(0, 0).data(Qt.DisplayRole) == 'failure'
     assert model.index(0, 1).data(Qt.DisplayRole) == 't.M.test_fail'
     assert model.index(0, 1).data(Qt.ToolTipRole) == 'test_foo.MyTest.test_fail'
-    assert model.index(0, 2).data(Qt.DisplayRole) == ''
-    assert model.index(1, 0).data(Qt.DisplayRole) == 'ok'
+    assert model.index(1, 0).data(Qt.DisplayRole) == 'success'
     assert model.index(1, 1).data(Qt.DisplayRole) == 't.M.test_ok'
     assert model.index(1, 1).data(Qt.ToolTipRole) == 'test_foo.MyTest.test_ok'
     assert model.index(1, 2).data(Qt.DisplayRole) == ''
 
+def test_run_tests_with_print_using_unittest_and_display_results(
+        qtbot, widget, tmpdir, monkeypatch):
+    """
+    Run a failing test which print to stderr using unittest and check
+    that it is displayed as a failing test.
+    Regression test for spyder-ide/spyder-unittest#160.
+    """
+    os.chdir(tmpdir.strpath)
+    testfilename = tmpdir.join('test_foo.py').strpath
+
+    with open(testfilename, 'w') as f:
+        f.write("import sys\n"
+                "import unittest\n"
+                "class MyTest(unittest.TestCase):\n"
+                "    def test_fail(self):\n"
+                "        print('text', file=sys.stderr)\n"
+                "        self.assertEqual(1+1, 3)\n"
+                "    def test_ok(self): self.assertEqual(1+1, 2)\n")
+
+    MockQMessageBox = Mock()
+    monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
+                        MockQMessageBox)
+
+    config = Config(wdir=tmpdir.strpath, framework='unittest', coverage=False)
+    with qtbot.waitSignal(widget.sig_finished, timeout=10000, raising=True):
+        widget.run_tests(config)
+
+    MockQMessageBox.assert_not_called()
+    model = widget.testdatamodel
+    assert model.rowCount() == 2
+    assert model.index(0, 0).data(Qt.DisplayRole) == 'failure'
+    assert model.index(1, 0).data(Qt.DisplayRole) == 'success'
+
 @pytest.mark.parametrize('framework', ['unittest', 'pytest', 'nose2'])
 def test_run_with_no_tests_discovered_and_display_results(
         qtbot, widget, tmpdir, monkeypatch, framework):