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

Issue:
Plugin does not work with Spyder 5
### What steps will reproduce the problem?

<!--- You can use Markdown here --->

Even after installing the below packages through anaconda navigator I don't find "Run Unit Test" option under Spyder IDEs "Run" menu or any Unit Testing tab. Please provide the resolution steps. Thanks!

spyder  5.0.1    py38haa244fe_0    conda-forge

spyder-kernels  2.0.2    py38haa244fe_0    conda-forge

spyder-unittest   0.3.1   py_1    conda-forge


Discussion / hints:
spyder-unittest hasn't been updated to work with Spyder 5, sorry. This will be fixed in the next couple of months.

I'm also moving this issue to the spyder-unittest repo because it really belongs there.
What's the status here? 
Checking in on this.
I have the latest spyder-5.1.1 and looking for the spyder-unittest plugin that works with it.
Thanks for your time and attention to this.
I started the work on converting the plugin to work with Spyder 5.2

Fix patch:
diff --git a/requirements/conda.txt b/requirements/conda.txt
index 611b24ff..17fcc16d 100644
--- a/requirements/conda.txt
+++ b/requirements/conda.txt
@@ -1,2 +1,2 @@
 lxml
-spyder
+spyder>=5.2
diff --git a/setup.py b/setup.py
index 6bca83b1..4f406cfa 100644
--- a/setup.py
+++ b/setup.py
@@ -37,7 +37,7 @@ def get_package_data(name, extlist):
 
 
 # Requirements
-REQUIREMENTS = ['lxml', 'spyder>=3', 'pyzmq']
+REQUIREMENTS = ['lxml', 'spyder>=5.2', 'pyzmq']
 EXTLIST = ['.jpg', '.png', '.json', '.mo', '.ini']
 LIBNAME = 'spyder_unittest'
 
@@ -72,4 +72,7 @@ def get_package_data(name, extlist):
         'Operating System :: OS Independent',
         'Programming Language :: Python :: 3',
         'Topic :: Software Development :: Testing',
-        'Topic :: Text Editors :: Integrated Development Environments (IDE)'])
+        'Topic :: Text Editors :: Integrated Development Environments (IDE)'],
+    entry_points={
+        'spyder.plugins': [
+            'unittest = spyder_unittest.unittestplugin:UnitTestPlugin']})


Test patch:
diff --git a/.github/workflows/linux-tests.yml b/.github/workflows/linux-tests.yml
index 4557f08d..f2acae52 100644
--- a/.github/workflows/linux-tests.yml
+++ b/.github/workflows/linux-tests.yml
@@ -19,7 +19,7 @@ jobs:
     strategy:
       fail-fast: false 
       matrix:
-        PYTHON_VERSION: ['3.6', '3.7', '3.8']
+        PYTHON_VERSION: ['3.7', '3.8', '3.9']
     steps:
       - name: Checkout branch
         uses: actions/checkout@v2
@@ -28,11 +28,10 @@ jobs:
           sudo apt-get update
           sudo apt-get install libegl1-mesa
       - name: Install Conda
-        uses: goanpeca/setup-miniconda@v1
+        uses: conda-incubator/setup-miniconda@v2
         with:
-           activate-environment: test
+           channels: conda-forge
            auto-update-conda: true
-           auto-activate-base: false
            python-version: ${{ matrix.PYTHON_VERSION }} 
       - name: Install package dependencies
         shell: bash -l {0}
@@ -55,6 +54,6 @@ jobs:
         run: xvfb-run --auto-servernum pytest spyder_unittest --cov=spyder_unittest -x -vv
         timeout-minutes: 10
       - name: Upload coverage to Codecov
-        if: matrix.PYTHON_VERSION == '3.8'
+        if: matrix.PYTHON_VERSION == '3.9'
         shell: bash -l {0}
         run: codecov -t 3458851b-c7a5-4108-be5e-9d19066a2fde
diff --git a/.github/workflows/macos-tests.yml b/.github/workflows/macos-tests.yml
index c1a6e044..25d88c0c 100644
--- a/.github/workflows/macos-tests.yml
+++ b/.github/workflows/macos-tests.yml
@@ -19,16 +19,15 @@ jobs:
     strategy:
       fail-fast: false 
       matrix:
-        PYTHON_VERSION: ['3.6', '3.7', '3.8']
+        PYTHON_VERSION: ['3.7', '3.8', '3.9']
     steps:
       - name: Checkout branch
         uses: actions/checkout@v2
       - name: Install Conda
-        uses: goanpeca/setup-miniconda@v1
+        uses: conda-incubator/setup-miniconda@v2
         with:
-           activate-environment: test
+           channels: conda-forge
            auto-update-conda: true
-           auto-activate-base: false
            python-version: ${{ matrix.PYTHON_VERSION }} 
       - name: Install package dependencies
         shell: bash -l {0}
diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml
index ee559e98..ddd8400d 100644
--- a/.github/workflows/windows-tests.yml
+++ b/.github/workflows/windows-tests.yml
@@ -19,16 +19,15 @@ jobs:
     strategy:
       fail-fast: false 
       matrix:
-        PYTHON_VERSION: ['3.6', '3.7', '3.8']
+        PYTHON_VERSION: ['3.7', '3.8', '3.9']
     steps:
       - name: Checkout branch
         uses: actions/checkout@v2
       - name: Install Conda
-        uses: goanpeca/setup-miniconda@v1
+        uses: conda-incubator/setup-miniconda@v2
         with:
-           activate-environment: test
+           channels: conda-forge
            auto-update-conda: true
-           auto-activate-base: false
            python-version: ${{ matrix.PYTHON_VERSION }}
       - name: Install package dependencies
         shell: bash -l {0}
diff --git a/spyder_unittest/backend/pytestrunner.py b/spyder_unittest/backend/pytestrunner.py
index fe83e9fc..dbc45596 100644
--- a/spyder_unittest/backend/pytestrunner.py
+++ b/spyder_unittest/backend/pytestrunner.py
@@ -98,7 +98,8 @@ def finished(self):
         """
         self.reader.close()
         output = self.read_all_process_output()
-        self.sig_finished.emit([] if "no tests ran" in output else None, output)
+        no_tests_ran = "no tests ran" in output.splitlines()[-1]
+        self.sig_finished.emit([] if no_tests_ran else None, output)
 
 
 def normalize_module_name(name):
diff --git a/spyder_unittest/tests/test_unittestplugin.py b/spyder_unittest/tests/test_unittestplugin.py
index 665eb993..9201bd36 100644
--- a/spyder_unittest/tests/test_unittestplugin.py
+++ b/spyder_unittest/tests/test_unittestplugin.py
@@ -7,16 +7,16 @@
 
 # Third party imports
 import pytest
-from spyder.plugins.projects.projecttypes import EmptyProject
+from spyder.plugins.projects.api import EmptyProject
 
 # Local imports
 from spyder_unittest.unittestplugin import UnitTestPlugin
 from spyder_unittest.widgets.configdialog import Config
 
 try:
-    from unittest.mock import Mock
+    from unittest.mock import MagicMock
 except ImportError:
-    from mock import Mock  # Python 2
+    from mock import MagicMock  # Python 2
 
 
 class PluginForTesting(UnitTestPlugin):
@@ -25,43 +25,42 @@ class PluginForTesting(UnitTestPlugin):
     def __init__(self, parent):
         UnitTestPlugin.__init__(self, parent)
 
+
 @pytest.fixture
 def plugin(qtbot):
     """Set up the unittest plugin."""
-    res = PluginForTesting(None)
-    qtbot.addWidget(res)
-    res.main = Mock()
-    res.main.get_spyder_pythonpath = lambda: 'fakepythonpath'
-    res.main.run_menu_actions = [42]
-    res.main.editor.pythonfile_dependent_actions = [42]
-    res.main.projects.get_active_project_path = lambda: None
-    res.register_plugin()
+    res = UnitTestPlugin(None, None)
+    res._main = MagicMock()
+    res._main.get_spyder_pythonpath = MagicMock(return_value='fakepythonpath')
+    res.initialize()
     return res
 
 
+@pytest.mark.skip('not clear how to test interactions between plugins')
 def test_plugin_initialization(plugin):
-    plugin.show()
     assert len(plugin.main.run_menu_actions) == 2
     assert plugin.main.run_menu_actions[1].text() == 'Run unit tests'
 
 
 def test_plugin_pythonpath(plugin):
     # Test signal/slot connection
-    plugin.main.sig_pythonpath_changed.connect.assert_called_with(
+    plugin.get_main().sig_pythonpath_changed.connect.assert_called_with(
         plugin.update_pythonpath)
 
     # Test pythonpath is set to path provided by Spyder
-    assert plugin.unittestwidget.pythonpath == 'fakepythonpath'
+    assert plugin.get_widget().pythonpath == 'fakepythonpath'
 
     # Test that change in path propagates
-    plugin.main.get_spyder_pythonpath = lambda: 'anotherpath'
+    plugin.get_main().get_spyder_pythonpath = MagicMock(
+        return_value='anotherpath')
     plugin.update_pythonpath()
-    assert plugin.unittestwidget.pythonpath == 'anotherpath'
+    assert plugin.get_widget().pythonpath == 'anotherpath'
 
 
+@pytest.mark.skip('not clear how to test interactions between plugins')
 def test_plugin_wdir(plugin, monkeypatch, tmpdir):
     # Test signal/slot connections
-    plugin.main.workingdirectory.set_explorer_cwd.connect.assert_called_with(
+    plugin.main.workingdirectory.sig_current_directory_changed.connect.assert_called_with(
         plugin.update_default_wdir)
     plugin.main.projects.sig_project_created.connect.assert_called_with(
         plugin.handle_project_change)
@@ -90,6 +89,7 @@ def test_plugin_wdir(plugin, monkeypatch, tmpdir):
     assert plugin.unittestwidget.default_wdir == 'fakecwd'
 
 
+@pytest.mark.skip('not clear how to test interactions between plugins')
 def test_plugin_config(plugin, tmpdir, qtbot):
     # Test config file does not exist and config is empty
     config_file_path = tmpdir.join('.spyproject', 'config', 'unittest.ini')
@@ -126,6 +126,7 @@ def test_plugin_config(plugin, tmpdir, qtbot):
     assert plugin.unittestwidget.config == config
 
 
+@pytest.mark.skip('not clear how to test interactions between plugins')
 def test_plugin_goto_in_editor(plugin, qtbot):
     plugin.unittestwidget.sig_edit_goto.emit('somefile', 42)
     plugin.main.editor.load.assert_called_with('somefile', 43, '')
diff --git a/spyder_unittest/unittestplugin.py b/spyder_unittest/unittestplugin.py
index 78a59361..b0b736ea 100644
--- a/spyder_unittest/unittestplugin.py
+++ b/spyder_unittest/unittestplugin.py
@@ -9,13 +9,12 @@
 import os.path as osp
 
 # Third party imports
-from qtpy.QtWidgets import QVBoxLayout
-from spyder.api.plugins import SpyderPluginWidget
+from spyder.api.plugins import Plugins, SpyderDockablePlugin
+from spyder.api.plugin_registration.decorators import on_plugin_available
 from spyder.config.base import get_translation
 from spyder.config.gui import is_dark_interface
+from spyder.plugins.mainmenu.api import ApplicationMenus
 from spyder.py3compat import PY2, getcwd
-from spyder.utils import icon_manager as ima
-from spyder.utils.qthelpers import create_action
 
 # Local imports
 from spyder_unittest.widgets.configdialog import Config
@@ -24,31 +23,167 @@
 _ = get_translation("unittest", dirname="spyder_unittest")
 
 
-class UnitTestPlugin(SpyderPluginWidget):
+class UnitTestPluginActions:
+    Run = 'Run tests'
+
+
+class UnitTestPlugin(SpyderDockablePlugin):
     """Spyder plugin for unit testing."""
 
-    CONF_SECTION = 'unittest'
+    NAME = 'unittest'
+    REQUIRES = []
+    OPTIONAL = [Plugins.Editor, Plugins.MainMenu, Plugins.Preferences,
+                Plugins.Projects, Plugins.WorkingDirectory]
+    TABIFY = [Plugins.VariableExplorer]
+    WIDGET_CLASS = UnitTestWidget
+
+    CONF_SECTION = NAME
     CONF_DEFAULTS = [(CONF_SECTION, {'framework': '', 'wdir': ''})]
     CONF_NAMEMAP = {CONF_SECTION: [(CONF_SECTION, ['framework', 'wdir'])]}
+    CONF_FILE = True
     CONF_VERSION = '0.1.0'
 
-    def __init__(self, parent):
+    # --- Mandatory SpyderDockablePlugin methods ------------------------------
+
+    @staticmethod
+    def get_name():
+        """
+        Return the plugin localized name.
+
+        Returns
+        -------
+        str
+            Localized name of the plugin.
+        """
+        return _('Unit testing')
+
+    def get_description(self):
+        """
+        Return the plugin localized description.
+
+        Returns
+        -------
+        str
+            Localized description of the plugin.
+        """
+        return _('Run test suites and view their results')
+
+    def get_icon(self):
+        """
+        Return the plugin associated icon.
+
+        Returns
+        -------
+        QIcon
+            QIcon instance
+        """
+        return self.create_icon('profiler')
+
+    def on_initialize(self):
+        """
+        Setup the plugin.
+        """
+        self.update_pythonpath()
+        self.get_main().sig_pythonpath_changed.connect(self.update_pythonpath)
+        self.get_widget().sig_newconfig.connect(self.save_config)
+
+        self.create_action(
+            UnitTestPluginActions.Run,
+            text=_('Run unit tests'),
+            tip=_('Run unit tests'),
+            icon=self.create_icon('profiler'),
+            triggered=self.maybe_configure_and_start,
+            register_shortcut=True)
+        #  TODO: shortcut="Shift+Alt+F11"
+
+    # --- Optional SpyderDockablePlugin methods -------------------------------
+
+    @staticmethod
+    def check_compatibility():
+        """
+        Check compatibility of the plugin.
+
+        This checks that the plugin is not run under Python 2.
+
+        Returns
+        -------
+        (bool, str)
+            The first value tells Spyder if the plugin has passed the
+            compatibility test defined in this method. The second value
+            is a message that must explain users why the plugin was
+            found to be incompatible (e.g. 'This plugin does not work
+            with PyQt4'). It will be shown at startup in a QMessageBox.
+        """
+        if PY2:
+            msg = _('The unittest plugin does not work with Python 2.')
+            return (False, msg)
+        else:
+            return (True, '')
+
+    # ----- Set up interactions with other plugins ----------------------------
+
+    @on_plugin_available(plugin=Plugins.Editor)
+    def on_editor_available(self):
+        """
+        Set up interactions when Editor plugin available.
+
+        Add 'Run unit tests' to context menu in editor for Python files.
+        Save all files in editor before running tests.
+        Go to test definition in editor on double click in unit test plugin.
+        """
+        editor = self.get_plugin(Plugins.Editor)
+        run_action = self.get_action(UnitTestPluginActions.Run)
+        editor.pythonfile_dependent_actions += [run_action]
+        # FIXME: Previous line does not do anything
+        self.get_widget().pre_test_hook = editor.save_all
+        self.get_widget().sig_edit_goto.connect(self.goto_in_editor)
+
+    @on_plugin_available(plugin=Plugins.MainMenu)
+    def on_main_menu_available(self):
+        """
+        Add 'Run unit tests' menu item when MainMenu plugin available.
+        """
+        mainmenu = self.get_plugin(Plugins.MainMenu)
+        run_action = self.get_action(UnitTestPluginActions.Run)
+        mainmenu.add_item_to_application_menu(
+            run_action, menu_id=ApplicationMenus.Run)
+
+    @on_plugin_available(plugin=Plugins.Preferences)
+    def on_preferences_available(self):
+        """
+        Use config when Preferences plugin available.
+
+        Specifically, find out whether Spyder uses a dark interface and
+        communicate this to the unittest widget.
+        """
+        self.get_widget().use_dark_interface(is_dark_interface())
+
+    @on_plugin_available(plugin=Plugins.Projects)
+    def on_projects_available(self):
         """
-        Initialize plugin and corresponding widget.
+        Connect when Projects plugin available.
 
-        The part of the initialization that depends on `parent` is done in
-        `self.register_plugin()`.
+        Connect to signals emitted when the current project changes.
         """
-        SpyderPluginWidget.__init__(self, parent)
+        projects = self.get_plugin(Plugins.Projects)
+        projects.sig_project_created.connect(self.handle_project_change)
+        projects.sig_project_loaded.connect(self.handle_project_change)
+        projects.sig_project_closed.connect(self.handle_project_change)
+
+    @on_plugin_available(plugin=Plugins.WorkingDirectory)
+    def on_working_directory_available(self):
+        """
+        Connect when WorkingDirectory plugin available.
+
+        Find out what the current working directory is and connect to the
+        signal emitted when the current working directory changes.
+        """
+        working_directory = self.get_plugin(Plugins.WorkingDirectory)
+        working_directory.sig_current_directory_changed.connect(
+            self.update_default_wdir)
+        self.update_default_wdir()
 
-        # Create unit test widget and add to dockwindow
-        self.unittestwidget = UnitTestWidget(
-            self.main,
-            options_button=self.options_button,
-            options_menu=self._options_menu)
-        layout = QVBoxLayout()
-        layout.addWidget(self.unittestwidget)
-        self.setLayout(layout)
+    # --- UnitTestPlugin methods ----------------------------------------------
 
     def update_pythonpath(self):
         """
@@ -58,7 +193,7 @@ def update_pythonpath(self):
         It synchronizes the Python path in the unittest widget with the Python
         path in Spyder.
         """
-        self.unittestwidget.pythonpath = self.main.get_spyder_pythonpath()
+        self.get_widget().pythonpath = self.get_main().get_spyder_pythonpath()
 
     def handle_project_change(self):
         """
@@ -79,10 +214,14 @@ def update_default_wdir(self):
         project is opened. This function is called whenever this directory may
         change.
         """
-        wdir = self.main.projects.get_active_project_path()
-        if not wdir:  # if no project opened
+        projects = self.get_plugin(Plugins.Projects)
+        if projects:
+            wdir = projects.get_active_project_path()
+            if not wdir:  # if no project opened
+                wdir = getcwd()
+        else:
             wdir = getcwd()
-        self.unittestwidget.default_wdir = wdir
+        self.get_widget().default_wdir = wdir
 
     def load_config(self):
         """
@@ -96,9 +235,15 @@ def load_config(self):
         If necessary, patch the project preferences to include this plugin's
         config options.
         """
-        project = self.main.projects.get_active_project()
+        widget = self.get_widget()
+        projects_plugin = self.get_plugin(Plugins.Projects)
+        if projects_plugin:
+            project = projects_plugin.get_active_project()
+        else:
+            project = None
+
         if not project:
-            self.unittestwidget.set_config_without_emit(None)
+            widget.set_config_without_emit(None)
             return
 
         if self.CONF_SECTION not in project.config._name_map:
@@ -117,11 +262,11 @@ def load_config(self):
             project.config._configs_map[self.CONF_SECTION] = conf
 
         new_config = Config(
-            framework=project.get_option(self.CONF_SECTION, 'framework'),
-            wdir=project.get_option(self.CONF_SECTION, 'wdir'))
-        if not self.unittestwidget.config_is_valid(new_config):
+            framework=project.get_option('framework', self.CONF_SECTION),
+            wdir=project.get_option('wdir', self.CONF_SECTION))
+        if not widget.config_is_valid(new_config):
             new_config = None
-        self.unittestwidget.set_config_without_emit(new_config)
+        widget.set_config_without_emit(new_config)
 
     def save_config(self, test_config):
         """
@@ -129,12 +274,15 @@ def save_config(self, test_config):
 
         If no project is opened, then do not save.
         """
-        project = self.main.projects.get_active_project()
+        projects_plugin = self.get_plugin(Plugins.Projects)
+        if not projects_plugin:
+            return
+        project = projects_plugin.get_active_project()
         if not project:
             return
-        project.set_option(self.CONF_SECTION, 'framework',
-                           test_config.framework)
-        project.set_option(self.CONF_SECTION, 'wdir', test_config.wdir)
+        project.set_option('framework', test_config.framework,
+                           self.CONF_SECTION)
+        project.set_option('wdir', test_config.wdir, self.CONF_SECTION)
 
     def goto_in_editor(self, filename, lineno):
         """
@@ -144,101 +292,12 @@ def goto_in_editor(self, filename, lineno):
         Note that the line number in the signal is zero based (the first line
         is line 0), but the editor expects a one-based line number.
         """
-        self.main.editor.load(filename, lineno + 1, '')
-
-# ----- SpyderPluginWidget API --------------------------------------------
-
-    def get_plugin_title(self):
-        """Return widget title."""
-        return _("Unit testing")
-
-    def get_plugin_icon(self):
-        """Return widget icon."""
-        return ima.icon('profiler')
-
-    def get_focus_widget(self):
-        """Return the widget to give focus to this dockwidget when raised."""
-        return self.unittestwidget.testdataview
-
-    def get_plugin_actions(self):
-        """Return a list of actions related to plugin."""
-        return self.unittestwidget.create_actions()
-
-    def on_first_registration(self):
-        """Action to be performed on first plugin registration."""
-        self.main.tabify_plugins(self.main.help, self)
-        self.dockwidget.hide()
-
-    def register_plugin(self):
-        """Register plugin in Spyder's main window."""
-        super(UnitTestPlugin, self).register_plugin()
-
-        # Get information from Spyder proper into plugin
-        self.update_pythonpath()
-        self.update_default_wdir()
-        self.unittestwidget.use_dark_interface(is_dark_interface())
-
-        # Connect to relevant signals
-        self.main.sig_pythonpath_changed.connect(self.update_pythonpath)
-        self.main.workingdirectory.set_explorer_cwd.connect(
-            self.update_default_wdir)
-        self.main.projects.sig_project_created.connect(
-            self.handle_project_change)
-        self.main.projects.sig_project_loaded.connect(
-            self.handle_project_change)
-        self.main.projects.sig_project_closed.connect(
-            self.handle_project_change)
-        self.unittestwidget.sig_newconfig.connect(self.save_config)
-        self.unittestwidget.sig_edit_goto.connect(self.goto_in_editor)
-
-        # Create action and add it to Spyder's menu
-        unittesting_act = create_action(
-            self,
-            _("Run unit tests"),
-            icon=ima.icon('profiler'),
-            shortcut="Shift+Alt+F11",
-            triggered=self.maybe_configure_and_start)
-        self.main.run_menu_actions += [unittesting_act]
-        self.main.editor.pythonfile_dependent_actions += [unittesting_act]
-
-        # Save all files before running tests
-        self.unittestwidget.pre_test_hook = self.main.editor.save_all
-
-    def refresh_plugin(self):
-        """Refresh unit testing widget."""
-        self._options_menu.clear()
-        self.get_plugin_actions()
-
-    def closing_plugin(self, cancelable=False):
-        """Perform actions before parent main window is closed."""
-        return True
-
-    def apply_plugin_settings(self, options):
-        """Apply configuration file's plugin settings."""
-        pass
-
-    def check_compatibility(self):
-        """
-        Check compatibility of the plugin.
-
-        This checks that the plugin is not run under Python 2.
-
-        Returns
-        -------
-        (bool, str)
-            The first value tells Spyder if the plugin has passed the
-            compatibility test defined in this method. The second value
-            is a message that must explain users why the plugin was
-            found to be incompatible (e.g. 'This plugin does not work
-            with PyQt4'). It will be shown at startup in a QMessageBox.
-        """
-        if PY2:
-            msg = _('The unittest plugin does not work with Python 2.')
-            return (False, msg)
-        else:
-            return (True, '')
+        editor_plugin = self.get_plugin(Plugins.Editor)
+        if editor_plugin:
+            editor_plugin.load(filename, lineno + 1, '')
 
     # ----- Public API --------------------------------------------------------
+
     def maybe_configure_and_start(self):
         """
         Ask for configuration if necessary and then run tests.
@@ -247,6 +306,5 @@ def maybe_configure_and_start(self):
         not valid (or not set), then ask the user to configure. Then
         run the tests.
         """
-        if self.dockwidget:
-            self.switch_to_plugin()
-        self.unittestwidget.maybe_configure_and_start()
+        self.switch_to_plugin()
+        self.get_widget().maybe_configure_and_start()
diff --git a/spyder_unittest/widgets/tests/test_unittestgui.py b/spyder_unittest/widgets/tests/test_unittestgui.py
index c8018d47..292ba68d 100644
--- a/spyder_unittest/widgets/tests/test_unittestgui.py
+++ b/spyder_unittest/widgets/tests/test_unittestgui.py
@@ -23,59 +23,56 @@
     from mock import Mock  # Python 2
 
 
-def test_unittestwidget_forwards_sig_edit_goto(qtbot):
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
+@pytest.fixture
+def widget(qtbot):
+    unittest_widget = UnitTestWidget('testwidget', None, None)
+    unittest_widget.setup()
+    qtbot.addWidget(unittest_widget)
+    return unittest_widget
+
+def use_mock_model(widget):
+    """Replace data model in unit test widget with mock model."""
+    widget.testdatamodel = Mock()
+    widget.testdatamodel.summary = lambda: 'message'
+    widget.testdatamodel.testresults = []
+
+def test_unittestwidget_forwards_sig_edit_goto(qtbot, widget):
     with qtbot.waitSignal(widget.sig_edit_goto) as blocker:
         widget.testdataview.sig_edit_goto.emit('ham', 42)
     assert blocker.args == ['ham', 42]
 
-def test_unittestwidget_set_config_emits_newconfig(qtbot):
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
+def test_unittestwidget_set_config_emits_newconfig(qtbot, widget):
     config = Config(wdir=os.getcwd(), framework='unittest')
     with qtbot.waitSignal(widget.sig_newconfig) as blocker:
         widget.config = config
     assert blocker.args == [config]
     assert widget.config == config
 
-def test_unittestwidget_set_config_does_not_emit_when_invalid(qtbot):
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
+def test_unittestwidget_set_config_does_not_emit_when_invalid(qtbot, widget):
     config = Config(wdir=os.getcwd(), framework=None)
     with qtbot.assertNotEmitted(widget.sig_newconfig):
         widget.config = config
     assert widget.config == config
 
-def test_unittestwidget_config_with_unknown_framework_invalid(qtbot):
+def test_unittestwidget_config_with_unknown_framework_invalid(widget):
     """Check that if the framework in the config is not known,
     config_is_valid() returns False"""
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
     config = Config(wdir=os.getcwd(), framework='unknown framework')
     assert widget.config_is_valid(config) == False
 
-def test_unittestwidget_process_finished_updates_results(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
-    widget.testdatamodel.summary = lambda: 'message'
-    widget.testdatamodel.testresults = []
+def test_unittestwidget_process_finished_updates_results(widget):
     results = [TestResult(Category.OK, 'ok', 'hammodule.spam')]
     widget.process_finished(results, 'output')
     assert widget.testdatamodel.testresults == results
 
-def test_unittestwidget_process_finished_with_results_none(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
-    widget.testdatamodel.summary = lambda: 'message'
+def test_unittestwidget_process_finished_with_results_none(widget):
     results = [TestResult(Category.OK, 'ok', 'hammodule.spam')]
     widget.testdatamodel.testresults = results
     widget.process_finished(None, 'output')
     assert widget.testdatamodel.testresults == results
 
-def test_unittestwidget_replace_pending_with_not_run(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
+def test_unittestwidget_replace_pending_with_not_run(widget):
+    use_mock_model(widget)
     results = [TestResult(Category.PENDING, 'pending', 'hammodule.eggs'),
                TestResult(Category.OK, 'ok', 'hammodule.spam')]
     widget.testdatamodel.testresults = results
@@ -83,47 +80,41 @@ def test_unittestwidget_replace_pending_with_not_run(qtbot):
     expected = [TestResult(Category.SKIP, 'not run', 'hammodule.eggs')]
     widget.testdatamodel.update_testresults.assert_called_once_with(expected)
 
-def test_unittestwidget_tests_collected(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
+def test_unittestwidget_tests_collected(widget):
+    use_mock_model(widget)
     details = ['hammodule.spam', 'hammodule.eggs']
     widget.tests_collected(details)
     results = [TestResult(Category.PENDING, 'pending', 'hammodule.spam'),
                TestResult(Category.PENDING, 'pending', 'hammodule.eggs')]
     widget.testdatamodel.add_testresults.assert_called_once_with(results)
 
-def test_unittestwidget_tests_started(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
+def test_unittestwidget_tests_started(widget):
+    use_mock_model(widget)
     details = ['hammodule.spam']
     results = [TestResult(Category.PENDING, 'pending', 'hammodule.spam', 'running')]
     widget.tests_started(details)
     widget.testdatamodel.update_testresults.assert_called_once_with(results)
 
-def test_unittestwidget_tests_collect_error(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
+def test_unittestwidget_tests_collect_error(widget):
+    use_mock_model(widget)
     names_plus_msg = [('hammodule.spam', 'msg')]
     results = [TestResult(Category.FAIL, 'failure', 'hammodule.spam',
                           'collection error', extra_text='msg')]
     widget.tests_collect_error(names_plus_msg)
     widget.testdatamodel.add_testresults.assert_called_once_with(results)
 
-def test_unittestwidget_tests_yield_results(qtbot):
-    widget = UnitTestWidget(None)
-    widget.testdatamodel = Mock()
+def test_unittestwidget_tests_yield_results(widget):
+    use_mock_model(widget)
     results = [TestResult(Category.OK, 'ok', 'hammodule.spam')]
     widget.tests_yield_result(results)
     widget.testdatamodel.update_testresults.assert_called_once_with(results)
 
-def test_unittestwidget_set_message(qtbot):
-    widget = UnitTestWidget(None)
+def test_unittestwidget_set_message(widget):
     widget.status_label = Mock()
     widget.set_status_label('xxx')
     widget.status_label.setText.assert_called_once_with('<b>xxx</b>')
 
-def test_run_tests_starts_testrunner(qtbot):
-    widget = UnitTestWidget(None)
+def test_run_tests_starts_testrunner(widget):
     mockRunner = Mock()
     widget.framework_registry.create_runner = Mock(return_value=mockRunner)
     config = Config(wdir=None, framework='ham')
@@ -132,17 +123,15 @@ def test_run_tests_starts_testrunner(qtbot):
     assert widget.framework_registry.create_runner.call_args[0][0] == 'ham'
     assert mockRunner.start.call_count == 1
 
-def test_run_tests_with_pre_test_hook_returning_true(qtbot):
-    widget = UnitTestWidget(None)
+def test_run_tests_with_pre_test_hook_returning_true(widget):
     mockRunner = Mock()
     widget.framework_registry.create_runner = Mock(return_value=mockRunner)
-    widget.pre_test_hook =  Mock(return_value=True)
+    widget.pre_test_hook = Mock(return_value=True)
     widget.run_tests(Config())
     assert widget.pre_test_hook.call_count == 1
     assert mockRunner.start.call_count == 1
 
-def test_run_tests_with_pre_test_hook_returning_false(qtbot):
-    widget = UnitTestWidget(None)
+def test_run_tests_with_pre_test_hook_returning_false(widget):
     mockRunner = Mock()
     widget.framework_registry.create_runner = Mock(return_value=mockRunner)
     widget.pre_test_hook =  Mock(return_value=False)
@@ -153,13 +142,12 @@ def test_run_tests_with_pre_test_hook_returning_false(qtbot):
 @pytest.mark.parametrize('results,label',
                          [([TestResult(Category.OK, 'ok', '')], '0 tests failed, 1 passed'),
                           ([], 'No results to show.')])
-def test_unittestwidget_process_finished_updates_status_label(qtbot, results, label):
-    widget = UnitTestWidget(None)
+def test_unittestwidget_process_finished_updates_status_label(widget, results, label):
     widget.process_finished(results, 'output')
     assert widget.status_label.text() == '<b>{}</b>'.format(label)
 
 @pytest.mark.parametrize('framework', ['pytest', 'nose'])
-def test_run_tests_and_display_results(qtbot, tmpdir, monkeypatch, framework):
+def test_run_tests_and_display_results(qtbot, widget, tmpdir, monkeypatch, framework):
     """Basic integration test."""
     os.chdir(tmpdir.strpath)
     testfilename = tmpdir.join('test_foo.py').strpath
@@ -172,8 +160,6 @@ def test_run_tests_and_display_results(qtbot, tmpdir, monkeypatch, framework):
     monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
                         MockQMessageBox)
 
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
     config = Config(wdir=tmpdir.strpath, framework=framework)
     with qtbot.waitSignal(widget.sig_finished, timeout=10000, raising=True):
         widget.run_tests(config)
@@ -192,8 +178,8 @@ def test_run_tests_and_display_results(qtbot, tmpdir, monkeypatch, framework):
     assert model.index(1, 1).data(Qt.ToolTipRole) == 'test_foo.test_fail'
 
 
-def test_run_tests_using_unittest_and_display_results(qtbot, tmpdir,
-                                                      monkeypatch):
+def test_run_tests_using_unittest_and_display_results(
+        qtbot, widget, tmpdir, monkeypatch):
     """Basic check."""
     os.chdir(tmpdir.strpath)
     testfilename = tmpdir.join('test_foo.py').strpath
@@ -208,8 +194,6 @@ def test_run_tests_using_unittest_and_display_results(qtbot, tmpdir,
     monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
                         MockQMessageBox)
 
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
     config = Config(wdir=tmpdir.strpath, framework='unittest')
     with qtbot.waitSignal(widget.sig_finished, timeout=10000, raising=True):
         widget.run_tests(config)
@@ -227,8 +211,8 @@ def test_run_tests_using_unittest_and_display_results(qtbot, tmpdir,
     assert model.index(1, 2).data(Qt.DisplayRole) == ''
 
 @pytest.mark.parametrize('framework', ['unittest', 'pytest', 'nose'])
-def test_run_with_no_tests_discovered_and_display_results(qtbot, tmpdir,
-                                                          monkeypatch, framework):
+def test_run_with_no_tests_discovered_and_display_results(
+        qtbot, widget, tmpdir, monkeypatch, framework):
     """Basic integration test."""
     os.chdir(tmpdir.strpath)
 
@@ -236,8 +220,6 @@ def test_run_with_no_tests_discovered_and_display_results(qtbot, tmpdir,
     monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
                         MockQMessageBox)
 
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
     config = Config(wdir=tmpdir.strpath, framework=framework)
     with qtbot.waitSignal(widget.sig_finished, timeout=10000, raising=True):
         widget.run_tests(config)
@@ -247,7 +229,7 @@ def test_run_with_no_tests_discovered_and_display_results(qtbot, tmpdir,
     assert model.rowCount() == 0
     assert widget.status_label.text() == '<b>No results to show.</b>'
 
-def test_stop_running_tests_before_testresult_is_received(qtbot, tmpdir):
+def test_stop_running_tests_before_testresult_is_received(qtbot, widget, tmpdir):
     os.chdir(tmpdir.strpath)
     testfilename = tmpdir.join('test_foo.py').strpath
 
@@ -259,8 +241,6 @@ def test_stop_running_tests_before_testresult_is_received(qtbot, tmpdir):
                 "      time.sleep(3)\n"
                 "      self.assertTrue(True)\n")
 
-    widget = UnitTestWidget(None)
-    qtbot.addWidget(widget)
     config = Config(wdir=tmpdir.strpath, framework='unittest')
     widget.run_tests(config)
     qtbot.waitUntil(lambda: widget.testrunner.process.state() == QProcess.Running)
@@ -270,11 +250,10 @@ def test_stop_running_tests_before_testresult_is_received(qtbot, tmpdir):
     assert widget.status_label.text() == ''
 
 
-def test_show_versions(monkeypatch):
+def test_show_versions(monkeypatch, widget):
     mockQMessageBox = Mock()
     monkeypatch.setattr('spyder_unittest.widgets.unittestgui.QMessageBox',
                         mockQMessageBox)
-    widget = UnitTestWidget(None)
     monkeypatch.setattr(widget.framework_registry.frameworks['nose'],
                         'is_installed', lambda: False)
     monkeypatch.setattr(widget.framework_registry.frameworks['pytest'],
diff --git a/spyder_unittest/widgets/unittestgui.py b/spyder_unittest/widgets/unittestgui.py
index 99090e42..86cda3da 100644
--- a/spyder_unittest/widgets/unittestgui.py
+++ b/spyder_unittest/widgets/unittestgui.py
@@ -14,11 +14,10 @@
 
 # Third party imports
 from qtpy.QtCore import Signal
-from qtpy.QtWidgets import (QHBoxLayout, QLabel, QMenu, QMessageBox,
-                            QToolButton, QVBoxLayout, QWidget)
+from qtpy.QtWidgets import QLabel, QMessageBox, QVBoxLayout
+from spyder.api.widgets.main_widget import PluginMainWidget
 from spyder.config.base import get_conf_path, get_translation
 from spyder.utils import icon_manager as ima
-from spyder.utils.qthelpers import create_action, create_toolbutton
 from spyder.plugins.variableexplorer.widgets.texteditor import TextEditor
 from spyder.py3compat import PY3
 
@@ -50,7 +49,26 @@
     FRAMEWORKS = {NoseRunner, UnittestRunner}
 
 
-class UnitTestWidget(QWidget):
+class UnitTestWidgetActions:
+    RunTests = 'run_tests'
+    Config = 'config'
+    ShowLog = 'show_log'
+    CollapseAll = 'collapse_all'
+    ExpandAll = 'expand_all'
+    ShowDependencies = 'show_dependencies'
+
+
+class UnitTestWidgetButtons:
+    Start = 'start'
+
+
+class UnitTestWidgetToolbar:
+    LeftStretcher = 'left_stretcher'
+    StatusLabel = 'status_label'
+    RightStretcher = 'right_stretcher'
+
+
+class UnitTestWidget(PluginMainWidget):
     """
     Unit testing widget.
 
@@ -86,11 +104,9 @@ class UnitTestWidget(QWidget):
     sig_newconfig = Signal(Config)
     sig_edit_goto = Signal(str, int)
 
-    def __init__(self, parent, options_button=None, options_menu=None):
+    def __init__(self, name, plugin, parent):
         """Unit testing widget."""
-        QWidget.__init__(self, parent)
-
-        self.setWindowTitle("Unit testing")
+        super().__init__(name, plugin, parent)
 
         self.config = None
         self.pythonpath = None
@@ -109,37 +125,104 @@ def __init__(self, parent, options_button=None, options_menu=None):
         for runner in FRAMEWORKS:
             self.framework_registry.register(runner)
 
-        self.start_button = create_toolbutton(self, text_beside_icon=True)
+        layout = QVBoxLayout()
+        layout.addWidget(self.testdataview)
+        self.setLayout(layout)
+
+    # --- Mandatory PluginMainWidget methods ----------------------------------
+
+    def get_title(self):
+        """
+        Return the title that will be displayed on dockwidget or window title.
+        """
+        return _('Unit testing')
+
+    def setup(self):
+        """
+        Create widget actions, add to menu and other setup requirements.
+        """
+
+        # Options menu
+
+        menu = self.get_options_menu()
+
+        config_action = self.create_action(
+            UnitTestWidgetActions.Config,
+            text=_('Configure ...'),
+            icon=self.create_icon('configure'),
+            triggered=self.configure)
+        self.add_item_to_menu(config_action, menu)
+
+        self.show_log_action = self.create_action(
+            UnitTestWidgetActions.ShowLog,
+            text=_('Show output'),
+            icon=self.create_icon('log'),
+            triggered=self.show_log)
+        self.add_item_to_menu(self.show_log_action, menu)
+
+        collapse_all_action = self.create_action(
+            UnitTestWidgetActions.CollapseAll,
+            text=_('Collapse all'),
+            icon=self.create_icon('collapse'),
+            triggered=self.testdataview.collapseAll)
+        self.add_item_to_menu(collapse_all_action, menu)
+
+        expand_all_action = self.create_action(
+            UnitTestWidgetActions.ExpandAll,
+            text=_('Expand all'),
+            icon=self.create_icon('expand'),
+            triggered=self.testdataview.expandAll)
+        self.add_item_to_menu(expand_all_action, menu)
+
+        show_dependencies_action = self.create_action(
+            UnitTestWidgetActions.ShowDependencies,
+            text=_('Dependencies'),
+            icon=self.create_icon('advanced'),
+            triggered=self.show_versions)
+        self.add_item_to_menu(show_dependencies_action, menu)
+
+        # Other widgets in the main toolbar
+
+        toolbar = self.get_main_toolbar()
+
+        self.start_button = self.create_toolbutton(UnitTestWidgetButtons.Start)
         self.set_running_state(False)
+        self.add_item_to_toolbar(self.start_button, toolbar=toolbar)
 
-        self.status_label = QLabel('', self)
+        self.add_item_to_toolbar(
+            self.create_stretcher(id_=UnitTestWidgetToolbar.LeftStretcher),
+            toolbar=toolbar)
 
-        self.create_actions()
+        self.status_label = QLabel('')
+        self.status_label.ID = UnitTestWidgetToolbar.StatusLabel
+        self.add_item_to_toolbar(self.status_label, toolbar=toolbar)
 
-        self.options_menu = options_menu or QMenu()
-        self.options_menu.addAction(self.config_action)
-        self.options_menu.addAction(self.log_action)
-        self.options_menu.addAction(self.collapse_action)
-        self.options_menu.addAction(self.expand_action)
-        self.options_menu.addAction(self.versions_action)
+        self.add_item_to_toolbar(
+            self.create_stretcher(id_=UnitTestWidgetToolbar.RightStretcher),
+            toolbar=toolbar)
 
-        self.options_button = options_button or QToolButton(self)
-        self.options_button.setIcon(ima.icon('tooloptions'))
-        self.options_button.setPopupMode(QToolButton.InstantPopup)
-        self.options_button.setMenu(self.options_menu)
-        self.options_button.setAutoRaise(True)
+    def update_actions(self):
+        """
+        Update the state of exposed actions.
 
-        hlayout = QHBoxLayout()
-        hlayout.addWidget(self.start_button)
-        hlayout.addStretch()
-        hlayout.addWidget(self.status_label)
-        hlayout.addStretch()
-        hlayout.addWidget(self.options_button)
+        Exposed actions are actions created by the self.create_action method.
+        """
+        pass
 
-        layout = QVBoxLayout()
-        layout.addLayout(hlayout)
-        layout.addWidget(self.testdataview)
-        self.setLayout(layout)
+    # --- Optional PluginMainWidget methods -----------------------------------
+
+    def get_focus_widget(self):
+        """
+        Return the test data view as the widget to give focus to.
+
+        Returns
+        -------
+        QWidget
+            QWidget to give focus to.
+        """
+        return self.testdataview
+
+    # --- UnitTestWidget methods ----------------------------------------------
 
     @property
     def config(self):
@@ -161,45 +244,14 @@ def use_dark_interface(self, flag):
         """Set whether widget should use colours appropriate for dark UI."""
         self.testdatamodel.is_dark_interface = flag
 
-    def create_actions(self):
-        """Create the actions for the unittest widget."""
-        self.config_action = create_action(
-            self,
-            text=_("Configure ..."),
-            icon=ima.icon('configure'),
-            triggered=self.configure)
-        self.log_action = create_action(
-            self,
-            text=_('Show output'),
-            icon=ima.icon('log'),
-            triggered=self.show_log)
-        self.collapse_action = create_action(
-            self,
-            text=_('Collapse all'),
-            icon=ima.icon('collapse'),
-            triggered=self.testdataview.collapseAll)
-        self.expand_action = create_action(
-            self,
-            text=_('Expand all'),
-            icon=ima.icon('expand'),
-            triggered=self.testdataview.expandAll)
-        self.versions_action = create_action(
-            self,
-            text=_('Dependencies'),
-            icon=ima.icon('advanced'),
-            triggered=self.show_versions)
-        return [
-            self.config_action, self.log_action, self.collapse_action,
-            self.expand_action, self.versions_action
-        ]
-
     def show_log(self):
         """Show output of testing process."""
         if self.output:
             te = TextEditor(
                 self.output,
                 title=_("Unit testing output"),
-                readonly=True)
+                readonly=True,
+                parent=self)
             te.show()
             te.exec_()
 
@@ -221,7 +273,7 @@ def configure(self):
         else:
             oldconfig = Config(wdir=self.default_wdir)
         frameworks = self.framework_registry.frameworks
-        config = ask_for_config(frameworks, oldconfig)
+        config = ask_for_config(frameworks, oldconfig, parent=self)
         if config:
             self.config = config
 
@@ -343,7 +395,7 @@ def process_finished(self, testresults, output):
         self.output = output
         self.set_running_state(False)
         self.testrunner = None
-        self.log_action.setEnabled(bool(output))
+        self.show_log_action.setEnabled(bool(output))
         if testresults is not None:
             self.testdatamodel.testresults = testresults
         self.replace_pending_with_not_run()