Skip to content

Testing

Running tests

# Full test suite with coverage (requires ≥ 80%)
conda run -n denbi-registry pytest tests/

# Or via make:
make test

# HTML coverage report
make test-cov
# then open htmlcov/index.html

Tests use SQLite in-memory and local-memory cache. No PostgreSQL, Redis, or external network access is required.


Test configuration

pytest.ini — points to config.settings_test and enables coverage by default:

[pytest]
DJANGO_SETTINGS_MODULE = config.settings_test
addopts = -v --tb=short --cov=apps --cov-report=term-missing --cov-fail-under=80

config/settings_test.py — key overrides:

Setting Value Why
DATABASES SQLite :memory: Zero setup, fast
CACHES LocMemCache No Redis needed
CELERY_TASK_ALWAYS_EAGER True Tasks run synchronously
EMAIL_BACKEND locmem Emails captured in mail.outbox
PASSWORD_HASHERS MD5 Fast hashing in tests
ALTCHA_HMAC_KEY "" CAPTCHA verification bypassed — tests submit forms without solving PoW. Tests that specifically need ALTCHA active use override_settings(ALTCHA_HMAC_KEY="test-key")
RATELIMIT_ENABLE False No throttle blocking mid-suite
DEFAULT_THROTTLE_CLASSES [] No DRF throttling
MEDIA_ROOT tempfile.mkdtemp() File uploads go to a temp dir — never accumulate in the project tree

Test files

File What it covers
test_models.py ServiceSubmission, SubmissionAPIKey validation, sanitisation, sensitive fields
test_views.py Registration form, update flow, session handling, logo upload via views, deprecation via edit form, ALTCHA challenge endpoint, ALTCHA verification on register and edit (missing payload, invalid payload, expired challenge, valid solved challenge, bypass when key empty, widget presence/absence in GET responses), health endpoints
test_forms.py SubmissionForm required fields, cross-field rules, URL validation, logo clean_logo()
test_api.py All REST endpoints — auth, permissions, response shape, error envelopes, logo upload, ?status=deprecated filter
test_admin.py Admin bulk actions (deprecate/undeprecate), change-view buttons, CSV/JSON export completeness
test_security.py API key auth, logging scrubber, CSRF, request ID middleware, ALTCHA challenge security (public access, JSON content type, HMAC key not leaked, non-empty fields, Cache-Control: no-store)
test_tasks.py Celery email notification and cleanup tasks
test_biotools.py bio.tools client (HTTP mocks), sync logic, tasks, signals, views
test_management_commands.py sync_edam, sync_biotools management commands, template tags, context processor
test_logo_utils.py validate_and_process_logo() — magic bytes, size limits, EXIF stripping, SVG sanitisation, XML attack prevention (XXE/billion-laughs), path traversal
test_template_tags.py linkify_description filter — named links, bare URLs, paragraph/line breaks, XSS escaping

Total: ~450 tests (enforced ≥ 80% coverage threshold).


Factories

tests/factories.py provides factory_boy factories for all models:

from tests.factories import ServiceSubmissionFactory, APIKeyFactory

# Creates a complete submission with all required fields
submission = ServiceSubmissionFactory()

# Override specific fields
submission = ServiceSubmissionFactory(
    service_name="My Service",
    biotools_url="",   # empty so signal doesn't trigger sync
)

# Create an API key with the plaintext for testing auth
key, plaintext = APIKeyFactory.create_with_plaintext(submission=submission)

Signal side effect

Creating a ServiceSubmissionFactory with a non-empty biotools_url triggers the post_save signal, which runs sync_biotools_record eagerly (Celery eager mode). If you need to test sync separately, create the submission with biotools_url="" and trigger sync manually.


Writing new tests

Standard test class structure

import pytest
from tests.factories import ServiceSubmissionFactory

@pytest.mark.django_db
class TestMyFeature:

    def test_something(self):
        submission = ServiceSubmissionFactory()
        # ... assertions

Mocking HTTP calls

Never make real network calls in tests. Use unittest.mock.patch:

from unittest.mock import patch

def test_biotools_client():
    with patch("urllib.request.urlopen") as mock_urlopen:
        mock_resp = MagicMock()
        mock_resp.read.return_value = b'{"name": "BLAST"}'
        mock_resp.__enter__ = lambda s: s
        mock_resp.__exit__ = MagicMock(return_value=False)
        mock_urlopen.return_value = mock_resp

        client = BioToolsClient()
        # test client behaviour...

Mocking Celery tasks

Tasks with internal imports need to be patched at the source module, not at the point of import:

# In tasks.py: from .sync import sync_tool
# Correct patch target:
with patch("apps.biotools.sync.sync_tool", return_value=expected):
    result = sync_biotools_record(str(submission.pk))

Testing API endpoints

from rest_framework.test import APIClient
from tests.factories import ServiceSubmissionFactory, APIKeyFactory

@pytest.mark.django_db
class TestMyEndpoint:

    def setup_method(self):
        self.client = APIClient()

    def test_requires_auth(self):
        response = self.client.get("/api/v1/submissions/some-id/")
        assert response.status_code in (401, 403)

    def test_with_valid_key(self):
        submission = ServiceSubmissionFactory(biotools_url="")
        key, plaintext = APIKeyFactory.create_with_plaintext(submission=submission)
        self.client.credentials(HTTP_AUTHORIZATION=f"ApiKey {plaintext}")
        response = self.client.get(f"/api/v1/submissions/{submission.pk}/")
        assert response.status_code == 200

Testing email dispatch

from django.core import mail

def test_sends_email(self):
    # ... trigger action that sends email
    assert len(mail.outbox) == 1
    assert "My Service" in mail.outbox[0].subject

Coverage

The coverage threshold is 80% enforced by --cov-fail-under=80 in pytest.ini. The CI pipeline fails if coverage drops below this.

Current coverage by module (approximate):

Module Coverage
api/ ~90%
biotools/ ~90%
submissions/models.py ~96%
submissions/forms.py ~88%
submissions/logo_utils.py ~91%
submissions/views.py ~88%
submissions/admin.py ~53% (admin UI is hard to test)
edam/management/commands/sync_edam.py ~91%

Admin code is intentionally at lower coverage — Django's admin class methods require a running admin site with a logged-in superuser, which adds significant test setup complexity with limited return value.