mirror of
https://github.com/inventree/InvenTree.git
synced 2026-11-14 12:32:44 +00:00
CI unit test improvements (#13257)
Speed improvements for backend CI testing - Use different coverage tool - Optimize plugin reloading during tests - Mock longer running processes - Adjust postgres configuration - Reduce test setup time
This commit is contained in:
1 parent
6387ae95e1
commit
cdfa7f1921
11 files changed
+141
-50
No files matched your search
@@ -487,6 +487,16 @@ jobs:
|
||||
dev-install: true
|
||||
update: true
|
||||
static: true
|
||||
# The database is discarded after the run - durability is not required
|
||||
- name: Tune PostgreSQL for Testing
|
||||
env:
|
||||
PG_CONTAINER: ${{ job.services.postgres.id }}
|
||||
run: |
|
||||
docker exec "$PG_CONTAINER" psql -U inventree \
|
||||
-c "ALTER SYSTEM SET fsync = off" \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = off" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = off" \
|
||||
-c "SELECT pg_reload_conf()"
|
||||
# Coverage is collected here (not on SQLite) so that tests which require
|
||||
# select_for_update (concurrency / locking tests) contribute to coverage
|
||||
- name: Coverage Tests
|
||||
|
||||
+4
-4
@@ -118,12 +118,12 @@ unused-ignore-comment = "error"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["src/backend/InvenTree", "InvenTree"]
|
||||
dynamic_context = "test_function"
|
||||
# sys.monitoring based measurement (Python 3.12+) is much faster than the default tracer.
|
||||
# Note: sysmon does not support 'dynamic_context', so per-test contexts are not recorded.
|
||||
# On older Python versions, coverage falls back to the default core (with a warning).
|
||||
core = "sysmon"
|
||||
omit = ["*/test_migrations.py", "*/migrations/*"]
|
||||
|
||||
[tool.coverage.html]
|
||||
show_contexts = true
|
||||
|
||||
[tool.djlint]
|
||||
ignore = "D018,H006,H008,H020,H021,H023,H025,H030,H031,T002"
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""Tests for tasks in app common."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
import requests
|
||||
|
||||
from common.models import NewsFeedEntry, NotificationEntry
|
||||
from InvenTree.tasks import offload_task
|
||||
|
||||
@@ -58,9 +62,18 @@ class NewsFeedTests(TestCase):
|
||||
"""
|
||||
settings.INVENTREE_NEWS_URL = 'http://10.255.255.1:81'
|
||||
|
||||
with self.assertLogs('inventree', level='WARNING'):
|
||||
# Simulate the connection timing out, rather than waiting for a real timeout
|
||||
with (
|
||||
patch(
|
||||
'common.tasks.requests.get',
|
||||
side_effect=requests.exceptions.ConnectTimeout,
|
||||
) as mock_get,
|
||||
self.assertLogs('inventree', level='WARNING'),
|
||||
):
|
||||
common_tasks.update_news_feed()
|
||||
|
||||
mock_get.assert_called_once_with(settings.INVENTREE_NEWS_URL, timeout=30)
|
||||
|
||||
self.assertEqual(NewsFeedEntry.objects.all().count(), 0)
|
||||
|
||||
def test_unset_url(self):
|
||||
|
||||
@@ -1161,12 +1161,12 @@ class PluginSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||
class PluginUserSettingsApiTest(PluginMixin, InvenTreeAPITestCase):
|
||||
"""Tests for the plugin user settings API."""
|
||||
|
||||
def setUp(self):
|
||||
"""Ensure plugin is activated."""
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Ensure plugin is activated (once for the whole class)."""
|
||||
super().setUpTestData()
|
||||
registry.set_plugin_state('sample', True)
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_user_setting_list(self):
|
||||
"""Test the plugin user setting list API."""
|
||||
url = reverse('api-plugin-user-setting-list', kwargs={'plugin': 'sample'})
|
||||
|
||||
@@ -19,13 +19,16 @@ class MachineAPITest(TestMachineRegistryMixin, InvenTreeAPITestCase):
|
||||
|
||||
roles = ['admin.add', 'admin.view', 'admin.change', 'admin.delete']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Ensure the test plugin is loaded (once for the whole class)."""
|
||||
super().setUpTestData()
|
||||
plg_registry.set_plugin_state('label-printer-test-plugin', True)
|
||||
|
||||
def setUp(self):
|
||||
"""Setup some testing drivers/machines."""
|
||||
registry.initialize()
|
||||
|
||||
# Ensure the test plugin is loaded
|
||||
plg_registry.set_plugin_state('label-printer-test-plugin', True)
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_machine_type_list(self):
|
||||
|
||||
@@ -333,13 +333,38 @@ class PluginsRegistry:
|
||||
slug (str): Plugin slug
|
||||
state (bool): Plugin state - true = active, false = inactive
|
||||
"""
|
||||
if slug not in self.plugins_full:
|
||||
logger.warning("Plugin registry has no record of plugin '%s'", slug)
|
||||
return
|
||||
self.set_plugin_states({slug: state})
|
||||
|
||||
cfg = self.get_plugin_config(slug)
|
||||
cfg.active = state
|
||||
cfg.save()
|
||||
@registry_entrypoint()
|
||||
def set_plugin_states(self, states: dict[str, bool]):
|
||||
"""Set the state (active/inactive) of multiple plugins, with a single reload.
|
||||
|
||||
Changing the active state of a PluginConfig triggers a full registry reload,
|
||||
which is expensive. This method updates all of the provided plugins first,
|
||||
and then performs (at most) one reload.
|
||||
|
||||
Arguments:
|
||||
states (dict): Mapping of {slug: state} - true = active, false = inactive
|
||||
"""
|
||||
changed = False
|
||||
|
||||
for slug, state in states.items():
|
||||
if slug not in self.plugins_full:
|
||||
logger.warning("Plugin registry has no record of plugin '%s'", slug)
|
||||
continue
|
||||
|
||||
cfg = self.get_plugin_config(slug)
|
||||
cfg.active = state
|
||||
|
||||
# Same condition as PluginConfig.save() uses to trigger a reload
|
||||
if cfg.reload_required():
|
||||
changed = True
|
||||
cfg.warn_reload(stacklevel=4)
|
||||
|
||||
cfg.save(no_reload=True)
|
||||
|
||||
if changed:
|
||||
self.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
# Update the registry hash value
|
||||
self.update_plugin_hash()
|
||||
|
||||
@@ -11,11 +11,10 @@ from stock.models import StockItem, StockLocation
|
||||
class LocatePluginTests(InvenTreeAPITestCase):
|
||||
"""Tests for LocateMixin."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up the test case."""
|
||||
super().setUp()
|
||||
|
||||
# Activate plugin
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Activate the plugin once for the whole class."""
|
||||
super().setUpTestData()
|
||||
registry.set_plugin_state('samplelocate', True)
|
||||
|
||||
fixtures = ['category', 'part', 'location', 'stock']
|
||||
|
||||
@@ -22,6 +22,9 @@ class AutoCreateBuildsTest(InvenTreeTestCase):
|
||||
"""
|
||||
super().setUpTestData()
|
||||
|
||||
# Changing plugin state forces a registry reload - do it once per class
|
||||
registry.set_plugin_state('autocreatebuilds', True)
|
||||
|
||||
cls.top = Part.objects.create(name='Top', description='Top', assembly=True)
|
||||
cls.sub_1 = Part.objects.create(
|
||||
name='Sub 1', description='Sub 1', assembly=True, component=True
|
||||
@@ -41,7 +44,6 @@ class AutoCreateBuildsTest(InvenTreeTestCase):
|
||||
"""Activate the plugin and create a top-level build order."""
|
||||
super().setUp()
|
||||
|
||||
registry.set_plugin_state('autocreatebuilds', True)
|
||||
self.plugin = registry.get_plugin('autocreatebuilds')
|
||||
|
||||
self.build = Build.objects.create(
|
||||
|
||||
@@ -9,6 +9,14 @@ from part.models import Part
|
||||
from plugin import registry
|
||||
from stock.models import StockItem, StockLocation
|
||||
|
||||
# Supplier barcode plugins which must be active for these tests
|
||||
SUPPLIER_PLUGINS = {
|
||||
'digikeyplugin': True,
|
||||
'mouserplugin': True,
|
||||
'lcscplugin': True,
|
||||
'tmeplugin': True,
|
||||
}
|
||||
|
||||
|
||||
class SupplierBarcodeTests(InvenTreeAPITestCase):
|
||||
"""Tests barcode parsing for all suppliers."""
|
||||
@@ -28,10 +36,7 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
|
||||
super().setUpTestData()
|
||||
|
||||
# Enable the plugins
|
||||
registry.set_plugin_state('digikeyplugin', True)
|
||||
registry.set_plugin_state('mouserplugin', True)
|
||||
registry.set_plugin_state('lcscplugin', True)
|
||||
registry.set_plugin_state('tmeplugin', True)
|
||||
registry.set_plugin_states(SUPPLIER_PLUGINS)
|
||||
|
||||
part = Part.objects.create(name='Test Part', description='Test Part')
|
||||
|
||||
@@ -67,11 +72,9 @@ class SupplierBarcodeTests(InvenTreeAPITestCase):
|
||||
|
||||
# Assign supplier information to the plugins
|
||||
# Add supplier information to each custom plugin
|
||||
registry.set_plugin_state('digikeyplugin', True)
|
||||
digikey_plugin = registry.get_plugin('digikeyplugin')
|
||||
digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk)
|
||||
|
||||
registry.set_plugin_state('mouserplugin', True)
|
||||
mouser_plugin = registry.get_plugin('mouserplugin')
|
||||
mouser_plugin.set_setting('SUPPLIER_ID', mouser_supplier.pk)
|
||||
|
||||
@@ -185,14 +188,25 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
||||
|
||||
roles = ['stock.view', 'stock_location.view', 'purchase_order.add']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Enable the supplier plugins once for the whole class.
|
||||
|
||||
Changing plugin state forces a full registry reload, so avoid doing it per-test.
|
||||
"""
|
||||
super().setUpTestData()
|
||||
registry.set_plugin_states(SUPPLIER_PLUGINS)
|
||||
|
||||
def setUp(self):
|
||||
"""Create supplier part and purchase_order."""
|
||||
super().setUp()
|
||||
|
||||
registry.set_plugin_state('digikeyplugin', True)
|
||||
registry.set_plugin_state('mouserplugin', True)
|
||||
registry.set_plugin_state('lcscplugin', True)
|
||||
registry.set_plugin_state('tmeplugin', True)
|
||||
# The plugin instances persist across tests (no per-test registry reload),
|
||||
# so clear the cached supplier - it refers to a company from a previous test
|
||||
for slug in SUPPLIER_PLUGINS:
|
||||
plugin = registry.get_plugin(slug)
|
||||
if hasattr(plugin, '_supplier'):
|
||||
del plugin._supplier
|
||||
|
||||
self.loc_1 = StockLocation.objects.create(name='Location 1')
|
||||
self.loc_2 = StockLocation.objects.create(name='Location 2')
|
||||
@@ -249,11 +263,9 @@ class SupplierBarcodePOReceiveTests(InvenTreeAPITestCase):
|
||||
self.purchase_order2.add_line_item(supplier_part, 5, destination=self.loc_2)
|
||||
|
||||
# Add supplier information to each custom plugin
|
||||
registry.set_plugin_state('digikeyplugin', True)
|
||||
digikey_plugin = registry.get_plugin('digikeyplugin')
|
||||
digikey_plugin.set_setting('SUPPLIER_ID', digikey_supplier.pk)
|
||||
|
||||
registry.set_plugin_state('mouserplugin', True)
|
||||
mouser_plugin = registry.get_plugin('mouserplugin')
|
||||
mouser_plugin.set_setting('SUPPLIER_ID', mouser.pk)
|
||||
|
||||
|
||||
@@ -141,6 +141,25 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
) # plugin cannot be pickled in some circumstances when used with drf views, remove it (#5408)
|
||||
return state
|
||||
|
||||
def warn_reload(self, stacklevel: int = 2):
|
||||
"""Emit a warning (in plugin testing mode) that this config triggered a registry reload."""
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn(
|
||||
f'A plugin registry reload was triggered for plugin {self.key}',
|
||||
stacklevel=stacklevel,
|
||||
)
|
||||
|
||||
def reload_required(self) -> bool:
|
||||
"""Return True if saving this config would require a plugin registry reload.
|
||||
|
||||
A reload is required when the 'active' status has changed since the
|
||||
config was loaded (or last saved), unless the plugin is mandatory.
|
||||
"""
|
||||
if self.is_mandatory():
|
||||
return False
|
||||
|
||||
return self.active != self.__org_active
|
||||
|
||||
def save(self, force_insert=False, force_update=False, *args, **kwargs):
|
||||
"""Extend save method to reload plugins if the 'active' status changes."""
|
||||
no_reload = kwargs.pop('no_reload', False) # check if no_reload flag is set
|
||||
@@ -151,14 +170,12 @@ class PluginConfig(InvenTree.models.MetadataMixin, models.Model):
|
||||
# Force active if mandatory plugin
|
||||
self.active = True
|
||||
|
||||
reload_required = self.reload_required()
|
||||
|
||||
super().save(force_insert, force_update, *args, **kwargs)
|
||||
|
||||
if not no_reload and self.active != self.__org_active and not mandatory:
|
||||
if settings.PLUGIN_TESTING:
|
||||
warnings.warn(
|
||||
f'A plugin registry reload was triggered for plugin {self.key}',
|
||||
stacklevel=2,
|
||||
)
|
||||
if not no_reload and reload_required:
|
||||
self.warn_reload(stacklevel=3)
|
||||
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)
|
||||
|
||||
self.__org_active = self.active
|
||||
|
||||
@@ -704,13 +704,18 @@ class PluginLockedSettingsTest(PluginMixin, InvenTreeAPITestCase):
|
||||
|
||||
UNLOCKED_KEY = 'CHOICE_SETTING'
|
||||
|
||||
def setUp(self):
|
||||
"""Activate the sample plugin and confirm it is ready."""
|
||||
super().setUp()
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Activate the sample plugin once for the whole class."""
|
||||
super().setUpTestData()
|
||||
|
||||
from plugin import registry
|
||||
|
||||
registry.set_plugin_state(self.PLUGIN_SLUG, True)
|
||||
registry.set_plugin_state(cls.PLUGIN_SLUG, True)
|
||||
|
||||
def setUp(self):
|
||||
"""Confirm the sample plugin is ready."""
|
||||
super().setUp()
|
||||
|
||||
self.cfg = PluginConfig.objects.filter(key=self.PLUGIN_SLUG).first()
|
||||
self.assertIsNotNone(self.cfg)
|
||||
@@ -840,12 +845,17 @@ class PluginUnauthenticatedAccessTest(PluginMixin, InvenTreeAPITestCase):
|
||||
PLUGIN_SLUG = 'sample'
|
||||
SETTING_KEY = 'API_KEY'
|
||||
|
||||
def setUp(self):
|
||||
"""Activate sample plugin, then log out to simulate an anonymous client."""
|
||||
super().setUp()
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""Activate the sample plugin once for the whole class."""
|
||||
super().setUpTestData()
|
||||
from plugin import registry
|
||||
|
||||
registry.set_plugin_state(self.PLUGIN_SLUG, True)
|
||||
registry.set_plugin_state(cls.PLUGIN_SLUG, True)
|
||||
|
||||
def setUp(self):
|
||||
"""Log out to simulate an anonymous client."""
|
||||
super().setUp()
|
||||
self.client.logout()
|
||||
|
||||
def test_plugin_detail_unauthenticated(self):
|
||||
|
||||
Reference in new issue
Block a user