FairDM Testing
Stack
- pytest + pytest-django — test runner and Django integration
- factory-boy — model instance generation
- Coverage.py — coverage reporting (configured in pyproject.toml)
Never use unittest, TestCase, or setUp/tearDown.
Directory Structure
Tests mirror the fairdm/ source tree with test_ prefixes:
fairdm/core/project/models.py → tests/test_core/test_project/test_models.py
fairdm/contrib/contributors/ → tests/test_contrib/test_contributors/
fairdm/registry/config.py → tests/test_registry/test_config.py
fairdm/plugins.py → tests/test_plugins.py
fairdm/db/fields.py → tests/test_db/test_fields.py
See references/structure-map.md for the complete mapping.
Rules:
- Test directories:
test_<dirname>/ - Test files:
test_<module>.py - Every directory needs an
__init__.py - No layer separation (no
unit/,integration/,contract/subdirectories) - Unit and integration tests for a module live together in the same file
Running Tests
poetry run pytest # all tests
poetry run pytest tests/test_core/ # one subtree
poetry run pytest -k "test_project" # by name
poetry run pytest -m slow # only slow-marked tests
poetry run pytest --no-header -q # quiet output
Factories
Import from the canonical path:
from fairdm.factories import (
ContributorFactory, DatasetFactory, MeasurementFactory,
OrganizationFactory, PersonFactory, ProjectFactory,
SampleFactory, UserFactory,
)
Factory Opt-In Pattern
FairDM factories follow an opt-in pattern for creating related metadata (descriptions, dates):
Key Principle: By default, factories create only required fields. No descriptions or dates are auto-created.
Why Opt-In?
- Prevents unique constraint violations — Models have
unique_togetheron(related, type). Auto-creating metadata could conflict with test-specific data. - Minimizes test data — Most tests don't need metadata. Creating it by default adds unnecessary DB records and slows tests.
- Explicit control — When you need metadata, you explicitly control count and types.
Usage Patterns:
# Minimal (no metadata) - DEFAULT behavior
project = ProjectFactory()
assert project.descriptions.count() == 0
assert project.dates.count() == 0
# With metadata (opt-in via kwargs)
project = ProjectFactory(descriptions=2, dates=1)
assert project.descriptions.count() == 2
assert project.dates.count() == 1
# Custom types (validated against model VOCABULARY)
project = ProjectFactory(
descriptions=3,
descriptions__types=["Abstract", "Introduction", "Objectives"]
)
# All core factories support this pattern
dataset = DatasetFactory(descriptions=2, dates=1)
sample = SampleFactory(descriptions=1, dates=2)
measurement = MeasurementFactory(descriptions=2)
Vocabulary Validation:
All description/date types are validated against the model's VOCABULARY.values:
# ✓ Valid - types exist in ProjectDescription.VOCABULARY
project = ProjectFactory(
descriptions=2,
descriptions__types=["Abstract", "Methods"]
)
# ✗ Raises ValueError - "InvalidType" not in VOCABULARY
project = ProjectFactory(
descriptions=1,
descriptions__types=["InvalidType"]
)
Available VOCABULARY types:
ProjectDescription.VOCABULARY.values: Abstract, Introduction, Background, Objectives, ExpectedOutput, Conclusions, Other (+ sample/measurement-specific types)ProjectDate.VOCABULARY.values: Start, EndDatasetDescription.VOCABULARY.values: Similar to ProjectDatasetDate.VOCABULARY.values: Check model for available typesSampleDescription.VOCABULARY.values: Includes SampleCollection, SamplePreparation, etc.MeasurementDescription.VOCABULARY.values: Includes MeasurementConditions, MeasurementSetup, etc.
Batch Creation with Metadata:
# All projects get 2 descriptions and 1 date
projects = ProjectFactory.create_batch(5, descriptions=2, dates=1)
# Custom types for all
datasets = DatasetFactory.create_batch(
3,
descriptions=2,
descriptions__types=["Abstract", "Methods"]
)
Factory Patterns (General)
SubFactoryfor FK relations — never manually create parent objects when a factory existsfactory.Sequence(lambda n: f"value{n}")for unique fieldscreate_batch(n)for bulk creation- Traits for alternate configurations
Portal-specific factories (e.g. demo app custom samples) are mapped via FAIRDM_FACTORIES
in test settings.
Fixtures
Built-in pytest-django fixtures
Use directly — no need to redeclare: db, client, rf, admin_user,
django_user_model, django_assert_num_queries.
Project fixtures (tests/fixtures/pytest_fixtures.py)
def test_something(user): # UserFactory()
def test_something(project): # ProjectFactory(owner=user)
def test_something(project_with_datasets): # (project, [3 datasets])
Registry fixtures (tests/test_registry/conftest.py)
def test_registration(clean_registry): # empty registry, cleaned after test
def test_dynamic_model(unique_app_label): # "test_app_<hex>" for Meta.app_label
# cleanup_test_app_models runs autouse — removes test_app_* from Django app registry
Prefer a factory call over a fixture when the fixture would just wrap a single factory call.
Test Organization
Group tests into classes by subject. One class per logical unit under test:
@pytest.mark.django_db
class TestProjectModel:
def test_creation_with_required_fields(self): ...
def test_uuid_is_unique(self): ...
def test_status_choices(self): ...
@pytest.mark.django_db
class TestProjectCreateForm:
def test_valid_with_required_fields(self): ...
def test_invalid_without_name(self): ...
Naming: test_<what_is_being_tested> — descriptive, no abbreviations.
Test Style
@pytest.mark.django_dbon every class or function that touches the database- Arrange–Act–Assert, separated by blank lines
- One logical assertion per test (unless tightly coupled)
- Deterministic and isolated — no cross-test state, no time-dependent logic
@pytest.mark.parametrizefor repeated logic with different inputs@pytest.mark.slowfor tests that take >1 second
Performance Tests
Never use wall-clock timing assertions (assert elapsed < 0.1).
Use query-count guards instead:
def test_list_view_query_count(client, django_assert_num_queries):
ProjectFactory.create_batch(10)
with django_assert_num_queries(3):
client.get("/projects/")
Or algorithmic complexity guards (assert O(n) not O(n²)).
Exemplar Patterns
See references/exemplar-patterns.md for complete tested patterns covering:
- Model tests (creation, constraints, field validation, relationships)
- Form tests (valid/invalid data, field requirements, business rules)
- View tests (GET/POST, permissions, redirects, context data)
- Admin tests (registration, list display, actions)
- Filter tests (queryset filtering, filterset configuration)
- Registry tests (registration, validation, dynamic models)
What NOT to Do
See references/anti-patterns.md for common mistakes and their corrections.
Test Settings (tests/settings.py)
Key configuration (already set up — do not modify without reason):
- In-memory SQLite database
- Migrations disabled via
DisableMigrationsclass - MD5 password hasher (fast, insecure — test-only)
- DummyCache backend
fairdm.setup(apps=["fairdm_demo"])loads the demo appFAIRDM_FACTORIESmaps demo models to their factories
Checklist for New Tests
- Identify the source module → derive the test file path from the mirror structure
- Create the test file with
__init__.pyin any new directories - Import from
fairdm.factoriesfor model instances - Group tests in classes by subject (
TestXxxModel,TestXxxForm, etc.) - Mark DB-accessing tests with
@pytest.mark.django_db - Run
poetry run pytest <test_file>to verify - Ensure coverage does not decrease
Historical Note
This skill file supersedes the original specs/002-testing-strategy spec (created January 2026, removed February 2026). The skill format provides more maintainable, agent-friendly documentation of FairDM's testing conventions.
Converted and distributed by TomeVault — claim your Tome and manage your conversions.