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

Issue:
Fix plugin tests
The tests in `test_unittestplugin.py` were disabled in PR #166. They test the interaction of this plugin with the rest of Spyder, which in Spyder 5 means other plugin. These tests need to be fixed so that they can be enabled again.


Discussion / hints:

Fix patch:
diff --git a/.github/scripts/generate-without-spyder.py b/.github/scripts/generate-without-spyder.py
new file mode 100644
index 00000000..26a94a63
--- /dev/null
+++ b/.github/scripts/generate-without-spyder.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python
+#
+# Copyright (c) Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see LICENSE.txt for details)
+
+"""Script to generate requirements/without-spyder.txt"""
+
+import re
+
+with open('requirements/conda.txt') as infile:
+    with open('requirements/without-spyder.txt', 'w') as outfile:
+        for line in infile:
+            package_name = re.match('[-a-z0-9_]*', line).group(0)
+            if package_name != 'spyder':
+                outfile.write(line)
+
diff --git a/requirements/conda.txt b/requirements/conda.txt
index cb8205f8..aae1ad93 100644
--- a/requirements/conda.txt
+++ b/requirements/conda.txt
@@ -1,2 +1,2 @@
 lxml
-spyder>=5.3.1,<6
+spyder>=6.0.0.dev0,<7
diff --git a/setup.py b/setup.py
index 8931c86c..6fa09873 100644
--- a/setup.py
+++ b/setup.py
@@ -37,7 +37,7 @@ def get_package_data(name, extlist):
 
 
 # Requirements
-REQUIREMENTS = ['lxml', 'spyder>=5.3.1,<6', 'pyzmq']
+REQUIREMENTS = ['lxml', 'spyder>=6.0.0.dev0,<7', 'pyzmq']
 EXTLIST = ['.jpg', '.png', '.json', '.mo', '.ini']
 LIBNAME = 'spyder_unittest'
 


Test patch:
diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 0c05a330..30071f95 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -15,20 +15,7 @@ jobs:
       matrix:
         OS: ['ubuntu', 'macos', 'windows']
         PYTHON_VERSION: ['3.8', '3.9', '3.10']
-        SPYDER_SOURCE: ['conda', 'git']
-        exclude:
-          - OS: 'macos'
-            PYTHON_VERSION: '3.8'
-            SPYDER_SOURCE: 'git'
-          - OS: 'macos'
-            PYTHON_VERSION: '3.9'
-            SPYDER_SOURCE: 'git'
-          - OS: 'windows'
-            PYTHON_VERSION: '3.8'
-            SPYDER_SOURCE: 'git'
-          - OS: 'windows'
-            PYTHON_VERSION: '3.9'
-            SPYDER_SOURCE: 'git'
+        SPYDER_SOURCE: ['git']
     name: ${{ matrix.OS }} py${{ matrix.PYTHON_VERSION }} spyder-from-${{ matrix.SPYDER_SOURCE }}
     runs-on: ${{ matrix.OS }}-latest
     env:
@@ -70,7 +57,14 @@ jobs:
         if: matrix.SPYDER_SOURCE == 'git'
         shell: bash -l {0}
         run: pip install --no-deps -e spyder
+      - name: Install plugin dependencies (without Spyder)
+        if: matrix.SPYDER_SOURCE == 'git'
+        shell: bash -l {0}
+        run: |
+          python .github/scripts/generate-without-spyder.py
+          mamba install --file requirements/without-spyder.txt -y
       - name: Install plugin dependencies
+        if: matrix.SPYDER_SOURCE == 'conda'
         shell: bash -l {0}
         run: mamba install --file requirements/conda.txt -y
       - name: Install test dependencies
diff --git a/conftest.py b/conftest.py
new file mode 100644
index 00000000..131e1454
--- /dev/null
+++ b/conftest.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+#
+
+"""Configuration file for Pytest."""
+
+# Standard library imports
+import os
+
+# To activate/deactivate certain things in Spyder when running tests.
+# NOTE: Please leave this before any other import here!!
+os.environ['SPYDER_PYTEST'] = 'True'
+
+# Third-party imports
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def reset_conf_before_test():
+    from spyder.config.manager import CONF
+    CONF.reset_to_defaults(notification=False)
diff --git a/requirements/tests.txt b/requirements/tests.txt
index 4a80c451..79b5c8fb 100644
--- a/requirements/tests.txt
+++ b/requirements/tests.txt
@@ -3,5 +3,4 @@ flaky
 nose
 pytest>=5
 pytest-cov
-pytest-mock
 pytest-qt
diff --git a/spyder_unittest/__init__.py b/spyder_unittest/__init__.py
index 499c3473..48a33488 100644
--- a/spyder_unittest/__init__.py
+++ b/spyder_unittest/__init__.py
@@ -3,10 +3,10 @@
 # Copyright © 2013 Spyder Project Contributors
 # Licensed under the terms of the MIT License
 # (see LICENSE.txt for details)
-"""Spyder unitest plugin."""
+"""Spyder unittest plugin."""
 
 # Local imports
 from .unittestplugin import UnitTestPlugin as PLUGIN_CLASS
 
-__version__ = '0.5.2.dev0'
+__version__ = '0.6.0.dev0'
 PLUGIN_CLASS
diff --git a/spyder_unittest/tests/conftest.py b/spyder_unittest/tests/conftest.py
new file mode 100644
index 00000000..82da4c25
--- /dev/null
+++ b/spyder_unittest/tests/conftest.py
@@ -0,0 +1,45 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright © Spyder Project Contributors
+# Licensed under the terms of the MIT License
+# (see LICENSE.txt for details)
+"""
+Configuration file for Pytest.
+
+This contains the necessary definitions to make the main_window fixture
+available for integration tests.
+"""
+
+# Third-party imports
+from qtpy.QtWidgets import QApplication
+import pytest
+
+# QtWebEngineWidgets must be imported
+# before a QCoreApplication instance is created
+from qtpy import QtWebEngineWidgets  # noqa
+
+# Spyder imports
+from spyder.app import start
+from spyder.config.manager import CONF
+
+
+@pytest.fixture
+def main_window(request):
+    """Main Window fixture"""
+
+    # Don't show tours message
+    CONF.set('tours', 'show_tour_message', False)
+    QApplication.processEvents()
+
+    from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY
+    PLUGIN_REGISTRY.reset()
+
+    # Start the window
+    window = start.main()
+    QApplication.processEvents()
+
+    yield window
+
+    # Close main window
+    window.close()
+    CONF.reset_to_defaults(notification=False)
diff --git a/spyder_unittest/tests/test_unittestplugin.py b/spyder_unittest/tests/test_unittestplugin.py
index b73e369c..1351b60d 100644
--- a/spyder_unittest/tests/test_unittestplugin.py
+++ b/spyder_unittest/tests/test_unittestplugin.py
@@ -1,128 +1,165 @@
 # -*- coding: utf-8 -*-
 #
-# Copyright © 2017 Spyder Project Contributors
+# Copyright © Spyder Project Contributors
 # Licensed under the terms of the MIT License
 # (see LICENSE.txt for details)
-"""Tests for unittestplugin.py"""
+"""
+Tests for the integration of the plugin with Spyder.
+
+All tests needs to be decorated with pytest.mark.order('last') to ensure
+that they are executed after all other tests because monkeypatching
+functions inside the plugin does not work after running these tests.
+
+The reason for this is not clear, but it may have to do with how plugins
+are imported in spyder/otherplugins.py.
+"""
+
+# Standard library imports
+from collections import OrderedDict
+import os
 
 # Third party imports
 import pytest
-from spyder.plugins.projects.api import EmptyProject
-from unittest.mock import MagicMock
+from qtpy.QtCore import Qt
+
+# Spyder imports
+from spyder.api.plugins import Plugins
+from spyder.plugins.mainmenu.api import ApplicationMenus
 
 # Local imports
 from spyder_unittest.unittestplugin import UnitTestPlugin
 from spyder_unittest.widgets.configdialog import Config
 
 
-class PluginForTesting(UnitTestPlugin):
-    CONF_FILE = False
-
-    def __init__(self, parent):
-        UnitTestPlugin.__init__(self, parent)
+def test_menu_item(main_window):
+    """
+    Test that plugin adds item 'Run unit tests' to Run menu.
+    """
+    main_menu = main_window.get_plugin(Plugins.MainMenu)
+    run_menu = main_menu.get_application_menu(ApplicationMenus.Run)
 
+    # Filter out seperators (indicated by action is None) and convert to text
+    menu_items = [action.text() for action in run_menu.get_actions() if action]
 
-@pytest.fixture
-def plugin(qtbot):
-    """Set up the unittest plugin."""
-    res = UnitTestPlugin(None, None)
-    res._main = MagicMock()
-    res._main.get_spyder_pythonpath = MagicMock(return_value='fakepythonpath')
-    res.initialize()
-    return res
+    assert 'Run unit tests' in menu_items
 
 
-@pytest.mark.skip('not clear how to test interactions between plugins')
-def test_plugin_initialization(plugin):
-    assert len(plugin.main.run_menu_actions) == 2
-    assert plugin.main.run_menu_actions[1].text() == 'Run unit tests'
+def test_pythonpath_change(main_window):
+    """
+    Test that pythonpath changes in Spyder propagate to UnitTestWidget.
+    """
+    ppm = main_window.get_plugin(Plugins.PythonpathManager)
+    unittest_plugin = main_window.get_plugin(UnitTestPlugin.NAME)
 
+    new_path = '/some/path'
+    new_path_dict = OrderedDict([(new_path, True)])
+    ppm.get_container()._update_python_path(new_path_dict)
 
-def test_plugin_pythonpath(plugin):
-    # Test signal/slot connection
-    plugin.get_main().sig_pythonpath_changed.connect.assert_called_with(
-        plugin.update_pythonpath)
+    assert unittest_plugin.get_widget().pythonpath == [new_path]
 
-    # Test pythonpath is set to path provided by Spyder
-    assert plugin.get_widget().pythonpath == 'fakepythonpath'
 
-    # Test that change in path propagates
-    plugin.get_main().get_spyder_pythonpath = MagicMock(
-        return_value='anotherpath')
-    plugin.update_pythonpath()
-    assert plugin.get_widget().pythonpath == 'anotherpath'
+def test_default_working_dir(main_window, tmpdir):
+    """
+    Test that plugin's default working dir is current working directory.
+    After creating a project, the plugin's default working dir should be the
+    same as the project directory. When the project is closed again, the 
+    plugin's default working dir should revert back to the current working
+    directory.
+    """
+    projects = main_window.get_plugin(Plugins.Projects)
+    unittest_plugin = main_window.get_plugin(UnitTestPlugin.NAME)
+    project_dir = str(tmpdir)
 
+    assert unittest_plugin.get_widget().default_wdir == os.getcwd()
 
-@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.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)
-    plugin.main.projects.sig_project_loaded.connect.assert_called_with(
-        plugin.handle_project_change)
-    plugin.main.projects.sig_project_closed.connect.assert_called_with(
-        plugin.handle_project_change)
+    projects._create_project(project_dir)
+    assert unittest_plugin.get_widget().default_wdir == project_dir
 
-    # Test default_wdir is set to current working dir
-    monkeypatch.setattr('spyder_unittest.unittestplugin.getcwd',
-                        lambda: 'fakecwd')
-    plugin.update_default_wdir()
-    assert plugin.unittestwidget.default_wdir == 'fakecwd'
+    projects.close_project()
+    assert unittest_plugin.get_widget().default_wdir == os.getcwd()
 
-    # Test after opening project, default_wdir is set to project dir
-    project = EmptyProject(str(tmpdir))
-    plugin.main.projects.get_active_project = lambda: project
-    plugin.main.projects.get_active_project_path = lambda: project.root_path
-    plugin.handle_project_change()
-    assert plugin.unittestwidget.default_wdir == str(tmpdir)
-
-    # Test after closing project, default_wdir is set back to cwd
-    plugin.main.projects.get_active_project = lambda: None
-    plugin.main.projects.get_active_project_path = lambda: None
-    plugin.handle_project_change()
-    assert plugin.unittestwidget.default_wdir == 'fakecwd'
 
+def test_plugin_config(main_window, tmpdir, qtbot):
+    """
+    Test that plugin uses the project's config file if a project is open.
+    """
+    projects = main_window.get_plugin(Plugins.Projects)
+    unittest_plugin = main_window.get_plugin(UnitTestPlugin.NAME)
+    unittest_widget = unittest_plugin.get_widget()
+    project_dir = str(tmpdir)
+    config_file_path = tmpdir.join('.spyproject', 'config', 'unittest.ini')
 
-@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')
     assert not config_file_path.check()
-    assert plugin.unittestwidget.config is None
+    assert unittest_widget.config is None
 
     # Open project
-    project = EmptyProject(str(tmpdir))
-    plugin.main.projects.get_active_project = lambda: project
-    plugin.main.projects.get_active_project_path = lambda: project.root_path
-    plugin.handle_project_change()
+    projects._create_project(project_dir)
 
     # Test config file does exist but config is empty
     assert config_file_path.check()
     assert 'framework = ' in config_file_path.read().splitlines()
-    assert plugin.unittestwidget.config is None
+    assert unittest_widget.config is None
 
     # Set config and test that this is recorded in config file
     config = Config(framework='unittest', wdir=str(tmpdir))
-    with qtbot.waitSignal(plugin.unittestwidget.sig_newconfig):
-        plugin.unittestwidget.config = config
+    with qtbot.waitSignal(unittest_widget.sig_newconfig):
+        unittest_widget.config = config
     assert 'framework = unittest' in config_file_path.read().splitlines()
 
     # Close project and test that config is empty
-    plugin.main.projects.get_active_project = lambda: None
-    plugin.main.projects.get_active_project_path = lambda: None
-    plugin.handle_project_change()
-    assert plugin.unittestwidget.config is None
+    projects.close_project()
+    assert unittest_widget.config is None
 
     # Re-open project and test that config is correctly read
-    plugin.main.projects.get_active_project = lambda: project
-    plugin.main.projects.get_active_project_path = lambda: project.root_path
-    plugin.handle_project_change()
-    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, '')
+    projects.open_project(project_dir)
+    assert unittest_widget.config == config
+
+    # Close project before ending test, which removes the project dir
+    projects.close_project()
+
+
+def test_go_to_test_definition(main_window, tmpdir, qtbot):
+    """
+    Test that double clicking on a test result opens the file with the test
+    definition in the editor with the cursor on the test definition.
+    """
+    unittest_plugin = main_window.get_plugin(UnitTestPlugin.NAME)
+    unittest_widget = unittest_plugin.get_widget()
+    model = unittest_widget.testdatamodel
+    view = unittest_widget.testdataview
+
+    # Write test file
+    testdir_str = str(tmpdir)
+    testfile_str = tmpdir.join('test_foo.py').strpath
+    os.chdir(testdir_str)
+    with open(testfile_str, 'w') as f:
+        f.write("def test_ok(): assert 1+1 == 2\n"
+                "def test_fail(): assert 1+1 == 3\n")
+
+    # Run tests
+    config = Config(wdir=testdir_str, framework='pytest', coverage=False)
+    with qtbot.waitSignal(
+            unittest_widget.sig_finished, timeout=10000, raising=True):
+        unittest_widget.run_tests(config)
+
+    # Check that row 1 corresponds to `test_fail`
+    index = model.index(1, 1)
+    point = view.visualRect(index).center()
+    assert view.indexAt(point).data(Qt.DisplayRole).endswith('test_fail')
+
+    # Double click on `test_fail`
+    unittest_plugin.switch_to_plugin()
+    with qtbot.waitSignal(view.sig_edit_goto):
+        qtbot.mouseClick(view.viewport(), Qt.LeftButton, pos=point, delay=100)
+        qtbot.mouseDClick(view.viewport(), Qt.LeftButton, pos=point)
+
+    # Check that test file is opened in editor
+    editor = main_window.get_plugin(Plugins.Editor)
+    filename = editor.get_current_filename()
+    assert filename == testfile_str
+    
+    # Check that cursor is on line defining `test_fail`
+    cursor = editor.get_current_editor().textCursor()
+    line = cursor.block().text()
+    assert line.startswith('def test_fail')
diff --git a/spyder_unittest/unittestplugin.py b/spyder_unittest/unittestplugin.py
index 43a72969..a9e36428 100644
--- a/spyder_unittest/unittestplugin.py
+++ b/spyder_unittest/unittestplugin.py
@@ -11,7 +11,8 @@
 
 # Third party imports
 from spyder.api.plugins import Plugins, SpyderDockablePlugin
-from spyder.api.plugin_registration.decorators import on_plugin_available
+from spyder.api.plugin_registration.decorators import (
+    on_plugin_available, on_plugin_teardown)
 from spyder.config.base import get_translation
 from spyder.config.gui import is_dark_interface
 from spyder.plugins.mainmenu.api import ApplicationMenus
@@ -33,7 +34,8 @@ class UnitTestPlugin(SpyderDockablePlugin):
     NAME = 'unittest'
     REQUIRES = []
     OPTIONAL = [Plugins.Editor, Plugins.MainMenu, Plugins.Preferences,
-                Plugins.Projects, Plugins.WorkingDirectory]
+                Plugins.Projects, Plugins.PythonpathManager,
+                Plugins.WorkingDirectory]
     TABIFY = [Plugins.VariableExplorer]
     WIDGET_CLASS = UnitTestWidget
 
@@ -86,7 +88,6 @@ 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(
@@ -116,6 +117,14 @@ def on_editor_available(self):
         self.get_widget().pre_test_hook = editor.save_all
         self.get_widget().sig_edit_goto.connect(self.goto_in_editor)
 
+    @on_plugin_teardown(plugin=Plugins.Editor)
+    def on_editor_teardown(self):
+        """
+        Disconnect from Editor plugin.
+        """
+        self.get_widget().pre_test_hook = None
+        self.get_widget().sig_edit_goto.disconnect(self.goto_in_editor)
+
     @on_plugin_available(plugin=Plugins.MainMenu)
     def on_main_menu_available(self):
         """
@@ -126,6 +135,15 @@ def on_main_menu_available(self):
         mainmenu.add_item_to_application_menu(
             run_action, menu_id=ApplicationMenus.Run)
 
+    @on_plugin_teardown(plugin=Plugins.MainMenu)
+    def on_main_menu_teardown(self):
+        """
+        Remove 'Run unit tests; menu item from the application menu.
+        """
+        mainmenu = self.get_plugin(Plugins.MainMenu)
+        mainmenu.remove_item_from_application_menu(
+            UnitTestPluginActions.Run, menu_id=ApplicationMenus.Run)
+
     @on_plugin_available(plugin=Plugins.Preferences)
     def on_preferences_available(self):
         """
@@ -148,6 +166,32 @@ def on_projects_available(self):
         projects.sig_project_loaded.connect(self.handle_project_change)
         projects.sig_project_closed.connect(self.handle_project_change)
 
+    @on_plugin_teardown(plugin=Plugins.Projects)
+    def on_projects_teardown(self):
+        """
+        Disconnect from Projects plugin.
+        """
+        projects = self.get_plugin(Plugins.Projects)
+        projects.sig_project_created.disconnect(self.handle_project_change)
+        projects.sig_project_loaded.disconnect(self.handle_project_change)
+        projects.sig_project_closed.disconnect(self.handle_project_change)
+
+    @on_plugin_available(plugin=Plugins.PythonpathManager)
+    def on_pythonpath_manager_available(self):
+        """
+        Connect to signal announcing that Python path changed.
+        """
+        ppm = self.get_plugin(Plugins.PythonpathManager)
+        ppm.sig_pythonpath_changed.connect(self.update_pythonpath)
+
+    @on_plugin_teardown(plugin=Plugins.PythonpathManager)
+    def on_pythonpath_manager_teardown(self):
+        """
+        Disconnect from PythonpathManager plugin.
+        """
+        ppm = self.get_plugin(Plugins.PythonpathManager)
+        ppm.sig_pythonpath_changed.disconnect(self.update_pythonpath)
+
     @on_plugin_available(plugin=Plugins.WorkingDirectory)
     def on_working_directory_available(self):
         """
@@ -161,6 +205,15 @@ def on_working_directory_available(self):
             self.update_default_wdir)
         self.update_default_wdir()
 
+    @on_plugin_teardown(plugin=Plugins.WorkingDirectory)
+    def on_working_directory_teardown(self):
+        """
+        Disconnect from WorkingDirectory plugin.
+        """
+        working_directory = self.get_plugin(Plugins.WorkingDirectory)
+        working_directory.sig_current_directory_changed.disconnect(
+            self.update_default_wdir)
+
     # --- UnitTestPlugin methods ----------------------------------------------
 
     def update_pythonpath(self):
@@ -171,7 +224,8 @@ def update_pythonpath(self):
         It synchronizes the Python path in the unittest widget with the Python
         path in Spyder.
         """
-        self.get_widget().pythonpath = self.get_main().get_spyder_pythonpath()
+        ppm = self.get_plugin(Plugins.PythonpathManager)
+        self.get_widget().pythonpath = ppm.get_spyder_pythonpath()
 
     def handle_project_change(self):
         """