Repository: rdmorganiser/rdmo (language: python)

Issue:
test optimization: reuse authentication state in frontend-tests
### Rationale / Begründung

The playwright test cases for the frontend log in for every test scenario. This should be optimized to reuse the authentication state.

### Affected

Developers

### Minimal functionality

A test case, that starts with an already logged in user.

### References / Verweise

* #769 
* https://playwright.dev/python/docs/auth

ci: update build-wheel job
## Description

This PR updates the `build-wheel` job.

I think it is better to not install rdmo[dev] to build the wheel and then force-reinstall. Installing just build and then installing "fresh" from the wheel, is much better for testing the wheel. Also it is much faster to not install rdmo in this step.

Also the output in the actions summary is now more verbose and includes a list of files in the wheel.

The Changes are:

- install build and twine, not rdmo[dev] to build the wheel
- build wheel and sdist
- check metadata of wheel and sdist
- write the output once, not step by step
- output in the actions summary is now more verbose and includes a list of files in the wheel

## Types of Changes
- [x] Other (please describe):

## Checklist
- [x] I have read the [contributor guide](https://github.com/rdmorganiser/rdmo/blob/main/CONTRIBUTING.md).


Discussion / hints:
After doing some research, this is not possible. The auth state is not frontend but backend territory.

Closing this.
See verbose output in [actions summary](https://github.com/rdmorganiser/rdmo/actions/runs/9993423865#summary-27622561802).

```console
$ python -m pip show --files --verbose rdmo
Name: rdmo
Version: 2.2.0.dev1+664cdf3
Summary: RDMO is a tool to support the systematic planning, organisation and implementation of the data management throughout the course of a research project.
Home-page: https://rdmorganiser.github.io
Author: 
Author-email: RDMO Arbeitsgemeinschaft <rdmo-team@listserv.dfn.de>
License: Apache-2.0
Location: /opt/hostedtoolcache/Python/3.12.4/x64/lib/python3.12/site-packages
Requires: defusedcsv, defusedxml, django, django-cleanup, django-compressor, django-extensions, django-filter, django-libsass, django-mathfilters, django-mptt, django-rest-swagger, django-settings-export, django-split-settings, django-widget-tweaks, djangorestframework, drf-extensions, iso8601, markdown, packaging, pypandoc, requests-toolbelt, rules
Required-by: 
Metadata-Version: 2.1
Installer: pip
Classifiers:
  Development Status :: 5 - Production/Stable
  Environment :: Web Environment
  Framework :: Django :: 4.2
  Intended Audience :: Science/Research
  License :: OSI Approved :: Apache Software License
  Operating System :: OS Independent
  Programming Language :: Python
  Programming Language :: Python :: 3 :: Only
  Programming Language :: Python :: 3.8
  Programming Language :: Python :: 3.9
  Programming Language :: Python :: 3.10
  Programming Language :: Python :: 3.11
  Programming Language :: Python :: 3.12
Entry-points:
Project-URLs:
  changelog, https://github.com/rdmorganiser/rdmo/blob/main/CHANGELOG.md
  documentation, https://rdmo.readthedocs.io
  homepage, https://rdmorganiser.github.io
  issues, https://github.com/rdmorganiser/rdmo/issues
  repository, https://github.com/rdmorganiser/rdmo.git
  slack, https://rdmo.slack.com
Files:
  rdmo-2.2.0.dev1+664cdf3.dist-info/AUTHORS
  rdmo-2.2.0.dev1+664cdf3.dist-info/INSTALLER
  rdmo-2.2.0.dev1+664cdf3.dist-info/LICENSE
  rdmo-2.2.0.dev1+664cdf3.dist-info/METADATA
  rdmo-2.2.0.dev1+664cdf3.dist-info/NOTICE
  rdmo-2.2.0.dev1+664cdf3.dist-info/RECORD
  rdmo-2.2.0.dev1+664cdf3.dist-info/REQUESTED
  rdmo-2.2.0.dev1+664cdf3.dist-info/WHEEL
  rdmo-2.2.0.dev1+664cdf3.dist-info/direct_url.json
  rdmo-2.2.0.dev1+664cdf3.dist-info/top_level.txt
  rdmo/__init__.py
  rdmo/accounts/__init__.py
  rdmo/accounts/adapter.py
  rdmo/accounts/admin.py
  rdmo/accounts/apps.py
  rdmo/accounts/forms.py
[...]
```
think that the failing test is currently not from this PR but in `dev-2.2.0` 

> Test (Python: 3.8, DB: mysql): rdmo/management/tests/test_viewset_upload.py#L129
> test_create_error[xml/error.xml-The content of the XML file does not consist of well-formed data or markup. XML Parsing Error: syntax error: line 1, column 0-editor-editor]
> 
> AssertionError: assert 'The content of the XML file does not consist of well-formed data or markup. XML Parsing Error: syntax error: line 1, column 0' in 'The content of the XML file does not consist of well-formed data or markup. XML Parsing Error: no element found: line 1, column 0'

https://github.com/rdmorganiser/rdmo/pull/878 needs to be fixed first
tests passed after re-try, so it seems fine

Fix patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 921c235d4c..905ee415d2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -38,14 +38,75 @@ jobs:
     # Ref: https://github.com/rdmorganiser/.github/blob/main/.github/workflows/_lint.yml
     uses: rdmorganiser/.github/.github/workflows/_lint.yml@main
 
+  build-wheel:
+    name: Build python wheel
+    runs-on: ubuntu-24.04
+    steps:
+    - uses: actions/checkout@v4
+    # update the version
+    - name: Get short commit SHA
+      run: |
+        if [ "${{ github.event_name }}" = "pull_request" ]; then
+          SHA="${{ github.event.pull_request.head.sha }}"
+        else
+          SHA="${{ github.sha }}"
+        fi
+        echo "SHA=$(git rev-parse --short $SHA)" >> $GITHUB_ENV
+    - name: Get current version (MAJOR.MINOR.PATCH)
+      id: current-version
+      run: echo "current_version=$(grep -Po '(?<=__version__ = ")[\d\w.]+(?=")' rdmo/__init__.py)" >> $GITHUB_OUTPUT
+    - name: Generate new version (current version + SHA)
+      id: new-version
+      run: echo "new_version=${{ steps.current-version.outputs.current_version }}+$SHA" >> $GITHUB_OUTPUT
+    - name: Update version in rdmo/__init__.py
+      run: |
+        sed -i "s/__version__ = .*/__version__ = \"${{ steps.new-version.outputs.new_version }}\"/" rdmo/__init__.py
+    # build the webpack bundle
+    - uses: actions/setup-node@v4
+      with:
+        node-version: 18
+        cache: npm
+    - run: npm install && npm run build:prod
+    # build the wheel
+    - uses: actions/setup-python@v5
+      with:
+        python-version: '3.12'
+        cache: pip
+    - run: |
+        python -m pip install --upgrade pip build[uv] twine
+        python -m pip --version
+    - name: Build the wheel
+      run: python -m build --installer=uv
+    - name: Check the metadata of wheel and sdist
+      run: python -m twine check --strict dist/*
+    - name: Install package from built wheel
+      run: python -m pip install --no-compile dist/rdmo*.whl # do not create __pycache__/*.pyc files
+    - name: Write info to step summary
+      run: |
+          {
+            echo -e "# ✓ Wheel successfully built (v${{ steps.new-version.outputs.new_version }})\n\n"
+            echo '<details><summary>Information about installed wheel</summary>'
+            echo -e "\n\`\`\`console"
+            echo "$ python -m pip show --files --verbose rdmo"
+            python -m pip show --files --verbose rdmo
+            echo -e "\`\`\`\n</details>"
+          } >> $GITHUB_STEP_SUMMARY
+    - name: Upload wheel as artifact
+      uses: actions/upload-artifact@v4
+      with:
+        name: wheel
+        path: dist/rdmo*.whl
+        if-no-files-found: error
+        retention-days: 30
+
   test:
+    name: "Test (Python: ${{ matrix.python-version }}, DB: ${{ matrix.db-backend }})"
+    needs: build-wheel
     runs-on: ubuntu-24.04
     strategy:
       matrix:
         python-version: ['3.8', '3.12']
         db-backend: [mysql, postgres]
-    name: "Test (Python: ${{ matrix.python-version }}, DB: ${{ matrix.db-backend }})"
-    needs: lint
     steps:
     - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
@@ -53,32 +114,35 @@ jobs:
       with:
         python-version: ${{ matrix.python-version }}
         cache: pip
+    - name: Download wheel
+      uses: actions/download-artifact@v4
+      with:
+        name: wheel
+        path: dist
     - name: Install Dependencies
       run: |
-        sudo apt update
-        sudo apt install --yes pandoc texlive-xetex librsvg2-bin
-        python -m pip install --upgrade pip
+        sudo apt-get update && sudo apt-get install --yes pandoc texlive-xetex librsvg2-bin
         pandoc --version
-    - name: Install rdmo[mysql] and start mysql
+        python -m pip install --upgrade pip
+        python -m pip --version
+    - name: Install rdmo[mysql] from wheel and start mysql
       run: |
-        python -m pip install --editable .[ci,mysql]
+        python -m pip install "$(ls dist/*.whl)[ci,mysql]"
         sudo systemctl start mysql.service
       if: matrix.db-backend == 'mysql'
-    - name: Install rdmo[postgres] and start postgresql
+    - name: Install rdmo[postgres] from wheel and start postgresql
       run: |
-        python -m pip install --editable .[ci,postgres]
+        python -m pip install "$(ls dist/*.whl)[ci,postgres]"
         sudo systemctl start postgresql.service
         pg_isready
         sudo -u postgres psql --command="CREATE USER postgres_user PASSWORD 'postgres_password' CREATEDB"
       if: matrix.db-backend == 'postgres'
     - name: Prepare Env
       run: |
-        cp -r testing/media testing/media_root
-        mkdir testing/log
+        cp -r testing/media testing/media_root && mkdir testing/log
     - name: Run package status tests first
       run: |
-        pytest rdmo/core/tests/test_package_status.py::test_package_json_and_pre_commit_versions_match \
-        --nomigrations --verbose
+        pytest rdmo/core/tests/test_package_status.py --nomigrations --verbose
       if: matrix.python-version == '3.12' && matrix.db-backend == 'postgres'
     - name: Run Tests
       run: |
@@ -90,22 +154,48 @@ jobs:
       with:
         flag-name: '${{ matrix.db-backend }}: ${{ matrix.python-version }}'
         parallel: true
-    # end-to-end tests
-    - uses: actions/setup-node@v4
+
+  test-e2e:
+    name: "End-to-end Test (Python: ${{ matrix.python-version }}, DB: ${{ matrix.db-backend }})"
+    needs: build-wheel
+    runs-on: ubuntu-24.04
+    strategy:
+      matrix:
+        python-version: ['3.12']
+        db-backend: [postgres]
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v5
       with:
-        node-version: 18
-        cache: npm
-      if: matrix.python-version == '3.12' && matrix.db-backend == 'postgres'
+        python-version: ${{ matrix.python-version }}
+        cache: pip
+    - name: Download wheel
+      uses: actions/download-artifact@v4
+      with:
+        name: wheel
+        path: dist
+    - name: Install Dependencies
+      run: |
+        sudo apt-get update && sudo apt install --yes pandoc texlive-xetex librsvg2-bin
+        python -m pip install --upgrade pip
+    - name: Install rdmo[postgres] from wheel and start postgresql
+      run: |
+        python -m pip install "$(ls dist/*.whl)[ci,postgres]"
+        sudo systemctl start postgresql.service
+        pg_isready
+        sudo -u postgres psql --command="CREATE USER postgres_user PASSWORD 'postgres_password' CREATEDB"
+    - name: Prepare Env
+      run: |
+        cp -r testing/media testing/media_root && mkdir testing/log
     - name: Install e2e tests dependencies
       run: |
-        npm install
-        npm run build:prod
-        playwright install chromium
-      if: matrix.python-version == '3.12' && matrix.db-backend == 'postgres'
+        playwright install --with-deps chromium
     - run: mkdir screenshots
+    - name: Collect static files into static root (only required if rdmo is installed from wheel)
+      run: python testing/manage.py collectstatic --noinput
     - name: Run end-to-end tests
       run: pytest -p randomly -p no:cacheprovider --reuse-db --numprocesses=auto --dist=loadscope -m e2e --nomigrations
-      if: matrix.python-version == '3.12' && matrix.db-backend == 'postgres'
       env:
         DJANGO_DEBUG: True
         GITHUB_DB_BACKEND: ${{ matrix.db-backend }}
@@ -113,7 +203,6 @@ jobs:
       with:
         name: screenshots
         path: screenshots/*.png
-      if: matrix.python-version == '3.12' && matrix.db-backend == 'postgres'
 
   coveralls:
     name: Indicate completion to coveralls
@@ -126,68 +215,6 @@ jobs:
       with:
         parallel-finished: true
 
-  build-wheel:
-    name: Build python wheel
-    needs: test
-    runs-on: ubuntu-24.04
-    steps:
-    - uses: actions/checkout@v4
-    # update the version
-    - name: Get short commit SHA
-      run: |
-        if [ "${{ github.event_name }}" = "pull_request" ]; then
-          SHA="${{ github.event.pull_request.head.sha }}"
-        else
-          SHA="${{ github.sha }}"
-        fi
-        echo "SHA=$(git rev-parse --short $SHA)" >> $GITHUB_ENV
-    - name: Get current version (MAJOR.MINOR.PATCH)
-      id: current-version
-      run: echo "current_version=$(grep -Po '(?<=__version__ = ")[\d\w.]+(?=")' rdmo/__init__.py)" >> $GITHUB_OUTPUT
-    - name: Generate new version (current version + SHA)
-      id: new-version
-      run: echo "new_version=${{ steps.current-version.outputs.current_version }}+$SHA" >> $GITHUB_OUTPUT
-    - name: Update version in rdmo/__init__.py
-      run: |
-        sed -i "s/__version__ = .*/__version__ = \"${{ steps.new-version.outputs.new_version }}\"/" rdmo/__init__.py
-    # build the webpack bundle
-    - uses: actions/setup-node@v4
-      with:
-        node-version: 18
-        cache: npm
-    - run: npm install && npm run build:prod
-    # build the wheel
-    - uses: actions/setup-python@v5
-      with:
-        python-version: '3.12'
-        cache: pip
-    - run: |
-        python -m pip install --upgrade pip build[uv] twine
-        pip --version
-    - name: Build the wheel
-      run: python -m build --installer=uv
-    - name: Check the metadata of wheel and sdist
-      run: python -m twine check --strict dist/*
-    - name: Install package from built wheel
-      run: python -m pip install --no-compile dist/rdmo*.whl # do not create __pycache__/*.pyc files
-    - name: Write info to step summary
-      run: |
-          {
-            echo -e "# ✓ Wheel successfully built (v${{ steps.new-version.outputs.new_version }})\n\n"
-            echo '<details><summary>Information about installed wheel</summary>'
-            echo -e "\n\`\`\`console"
-            echo "$ python -m pip show --files --verbose rdmo"
-            python -m pip show --files --verbose rdmo
-            echo -e "\`\`\`\n</details>"
-          } >> $GITHUB_STEP_SUMMARY
-    - name: Upload wheel as artifact
-      uses: actions/upload-artifact@v4
-      with:
-        name: wheel
-        path: dist/rdmo*.whl
-        if-no-files-found: error
-        retention-days: 30
-
   dev-setup:
     # Ref: structlog (MIT licensed) <https://github.com/hynek/structlog/blob/main/.github/workflows/ci.yml>
     name: "Test dev setup on ${{ matrix.os }}"
@@ -206,6 +233,7 @@ jobs:
 
   dependencies:
     name: Test installation of all dependencies
+    needs: build-wheel
     runs-on: ubuntu-24.04
     steps:
       - uses: actions/checkout@v4
@@ -213,12 +241,18 @@ jobs:
         with:
           python-version: "3.12"
           cache: pip
+      - name: Download wheel
+        uses: actions/download-artifact@v4
+        with:
+          name: wheel
+          path: dist
       - name: Install os requirements for python-ldap
-        run: |
-          sudo apt update
-          sudo apt install --yes libldap2-dev libsasl2-dev
+        run: sudo apt-get update && sudo apt-get install --yes libldap2-dev libsasl2-dev
       - run: python -m pip install --upgrade pip
-      - run: python -m pip install .[allauth,ci,dev,gunicorn,ldap,mysql,postgres,pytest]
+      - name: Install rdmo wheel with all optional dependency groups
+        run: python -m pip install --no-compile "$(ls dist/*.whl)[allauth,ci,dev,gunicorn,ldap,mysql,postgres,pytest]"
+      - name: Verify installed packages have compatible dependencies
+        run: python -m pip check
       - uses: actions/setup-node@v4
         with:
           node-version: 18
@@ -254,9 +288,10 @@ jobs:
     if: always()
     needs:
       - lint
+      - build-wheel
       - test
       - coveralls
-      - build-wheel
+      - test-e2e
       - dev-setup
       - dependencies
     runs-on: ubuntu-24.04


Test patch:
diff --git a/rdmo/management/tests/conftest.py b/rdmo/management/tests/conftest.py
deleted file mode 100644
index 79cbc2d4aa..0000000000
--- a/rdmo/management/tests/conftest.py
+++ /dev/null
@@ -1,49 +0,0 @@
-import pytest
-
-from django.core.management import call_command
-
-from playwright.sync_api import BrowserType, Page, expect
-from pytest_django.live_server_helper import LiveServer
-
-from rdmo.accounts.utils import set_group_permissions
-
-
-@pytest.fixture
-def _e2e_tests_django_db_setup(django_db_setup, django_db_blocker, fixtures):
-    """Set up database and populate with fixtures, that get restored for every test case."""
-    with django_db_blocker.unblock():
-        call_command("loaddata", *fixtures)
-        set_group_permissions()
-
-
-
-@pytest.fixture
-def base_url_page(live_server: LiveServer, browser: BrowserType) -> Page:
-    """Enable playwright to address URLs with base URL automatically prefixed."""
-    context = browser.new_context(base_url=live_server.url)
-    page = context.new_page()
-    yield page
-    context.close()
-
-
-# helper function for logging in the user
-def login_user(page: Page, username: str, password: str) -> Page:
-    page.goto("/account/login")
-    page.get_by_label("Username").fill(username, timeout=5000)
-    page.get_by_label("Password").fill(password)
-    page.get_by_role("button", name="Login").click()
-    page.goto("/management")
-    return page
-
-def logout_user(page: Page):
-    page.goto("/account/logout")
-    page.get_by_role("button", name="Logout").click()
-    expect(page).to_have_url('/')
-    return page
-
-@pytest.fixture
-def logged_in_user(_e2e_tests_django_db_setup, base_url_page, username:str, password: str) -> Page:
-    """Log in as admin user through Django login UI, returns logged in page for e2e tests."""
-    page = login_user(base_url_page, username, password)
-    yield page
-    logout_user(page)
diff --git a/rdmo/management/tests/e2e/__init__.py b/rdmo/management/tests/e2e/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/rdmo/management/tests/e2e/conftest.py b/rdmo/management/tests/e2e/conftest.py
new file mode 100644
index 0000000000..44d1321a2f
--- /dev/null
+++ b/rdmo/management/tests/e2e/conftest.py
@@ -0,0 +1,72 @@
+import os
+
+import pytest
+
+from django.conf import settings
+from django.contrib.auth import get_user_model
+from django.core.management import call_command
+from django.test import Client
+
+from playwright.sync_api import Page
+
+from rdmo.accounts.utils import set_group_permissions
+
+USERNAME = "editor"  # the user needs exist in the database
+PLAYWRIGHT_TIMEOUT = 10_000  # timeout in ms
+
+
+@pytest.fixture(scope="session", autouse=True)
+def _set_django_allow_async_unsafe():
+    """pytest-playwright needs this setting to be enabled."""
+    os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
+
+
+@pytest.fixture
+def django_db_setup(django_db_setup, django_db_blocker, fixtures): # noqa: PT004 - pytest-django requires this name "django_db_setup"
+    """Set up database and populate with fixtures, that get restored for every test case.
+
+    This fixture overrides the django_db_setup in the main conftest.py, this only applies to the e2e tests
+    in this directory.
+    """
+    with django_db_blocker.unblock():
+        call_command("loaddata", *fixtures, verbosity=0)
+        set_group_permissions()
+
+
+@pytest.fixture
+def authenticated_client(db) -> Client:
+    """An authenticated test client, used to bypass the login page."""
+    user = get_user_model().objects.get(username=USERNAME)
+    client = Client()
+    client.user = user  # attach user to client to access in other fixtures
+    client.force_login(user)
+    return client
+
+
+@pytest.fixture
+def page(live_server, browser, authenticated_client) -> Page:
+    """An authenticated playwright page.
+
+    The page is authenticated with session cookies from authenticated_client.
+    The page has access to the live server and starts at "/management".
+    The page has the authenticated user attached to it.
+    """
+
+    # retrieve the session cookie from the authenticated client
+    session_cookie = authenticated_client.cookies[settings.SESSION_COOKIE_NAME]
+    cookie = {
+        "name": session_cookie.key,
+        "value": session_cookie.value,
+        "url": live_server.url,
+    }
+    context = browser.new_context(base_url=live_server.url)
+    # the browser context is now "authenticated" with the session cookie
+    context.add_cookies([cookie])
+    page = context.new_page()
+    page.set_default_timeout(PLAYWRIGHT_TIMEOUT)
+    page.set_default_navigation_timeout(PLAYWRIGHT_TIMEOUT)
+    page.user = authenticated_client.user  # attach user to page to access in tests
+    # the page starts at base_url + /management
+    page.goto("/management")
+    yield page
+    context.close()
diff --git a/rdmo/management/tests/test_frontend_import_options.py b/rdmo/management/tests/e2e/test_frontend_import_options.py
similarity index 76%
rename from rdmo/management/tests/test_frontend_import_options.py
rename to rdmo/management/tests/e2e/test_frontend_import_options.py
index 2cf980de61..b3f2dab3ba 100644
--- a/rdmo/management/tests/test_frontend_import_options.py
+++ b/rdmo/management/tests/e2e/test_frontend_import_options.py
@@ -1,46 +1,35 @@
 # ruff: noqa: F811
-import os
-
 import pytest
 
 from playwright.sync_api import Page, expect
 
+from rdmo.management.tests.helpers_import_elements import IMPORT_ELEMENT_PANELS_LOCATOR
+from rdmo.management.tests.helpers_models import delete_all_objects
 from rdmo.options.models import Option, OptionSet
 
-from .helpers_import_elements import IMPORT_ELEMENT_PANELS_LOCATOR
-from .helpers_models import delete_all_objects
-
 pytestmark = pytest.mark.e2e
 
-# needed for playwright to run
-os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
-
-test_users = [('editor', 'editor')]
 import_xml = "./testing/xml/elements/optionsets.xml"
 import_xml_1 = "./testing/xml/elements/updated-and-changed/optionsets-1.xml"
-OPTIONSETS_COUNTS = {
-    "total": 13,
-    "updated": 13,
-    "changed": 5,
-    "warnings": 2
-}
-OPTIONSETS_COUNTS_HEADER_INFOS = [f"{k.capitalize()}: {v}" for k,v in OPTIONSETS_COUNTS.items()]
+OPTIONSETS_COUNTS = {"total": 13, "updated": 13, "changed": 5, "warnings": 2}
+OPTIONSETS_COUNTS_HEADER_INFOS = [f"{k.capitalize()}: {v}" for k, v in OPTIONSETS_COUNTS.items()]
 # Defined in filterCheckBoxText in rdmo/management/assets/js/components/import/common/ImportFilters.js
 IMPORT_FILTER_LABEL_TEXT = 'Show only new and changed elements (%s)'
 
-@pytest.mark.parametrize("username,password", test_users)  # consumed by fixture
-def test_import_and_update_optionsets_in_management(logged_in_user: Page) -> None:
+
+def test_import_and_update_optionsets_in_management(page: Page) -> None:
     """Test that each content type is available through the navigation."""
     delete_all_objects([OptionSet, Option])
 
-    page = logged_in_user
     expect(page.get_by_role("heading", name="Management")).to_be_visible()
     expect(page.locator("strong").filter(has_text="Catalogs")).to_be_visible()
     ## 1. Import fresh optionset.xml
     # choose the file to be imported
-    page.locator("input[name=\"uploaded_file\"]").set_input_files(import_xml)
+    page.locator('input[name="uploaded_file"]').set_input_files(import_xml)
     # click the import form submit button, this will take some time
-    page.locator('#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary').click()  # noqa: E501
+    page.locator(
+        "#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary"
+    ).click()
     # wait for import to be finished with timeout 30s
     expect(page.get_by_text("Import from: optionsets.xml")).to_be_visible(timeout=30_000)
     ## TODO test if ImportInfo numbers are correct
@@ -51,7 +40,7 @@ def test_import_and_update_optionsets_in_management(logged_in_user: Page) -> Non
     page.get_by_role("link", name="Select all", exact=True).click()
     page.get_by_role("link", name="Show all", exact=True).click()
     rows_displayed_in_ui = page.locator(IMPORT_ELEMENT_PANELS_LOCATOR)
-    expect(rows_displayed_in_ui).to_have_count(OPTIONSETS_COUNTS['total'])
+    expect(rows_displayed_in_ui).to_have_count(OPTIONSETS_COUNTS["total"])
     # click the import button to start saving the instances to the db
     page.get_by_role("button", name=f"Import {OPTIONSETS_COUNTS['total']} elements").click()
     expect(page.get_by_role("heading", name="Import successful")).to_be_visible()
@@ -66,9 +55,11 @@ def test_import_and_update_optionsets_in_management(logged_in_user: Page) -> Non
 
     ## 2. import optionset-1.xml with changes
     # choose the file to be imported
-    page.locator("input[name=\"uploaded_file\"]").set_input_files(import_xml_1)
+    page.locator('input[name="uploaded_file"]').set_input_files(import_xml_1)
     # click the import form submit button, this will take some time
-    page.locator('#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary').click()  # noqa: E501
+    page.locator(
+        "#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary"
+    ).click()
     expect(page.get_by_text("Import from: optionsets-1.xml")).to_be_visible(timeout=40_000)
     # assert changed elements
     for text in OPTIONSETS_COUNTS_HEADER_INFOS:
diff --git a/rdmo/management/tests/test_frontend_import_questions.py b/rdmo/management/tests/e2e/test_frontend_import_questions.py
similarity index 75%
rename from rdmo/management/tests/test_frontend_import_questions.py
rename to rdmo/management/tests/e2e/test_frontend_import_questions.py
index 7b04e42489..3de152050c 100644
--- a/rdmo/management/tests/test_frontend_import_questions.py
+++ b/rdmo/management/tests/e2e/test_frontend_import_questions.py
@@ -1,36 +1,30 @@
 # ruff: noqa: F811
-import os
-
 import pytest
 
-from playwright.sync_api import Page, expect
+from playwright.sync_api import expect
 
+from rdmo.management.tests.helpers_import_elements import IMPORT_ELEMENT_PANELS_LOCATOR_SHOWN
+from rdmo.management.tests.helpers_models import delete_all_objects
 from rdmo.questions.models import Catalog, Question, Section
 from rdmo.questions.models import Page as PageModel
 from rdmo.questions.models.questionset import QuestionSet
 
-from .helpers_import_elements import IMPORT_ELEMENT_PANELS_LOCATOR_SHOWN
-from .helpers_models import delete_all_objects
-
 pytestmark = pytest.mark.e2e
 
-# needed for playwright to run
-os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
 
-test_users = [('editor', 'editor')]
-
-@pytest.mark.parametrize("username,password", test_users)  # consumed by fixture
-def test_import_catalogs_in_management(logged_in_user: Page) -> None:
+def test_import_catalogs_in_management(page) -> None:
     """Test that the catalogs.xml can be imported correctly."""
+
     delete_all_objects([Catalog, Section, PageModel, QuestionSet, Question])
 
-    page = logged_in_user
     expect(page.get_by_role("heading", name="Management")).to_be_visible()
     expect(page.locator("strong").filter(has_text="Catalogs")).to_be_visible()
     # choose the file to be imported
-    page.locator("input[name=\"uploaded_file\"]").set_input_files("./testing/xml/elements/catalogs.xml")
+    page.locator('input[name="uploaded_file"]').set_input_files("./testing/xml/elements/catalogs.xml")
     # click the import form submit button, this will take some time
-    page.locator('#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary').click()  # noqa: E501
+    page.locator(
+        "#sidebar div.elements-sidebar form.upload-form.sidebar-form div.sidebar-form-button button.btn.btn-primary"
+    ).click()
     # wait for import to be finished with timeout 30s
     expect(page.get_by_text("Import from: catalogs.xml")).to_be_visible(timeout=30_000)
     ## TODO test if ImportInfo numbers are correct
diff --git a/rdmo/management/tests/test_frontend_management_elements.py b/rdmo/management/tests/e2e/test_frontend_management_elements.py
similarity index 75%
rename from rdmo/management/tests/test_frontend_management_elements.py
rename to rdmo/management/tests/e2e/test_frontend_management_elements.py
index e735598e9c..47c51e9483 100644
--- a/rdmo/management/tests/test_frontend_management_elements.py
+++ b/rdmo/management/tests/e2e/test_frontend_management_elements.py
@@ -1,5 +1,4 @@
 # ruff: noqa: F811
-import os
 import re
 from urllib.parse import urlparse
 
@@ -9,22 +8,15 @@
 
 from rdmo.conditions.models import Condition
 from rdmo.domain.models import Attribute
+from rdmo.management.tests.helpers_models import ModelHelper, model_helpers
 from rdmo.questions.models import Catalog
 
-from .helpers_models import ModelHelper, model_helpers
-
 pytestmark = pytest.mark.e2e
 
-# needed for playwright to run
-os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
-
-test_users = [('editor', 'editor')]
 
-@pytest.mark.parametrize("username,password", test_users)
 @pytest.mark.parametrize("helper", model_helpers)
-def test_management_navigation(logged_in_user: Page, helper: ModelHelper, username: str, password: str) -> None:
+def test_management_navigation(page: Page, helper: ModelHelper) -> None:
     """Test that each content type is available through the navigation."""
-    page = logged_in_user
     expect(page.get_by_role("heading", name="Management")).to_be_visible()
 
     # click a link in the navigation
@@ -43,25 +35,18 @@ def test_management_navigation(logged_in_user: Page, helper: ModelHelper, userna
         page.screenshot(path="screenshots/management-navigation-catalog.png", full_page=True)
 
 
-
-@pytest.mark.parametrize("username,password", test_users)
 @pytest.mark.parametrize("helper", model_helpers)
-def test_management_has_items(logged_in_user: Page, helper: ModelHelper) -> None:
+def test_management_has_items(page: Page, helper: ModelHelper) -> None:
     """Test all items in database are visible in management UI."""
-    page = logged_in_user
     num_items_in_database = helper.model.objects.count()
     page.goto(f"/management/{helper.url}")
     items_in_ui = page.locator(".list-group > .list-group-item")
     expect(items_in_ui).to_have_count(num_items_in_database)
 
 
-@pytest.mark.parametrize("username,password", test_users)
 @pytest.mark.parametrize("helper", model_helpers)
-def test_management_nested_view(
-    logged_in_user: Page, helper: ModelHelper
-) -> None:
+def test_management_nested_view(page: Page, helper: ModelHelper) -> None:
     """For each element type, that has a nested view, click the first example."""
-    page = logged_in_user
     page.goto(f"/management/{helper.url}")
     # Open nested view for element type
     if helper.has_nested:
@@ -70,13 +55,9 @@ def test_management_nested_view(
         expect(page.locator(".panel-default > .panel-body").first).to_be_visible()
 
 
-@pytest.mark.parametrize("username,password", test_users)
 @pytest.mark.parametrize("helper", model_helpers)
-def test_management_create_model(
-    logged_in_user: Page, helper: ModelHelper
-) -> None:
+def test_management_create_model(page: Page, helper: ModelHelper) -> None:
     """Test management UI can create objects in the database."""
-    page = logged_in_user
     num_objects_at_start = helper.model.objects.count()
     page.goto(f"/management/{helper.url}")
     # click "New" button
@@ -86,11 +67,7 @@ def test_management_create_model(
     page.get_by_label(helper.form_field).fill(value)
     if helper.model == Condition:
         # conditions need to have a source attribute
-        source_form = (
-            page.locator(".form-group")
-            .filter(has_text="Source")
-            .locator(".select-item > .react-select")
-        )
+        source_form = page.locator(".form-group").filter(has_text="Source").locator(".select-item > .react-select")
         source_form.click()
         page.keyboard.type(Attribute.objects.first().uri)
         page.keyboard.press("Enter")
@@ -107,10 +84,8 @@ def test_management_create_model(
     assert helper.model.objects.get(**query)
 
 
-@pytest.mark.parametrize("username,password", test_users)
 @pytest.mark.parametrize("helper", model_helpers)
-def test_management_edit_model(logged_in_user: Page, helper: ModelHelper) -> None:
-    page = logged_in_user
+def test_management_edit_model(page: Page, helper: ModelHelper) -> None:
     page.goto(f"/management/{helper.url}")
     # click edit
     edit_button_title = f"Edit {helper.verbose_name}"
diff --git a/testing/manage.py b/testing/manage.py
new file mode 100644
index 0000000000..0004541852
--- /dev/null
+++ b/testing/manage.py
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)