Unit Test Improvements (#5087)

* Disable migration testing

- Compare how long the unit tests take

* Change file

- To get unit tests to run

* Fix format

* Consolidate tasks.py

- Remove coverage task
- Add --coverage flag to test task

* Fix typo

* Run migration unit tests if migration files are updated

* Fix

* Touch migration file

- Should cause migration unit tests to be run

* Force migration checks for docker build

* Prevent default report creation in unit tests

- Should save some time

* Add simple profiling for plugin loading

- Display time taken to load each plugin

* Fix to invoke test

* Disable get_git_log (for testing)

* Disable get_git_path in CI

- Might remove this entirely?
- For now, bypass for unit testing

* Add debug for unit registry

- Display time taken to load registry

* Don't full-reload unit registry

* Adjust migration test workflow

- env var updates
- change paths-filter output

* Fix for migration_test.yaml

- Actually need to set the output

* env fix

* db name

* Prevent sleep if in test mode

* Reduce sleep time on wait_for_db
This commit is contained in:
Oliver 2023-06-23 17:25:59 +10:00 committed by GitHub
parent 693d24b4b6
commit 3b4e20b54a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 127 additions and 58 deletions

View File

@ -78,6 +78,7 @@ jobs:
run: | run: |
echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env echo "GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}" >> docker.dev.env
docker-compose run inventree-dev-server invoke test --disable-pty docker-compose run inventree-dev-server invoke test --disable-pty
docker-compose run inventree-dev-server invoke test --migrations --disable-pty
docker-compose down docker-compose down
- name: Set up QEMU - name: Set up QEMU
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'

View File

@ -16,11 +16,11 @@ on:
env: env:
python_version: 3.9 python_version: 3.9
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
INVENTREE_MEDIA_ROOT: ../test_inventree_media INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_BACKUP_DIR: ../test_inventree_backup INVENTREE_BACKUP_DIR: ../test_inventree_backup
INVENTREE_DEBUG: info
INVENTREE_PLUGINS_ENABLED: false
jobs: jobs:
paths-filter: paths-filter:
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs: outputs:
server: ${{ steps.filter.outputs.server }} migrations: ${{ steps.filter.outputs.migrations }}
steps: steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0 - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
@ -36,17 +36,54 @@ jobs:
id: filter id: filter
with: with:
filters: | filters: |
server: migrations:
- 'InvenTree/**' - '**/migrations/**'
- 'requirements.txt' - '.github/workflows**'
- 'requirements-dev.txt'
- '.github/**' migration-tests:
name: Run Migration Unit Tests
runs-on: ubuntu-latest
needs: paths-filter
if: needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: django.db.backends.postgresql
INVENTREE_DB_NAME: inventree
INVENTREE_DB_USER: inventree
INVENTREE_DB_PASSWORD: password
INVENTREE_DB_HOST: '127.0.0.1'
INVENTREE_DB_PORT: 5432
services:
postgres:
image: postgres:14
env:
POSTGRES_USER: inventree
POSTGRES_PASSWORD: password
ports:
- 5432:5432
steps:
- uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # pin@v3.1.0
- name: Environment Setup
uses: ./.github/actions/setup
with:
apt-dependency: gettext poppler-utils libpq-dev
pip-dependency: psycopg2
dev-install: true
update: true
- name: Run Tests
run: invoke test --migrations --report
migrations-checks: migrations-checks:
name: Run Database Migrations name: Run Database Migrations
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: paths-filter needs: paths-filter
if: needs.paths-filter.outputs.server == 'true' if: needs.paths-filter.outputs.migrations == 'true'
env:
INVENTREE_DB_ENGINE: sqlite3
INVENTREE_DB_NAME: /home/runner/work/InvenTree/db.sqlite3
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3

View File

@ -204,7 +204,7 @@ jobs:
- name: Check Migration Files - name: Check Migration Files
run: python3 ci/check_migration_files.py run: python3 ci/check_migration_files.py
- name: Coverage Tests - name: Coverage Tests
run: invoke coverage run: invoke test --coverage
- name: Upload Coverage Report - name: Upload Coverage Report
uses: coverallsapp/github-action@v2 uses: coverallsapp/github-action@v2
with: with:

View File

@ -48,7 +48,7 @@ class InvenTreeConfig(AppConfig):
self.collect_notification_methods() self.collect_notification_methods()
# Ensure the unit registry is loaded # Ensure the unit registry is loaded
InvenTree.conversion.reload_unit_registry() InvenTree.conversion.get_unit_registry()
if canAppAccessDatabase() or settings.TESTING_ENV: if canAppAccessDatabase() or settings.TESTING_ENV:
self.add_user_on_startup() self.add_user_on_startup()

View File

@ -1,5 +1,7 @@
"""Helper functions for converting between units.""" """Helper functions for converting between units."""
import logging
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -8,6 +10,9 @@ import pint
_unit_registry = None _unit_registry = None
logger = logging.getLogger('inventree')
def get_unit_registry(): def get_unit_registry():
"""Return a custom instance of the Pint UnitRegistry.""" """Return a custom instance of the Pint UnitRegistry."""
@ -26,6 +31,9 @@ def reload_unit_registry():
This function is called at startup, and whenever the database is updated. This function is called at startup, and whenever the database is updated.
""" """
import time
t_start = time.time()
global _unit_registry global _unit_registry
_unit_registry = pint.UnitRegistry() _unit_registry = pint.UnitRegistry()
@ -39,6 +47,9 @@ def reload_unit_registry():
# TODO: Allow for custom units to be defined in the database # TODO: Allow for custom units to be defined in the database
dt = time.time() - t_start
logger.debug(f'Loaded unit registry in {dt:.3f}s')
def convert_physical_value(value: str, unit: str = None): def convert_physical_value(value: str, unit: str = None):
"""Validate that the provided value is a valid physical quantity. """Validate that the provided value is a valid physical quantity.

View File

@ -18,7 +18,7 @@ class Command(BaseCommand):
while not connected: while not connected:
time.sleep(5) time.sleep(2)
try: try:
connection.ensure_connection() connection.ensure_connection()

View File

@ -91,13 +91,15 @@ def check_daily_holdoff(task_name: str, n_days: int = 1) -> bool:
""" """
from common.models import InvenTreeSetting from common.models import InvenTreeSetting
from InvenTree.ready import isInTestMode
if n_days <= 0: if n_days <= 0:
logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run") logger.info(f"Specified interval for task '{task_name}' < 1 - task will not run")
return False return False
# Sleep a random number of seconds to prevent worker conflict # Sleep a random number of seconds to prevent worker conflict
time.sleep(random.randint(1, 5)) if not isInTestMode():
time.sleep(random.randint(1, 5))
attempt_key = f'_{task_name}_ATTEMPT' attempt_key = f'_{task_name}_ATTEMPT'
success_key = f'_{task_name}_SUCCESS' success_key = f'_{task_name}_SUCCESS'

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.18 on 2023-04-17 05:54 # Generated by Django 3.2.18 on 2023-04-17 05:55
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models

View File

@ -35,7 +35,7 @@ class LabelConfig(AppConfig):
def ready(self): def ready(self):
"""This function is called whenever the label app is loaded.""" """This function is called whenever the label app is loaded."""
if canAppAccessDatabase(): if canAppAccessDatabase(allow_test=False):
try: try:
self.create_labels() # pragma: no cover self.create_labels() # pragma: no cover

View File

@ -1,6 +1,5 @@
"""Helpers for plugin app.""" """Helpers for plugin app."""
import datetime
import inspect import inspect
import logging import logging
import pathlib import pathlib
@ -14,8 +13,6 @@ from django.conf import settings
from django.core.exceptions import AppRegistryNotReady from django.core.exceptions import AppRegistryNotReady
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from dulwich.repo import NotGitRepository, Repo
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -112,25 +109,34 @@ def get_entrypoints():
def get_git_log(path): def get_git_log(path):
"""Get dict with info of the last commit to file named in path.""" """Get dict with info of the last commit to file named in path."""
import datetime
from dulwich.repo import NotGitRepository, Repo
from InvenTree.ready import isInTestMode
output = None output = None
path = path.replace(str(settings.BASE_DIR.parent), '')[1:] path = path.replace(str(settings.BASE_DIR.parent), '')[1:]
try: # only do this if we are not in test mode
walker = Repo.discover(path).get_walker(paths=[path.encode()], max_entries=1) if not isInTestMode(): # pragma: no cover
try: try:
commit = next(iter(walker)).commit walker = Repo.discover(path).get_walker(paths=[path.encode()], max_entries=1)
except StopIteration: try:
commit = next(iter(walker)).commit
except StopIteration:
pass
else:
output = [
commit.sha().hexdigest(),
commit.author.decode().split('<')[0][:-1],
commit.author.decode().split('<')[1][:-1],
datetime.datetime.fromtimestamp(commit.author_time, ).isoformat(),
commit.message.decode().split('\n')[0],
]
except NotGitRepository:
pass pass
else:
output = [
commit.sha().hexdigest(),
commit.author.decode().split('<')[0][:-1],
commit.author.decode().split('<')[1][:-1],
datetime.datetime.fromtimestamp(commit.author_time, ).isoformat(),
commit.message.decode().split('\n')[0],
]
except NotGitRepository:
pass
if not output: if not output:
output = 5 * [''] # pragma: no cover output = 5 * [''] # pragma: no cover

View File

@ -9,6 +9,7 @@ import importlib
import logging import logging
import os import os
import subprocess import subprocess
import time
from pathlib import Path from pathlib import Path
from typing import Dict, List, OrderedDict from typing import Dict, List, OrderedDict
@ -439,13 +440,16 @@ class PluginsRegistry:
continue # continue -> the plugin is not loaded continue # continue -> the plugin is not loaded
# Initialize package - we can be sure that an admin has activated the plugin # Initialize package - we can be sure that an admin has activated the plugin
logger.info(f'Loading plugin `{plg_name}`') logger.debug(f'Loading plugin `{plg_name}`')
try: try:
t_start = time.time()
plg_i: InvenTreePlugin = plg() plg_i: InvenTreePlugin = plg()
logger.debug(f'Loaded plugin `{plg_name}`') dt = time.time() - t_start
logger.info(f'Loaded plugin `{plg_name}` in {dt:.3f}s')
except Exception as error: except Exception as error:
handle_error(error, log_name='init') # log error and raise it -> disable plugin handle_error(error, log_name='init') # log error and raise it -> disable plugin
logger.warning(f"Plugin `{plg_name}` could not be loaded")
# Safe extra attributes # Safe extra attributes
plg_i.is_package = getattr(plg_i, 'is_package', False) plg_i.is_package = getattr(plg_i, 'is_package', False)

View File

@ -10,7 +10,7 @@ from plugin.models import PluginConfig
class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase): class PluginDetailAPITest(PluginMixin, InvenTreeAPITestCase):
"""Tests the plugin API endpoints.""" """Tests the plugin API endpoints"""
roles = [ roles = [
'admin.add', 'admin.add',

View File

@ -25,7 +25,7 @@ class ReportConfig(AppConfig):
logging.getLogger('weasyprint').setLevel(logging.WARNING) logging.getLogger('weasyprint').setLevel(logging.WARNING)
# Create entries for default report templates # Create entries for default report templates
if canAppAccessDatabase(allow_test=True): if canAppAccessDatabase(allow_test=False):
self.create_default_test_reports() self.create_default_test_reports()
self.create_default_build_reports() self.create_default_build_reports()
self.create_default_bill_of_materials_reports() self.create_default_bill_of_materials_reports()

View File

@ -565,9 +565,12 @@ def test_translations(c):
help={ help={
'disable_pty': 'Disable PTY', 'disable_pty': 'Disable PTY',
'runtest': 'Specify which tests to run, in format <module>.<file>.<class>.<method>', 'runtest': 'Specify which tests to run, in format <module>.<file>.<class>.<method>',
'migrations': 'Run migration unit tests',
'report': 'Display a report of slow tests',
'coverage': 'Run code coverage analysis (requires coverage package)',
} }
) )
def test(c, disable_pty=False, runtest=''): def test(c, disable_pty=False, runtest='', migrations=False, report=False, coverage=False):
"""Run unit-tests for InvenTree codebase. """Run unit-tests for InvenTree codebase.
To run only certain test, use the argument --runtest. To run only certain test, use the argument --runtest.
@ -583,8 +586,32 @@ def test(c, disable_pty=False, runtest=''):
pty = not disable_pty pty = not disable_pty
# Run coverage tests _apps = ' '.join(apps())
manage(c, f'test --slowreport {runtest}', pty=pty)
cmd = 'test'
if runtest:
# Specific tests to run
cmd += f' {runtest}'
else:
# Run all tests
cmd += f' {_apps}'
if report:
cmd += ' --slowreport'
if migrations:
cmd += ' --tag migration_test'
else:
cmd += ' --exclude-tag migration_test'
if coverage:
# Run tests within coverage environment, and generate report
c.run(f'coverage run {managePyPath()} {cmd}')
c.run('coverage html -i')
else:
# Run simple test runner, without coverage
manage(c, cmd, pty=pty)
@task(help={'dev': 'Set up development environment at the end'}) @task(help={'dev': 'Set up development environment at the end'})
@ -629,25 +656,6 @@ def setup_test(c, ignore_update=False, dev=False, path="inventree-demo-dataset")
setup_dev(c) setup_dev(c)
@task
def coverage(c):
"""Run code-coverage of the InvenTree codebase, using the 'coverage' code-analysis tools.
Generates a code coverage report (available in the htmlcov directory)
"""
# Run sanity check on the django install
manage(c, 'check')
# Run coverage tests
c.run('coverage run {manage} test {apps}'.format(
manage=managePyPath(),
apps=' '.join(apps())
))
# Generate coverage report
c.run('coverage html -i')
@task(help={ @task(help={
'filename': "Output filename (default = 'schema.yml')", 'filename': "Output filename (default = 'schema.yml')",
'overwrite': "Overwrite existing files without asking first (default = off/False)", 'overwrite': "Overwrite existing files without asking first (default = off/False)",