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:
Oliver authored and GitHub committed 2026-11-13 07:35:11 +11:00
1 parent 6387ae95e1
commit cdfa7f1921
11 files changed
+141 -50

No files matched your search

+10
View File
@@ -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
View File
@@ -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"
+14 -1
View File
@@ -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):
+4 -4
View File
@@ -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'})
+6 -3
View File
@@ -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)
+23 -6
View File
@@ -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
+18 -8
View File
@@ -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):