Skip to content

Commit

Permalink
Merge pull request #282 from livMatS/2023-10-26-unit-tests
Browse files Browse the repository at this point in the history
WIP: unit tests
  • Loading branch information
jotelha authored Nov 13, 2023
2 parents b508094 + ee624eb commit a4074c9
Show file tree
Hide file tree
Showing 4 changed files with 272 additions and 2 deletions.
34 changes: 32 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ Contributing to dtool-lookup-gui

Code style
----------
Always follow [PEP-8](https://www.python.org/dev/peps/pep-0008/) with the exception of line
breaks.
Always follow [PEP-8](https://www.python.org/dev/peps/pep-0008/) with the exception of line breaks.

Development branches
--------------------
Expand All @@ -23,3 +22,34 @@ Prepend you commits with a shortcut indicating the type of changes they contain:
* `MAINT`: Maintenance (e.g. fixing a typo)
* `TST`: Changes to the unit test environment
* `WIP`: Work in progress

GTK-specific
------------

The dtool-lookup-gui uses [GTK 3](/https://docs.gtk.org/gtk3/) to provide a platform-independent graphical user interface (GUI).
GTK originates at the [GNOME](https://wiki.gnome.org/) project.
At it's core sits [GLib](https://gitlab.gnome.org/GNOME/glib/), and on top of that lives the [GObject](https://docs.gtk.org/gobject/) library.
Again higher live the [Gio](https://docs.gtk.org/gio/) and [Gdk](https://docs.gtk.org/gdk4/) libraries and on top of them all the GTK toolkit.
The dtool-lookup-gui interfaces all GLib, GObject, Gio, Gtk, and Gdk functionality with [PyGObject](https://pygobject.readthedocs.io/en/latest/).
The [Python GTK+ 3 Tutorial](https://python-gtk-3-tutorial.readthedocs.io/en/latest/) helps with learning how to build GTK applications with Python.

Signals and events, and the event loop
--------------------------------------

GTK applications run asynchronously in the [GLib](https://docs.gtk.org/glib/) main event loop. [gbulb](https://github.com/beeware/gbulb) provides an implementation of the [PEP 3156](https://peps.python.org/pep-3156/) interface to this GLib event loop for Python-native use of [asyncio](https://docs.python.org/3/library/asyncio.html).
Within the dtool-lookup-gui's code, and importantly within `test/conftest.py` you will hence find `gbulb` bits that configure `asyncio` and `GTK` to share the same event loop.

Gio.Action
----------

Next to signals and events, actions are an important concept.
[Actions](https://docs.gtk.org/gio/iface.Action.html) implement functionality that is not necessarily bound to a specific element in the graphical representation.
Wrapping as much behavior as possible into atomic actions helps to structure the code and let the application evolve in the spirit of a clean separation between model, view, and controlling logic ([MVC software design pattern](https://en.wikipedia.org/wiki/Model%E2%80%93view%E2%80%93controller)).
Furthermore, actions can be evoked by other means than user interaction via the GUI, such as via the command line or a keyboard shortcut.
They facilitate writing unit tests as well.

When implementing new features, think about how to break them down into atomic actions.
In most cases, you will want to use the [Gio.SimpleAction](https://lazka.github.io/pgi-docs/Gio-2.0/interfaces/SimpleAction.html) interface.
You will find usage examples within the code, in the [GTK Python developer handbook: Working with GIO Actions](https://bharatkalluri.gitbook.io/gnome-developer-handbook/writing-apps-using-python/working-with-gio-gactions).
Write actions as methods prefixed with `do_*`, e.g. `do_search` and attach them to the app itself or to the window they relate to.
Write at least one unit test for each action, e.g. `test_do_search` for the action `do_search`.
5 changes: 5 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ The GUI uses custom Gtk widgets. To edit the the XML UI definition files with
Glade_, add the directory ``glade/catalog`` to `Extra Catalog & Template paths`
within Glade's preferences dialog.
Running unit tests
^^^^^^^^^^^^^^^^^^
Running the unit tests requires `pytest` and `pytest-asyncio`. Then, run all tests from repository root with `pytest`.
Funding
-------
Expand Down
232 changes: 232 additions & 0 deletions test/test_main_app_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
import json
import logging
import types
from unittest import mock
from unittest.mock import patch, mock_open, MagicMock, ANY, AsyncMock
import pytest
import dtoolcore

from dtool_lookup_gui.models.settings import settings as app_settings
from dtool_lookup_api.core.LookupClient import authenticate


@pytest.mark.asyncio
async def test_app_id(app):
assert app.get_application_id() == 'de.uni-freiburg.dtool-lookup-gui'


@pytest.fixture
def settings():
"""Provide test case with app settings."""
yield app_settings


@pytest.mark.asyncio
async def test_do_toggle_logging(app):
"""Test do_toggle_logging action."""

with patch('logging.disable') as mock_logging_disable:
# Mock value to mimic the GVariant returned by the value parameter
value = MagicMock()
value.get_boolean.return_value = True # Testing the True case

# Mock action to mimic the Gio.SimpleAction
action = MagicMock()

# Perform the toggle logging action for enabling logging
app.do_toggle_logging(action, value)

# Assert that logging.disable was called with logging.NOTSET
mock_logging_disable.assert_called_once_with(logging.NOTSET)
action.set_state.assert_called_once_with(value)

# Reset mocks to test the False case
mock_logging_disable.reset_mock()
action.set_state.reset_mock()
value.get_boolean.return_value = False

# Perform the toggle logging action for disabling logging
app.do_toggle_logging(action, value)

# Assert that logging.disable was called with logging.WARNING
mock_logging_disable.assert_called_once_with(logging.WARNING)
action.set_state.assert_called_once_with(value)


@pytest.mark.asyncio
async def test_do_set_loglevel(app):
"""Test do_set_loglevel action."""

with patch.object(logging.getLogger(), 'setLevel') as mock_set_level:
# Mock values for loglevel
loglevel = 20 # Example log level (e.g., logging.INFO)

# Mock value to mimic the GVariant returned by the value parameter
value = MagicMock()
value.get_uint16.return_value = loglevel

# Mock action to mimic the Gio.SimpleAction
action = MagicMock()
action_state = MagicMock()
action_state.get_uint16.return_value = 0
action.get_state.return_value = action_state

# Perform the set loglevel action
app.do_set_loglevel(action, value)

# Assert that the root logger's setLevel was called with the correct level
mock_set_level.assert_called_once_with(loglevel)
action.set_state.assert_called_once_with(value)


@pytest.mark.asyncio
async def test_do_set_logfile(app):
"""Test do_set_logfile action."""
with patch('logging.FileHandler') as mock_file_handler, \
patch.object(logging.getLogger(), 'addHandler') as mock_add_handler:
# Mock values for logfile
logfile = "/path/to/logfile.log"

# Mock value to mimic the GVariant returned by the value parameter
value = MagicMock()
value.get_string.return_value = logfile

# Mock action to mimic the Gio.SimpleAction
action = MagicMock()
action_state = MagicMock()
action_state.get_string.return_value = ""
action.get_state.return_value = action_state

# Perform the set logfile action
app.do_set_logfile(action, value)

# Assert that logging.FileHandler was called with the correct path
mock_file_handler.assert_called_once_with(logfile)

# Assert that the FileHandler was added to the root logger
mock_add_handler.assert_called_once_with(ANY)

# Assert that the action's state was set to the new logfile value
action.set_state.assert_called_once_with(value)


@pytest.mark.asyncio
async def test_do_reset_config(app, settings):
"""Test do_reset_config action."""
with patch('os.remove') as mock_remove, \
patch.object(app, 'emit') as mock_emit: # Patch the emit method

settings.reset = MagicMock() # Mock the settings reset method

# Perform the reset config action
app.activate_action('reset-config')

# The 'do_reset_config' action removes and replaces the dtool.json
# config file.
# Assert that os.remove has been called with the correct path
mock_remove.assert_called_once_with(dtoolcore.utils.DEFAULT_CONFIG_PATH)

# The 'do_reset_config' action calls the reset method on the
# Gio.Setting property of the settings object.
# Assert that the settings.reset was called once.
settings.reset.assert_called_once()

# The emit method is called when a signal is emitted.
# The 'do_reset_config' action is expected to emit a
# 'dtool-config-changed' signal.
# Assert that the 'dtool-config-changed' signal has been emitted
mock_emit.assert_called_once_with('dtool-config-changed')


@pytest.mark.asyncio
async def test_do_import_config(app, settings):
"""Test do_import_config action."""
mock_config = {'key1': 'value1', 'key2': 'value2'}
# Define the mock config file path
config_file_path = '/path/to/mock/config.json'

with patch('builtins.open', mock_open(read_data=json.dumps(mock_config))) as mock_file, \
patch('json.load', return_value=mock_config), \
patch('dtoolcore.utils.write_config_value_to_file') as mock_write_config, \
patch.object(app, 'emit') as mock_emit:
# Create a mock for the GLib.Variant object
mock_value = mock.Mock()
mock_value.get_string.return_value = config_file_path

# Perform the import config action
app.do_import_config('import-config', mock_value)

# Assert that the config file is opened correctly with the specified path
mock_file.assert_any_call(config_file_path, 'r')

# Assert that the config values are written to file
for key, value in mock_config.items():
mock_write_config.assert_any_call(key, value)

# Assert that the 'dtool-config-changed' signal has been emitted
mock_emit.assert_called_once_with('dtool-config-changed')


@pytest.mark.asyncio
async def test_do_export_config(app, settings):
"""Test do_export_config action."""
mock_config = {'key1': 'value1', 'key2': 'value2'}
# Define the mock config file path
config_file_path = '/path/to/mock/config.json'

# Setup mock open
mock_file_handle = mock_open()
with patch('builtins.open', mock_file_handle) as mock_file, \
patch('dtoolcore.utils._get_config_dict_from_file', return_value=mock_config), \
patch.object(app, 'emit') as mock_emit:
# Create a mock for the GLib.Variant object
mock_value = mock.Mock()
mock_value.get_string.return_value = config_file_path

# Perform the export config action
app.do_export_config('export-config', mock_value)

# Assert that the config file is opened correctly with the specified path
mock_file.assert_called_once_with(config_file_path, 'w')

# Concatenate all the calls to write and compare with expected JSON
written_data = ''.join(call_arg.args[0] for call_arg in mock_file_handle().write.call_args_list)
expected_file_content = json.dumps(mock_config, indent=4)
assert written_data == expected_file_content


@pytest.mark.asyncio
async def test_do_renew_token(app):
"""Test do_renew_token action."""
with patch('asyncio.create_task') as mock_create_task, \
patch.object(app, 'emit') as mock_emit: # Patch the emit method

# Mock values for username, password, and auth_url
username = "testuser"
password = "testpass"
auth_url = "http://auth.example.com"

# Tuple variant to mimic the value parameter in the method
value = MagicMock()
value.unpack.return_value = (username, password, auth_url)

# Perform the renew token action
app.do_renew_token(None, value)

# Assert that asyncio.create_task was called with the retrieve_token coroutine
mock_create_task.assert_called_once()
args, kwargs = mock_create_task.call_args
retrieve_token_coro = args[0]
assert isinstance(retrieve_token_coro, types.CoroutineType)

# Assert that the coroutine was called with the correct arguments
assert retrieve_token_coro.cr_code.co_name == 'retrieve_token'
assert retrieve_token_coro.cr_frame.f_locals['auth_url'] == auth_url
assert retrieve_token_coro.cr_frame.f_locals['username'] == username
assert retrieve_token_coro.cr_frame.f_locals['password'] == password

# The 'do_renew_token' action is expected to emit a
# 'dtool-config-changed' signal after successful token renewal.
# Since the actual token retrieval is asynchronous and mocked,
# we cannot assert this directly in this test.
# mock_emit.assert_called_once_with('dtool-config-changed')
3 changes: 3 additions & 0 deletions test/test_main_window_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import asyncio
import logging
import pytest

0 comments on commit a4074c9

Please sign in to comment.