Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ The suite has two complementary styles:
| **Unit** | `SimpleTestCase` + `MagicMock` | Pure logic — formatters, BibTeX generation, single-method behavior. No DB; runs in milliseconds. |
| **Integration** | `DatabaseTestCase` (subclass of Django's `TestCase`, in `website/tests/base.py`) | View, queryset, template, and URL-routing regressions. Each test runs inside a transaction that is rolled back, so tests stay isolated. |

The `DatabaseTestCase` base provides `make_person`, `make_publication`, `make_talk`, and `make_news_item` helpers built on plain `Model.objects.create()` — use those rather than hand-rolling fixtures.
The `DatabaseTestCase` base provides `make_person`, `make_publication`, `make_talk`, and `make_news_item` helpers — use those rather than hand-rolling fixtures. They're thin wrappers over the `factory_boy` factories in `website/tests/factories.py`, which are the single source of truth for building model instances; reach for a factory directly when you need an entity the helpers don't cover or want to customize fields.

### When to add a test

Expand Down
17 changes: 16 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,4 +123,19 @@ django-ckeditor==6.7.3
pyOpenSSL==26.0.0

# Requests - HTTP library for Python
requests==2.33.0
requests==2.33.0

# -----------------------------------------------------------------------------
# Testing
# -----------------------------------------------------------------------------
# factory_boy - model fixtures for the test suite (#1272). The de-facto Django
# standard; we use explicit factories (website/tests/factories.py) over
# model_bakery because they're easier to debug with our M2M / SortedManyToMany /
# ProjectRole-through relationship graph. Pulls in Faker (pinned below) as a dep.
# See: https://factoryboy.readthedocs.io/
factory_boy==3.3.3

# Faker - realistic fake data (names, sentences, dates) used by the factories.
# Dependency of factory_boy; pinned explicitly so test data stays reproducible.
# See: https://faker.readthedocs.io/
Faker==40.23.0
118 changes: 39 additions & 79 deletions website/tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,103 +12,69 @@
coverage incrementally. See #1267 for the broader plan.
"""

from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from datetime import date as _date

from django.test import TestCase

# Minimal 1x1 GIF used to satisfy Person.image / Person.easter_egg without
# touching the filesystem. Person.save() picks a random Star Wars image when
# either field is empty, opening a real file from media/. Pre-setting both
# with this SimpleUploadedFile skips the fallback branch entirely.
_GIF_1PX = (
b"GIF89a\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00"
b"!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01"
b"\x00\x00\x02\x02D\x01\x00;"
from website.tests.factories import (
NewsItemFactory,
PersonFactory,
ProjectFactory,
PublicationFactory,
TalkFactory,
VideoFactory,
image_upload,
)


def _make_image_upload(name):
"""Return a SimpleUploadedFile that satisfies an ImageField."""
return SimpleUploadedFile(name, _GIF_1PX, content_type="image/gif")


class DatabaseTestCase(TestCase):
"""
Shared base for tests that touch the database. Provides small fixture
helpers (make_person / make_publication / make_news_item) built on
plain Model.objects.create() — no third-party fixture library. Each
test runs inside a transaction and is rolled back, so tests stay
isolated without manual cleanup.

Why a base class instead of module-level helpers: subclasses can
override the defaults in setUp() and the helpers can grow without
cluttering the module namespace.
helpers (make_person / make_publication / make_news_item / ...) that
delegate to the factory_boy factories in :mod:`website.tests.factories`
(#1272). The factories are the single source of truth for building
instances; these helpers preserve the original keyword API (notably the
``year`` shorthand) so the existing suite keeps working unchanged. Each
test runs inside a transaction and is rolled back, so tests stay isolated
without manual cleanup.

Why keep the helpers at all: they encode test-friendly defaults (fixed
dates via ``year``, ``with_thumbnail`` for the project visibility backfill)
and give subclasses a stable seam to override in ``setUp()``. Tests that
want richer fixtures (Faker values, batches, the relationship graph) can
import and use the factories directly.
"""

def make_person(self, first_name="Jane", last_name="Doe", **kwargs):
"""
Create and return a Person. Image fields are pre-populated to
skip Person.save()'s Star Wars fallback (which reads a real file
from media/). Override by passing image=... explicitly.
Create and return a Person. Image fields are pre-populated by
PersonFactory to skip Person.save()'s Star Wars fallback (which reads a
real file from media/). Override by passing image=... explicitly.
"""
from website.models import Person
kwargs.setdefault(
"image", _make_image_upload(f"{first_name}_{last_name}.gif")
)
kwargs.setdefault(
"easter_egg",
_make_image_upload(f"{first_name}_{last_name}_egg.gif"),
)
return Person.objects.create(
return PersonFactory(
first_name=first_name, last_name=last_name, **kwargs
)

def make_publication(self, title="A Test Paper", year=2024, **kwargs):
"""
Create and return a Publication with sensible defaults: post-lab-
formation date, conference venue, a forum name, and a dummy PDF
(display_pub_snippet.html unconditionally renders pub.pdf_file.url,
so tests that go through the publications view need one to render).
Override via kwargs.
formation date (from ``year``), conference venue, a forum name, and a
dummy PDF (display_pub_snippet.html unconditionally renders
pub.pdf_file.url, so tests that go through the publications view need
one to render). Override via kwargs.
"""
from datetime import date as _date
from website.models import Publication
from website.models.publication import PubType
kwargs.setdefault("date", _date(year, 1, 1))
kwargs.setdefault("forum_name", "CHI")
kwargs.setdefault("pub_venue_type", PubType.CONFERENCE)
kwargs.setdefault(
"pdf_file",
SimpleUploadedFile(
f"{title.replace(' ', '_')}.pdf",
b"%PDF-1.4 test",
content_type="application/pdf",
),
)
return Publication.objects.create(title=title, **kwargs)
return PublicationFactory(title=title, **kwargs)

def make_talk(self, title="A Test Talk", year=2024, **kwargs):
"""
Create and return a Talk. Artifact.save() generates a thumbnail
from pdf_file (via ImageMagick) on every save, so we provide a
small valid PDF and let it run; tests that don't care about the
from pdf_file (via ImageMagick) on every save, so the factory provides
a small valid PDF and lets it run; tests that don't care about the
thumbnail just ignore it.
"""
from datetime import date as _date
from website.models import Talk
from website.models.talk import TalkType
kwargs.setdefault("date", _date(year, 1, 1))
kwargs.setdefault("forum_name", "CHI")
kwargs.setdefault("talk_type", TalkType.CONFERENCE_TALK)
kwargs.setdefault(
"pdf_file",
SimpleUploadedFile(
f"{title.replace(' ', '_')}.pdf",
b"%PDF-1.4 test",
content_type="application/pdf",
),
)
return Talk.objects.create(title=title, **kwargs)
return TalkFactory(title=title, **kwargs)

def make_video(self, title="A Test Video", year=2024, **kwargs):
"""
Expand All @@ -117,23 +83,18 @@ def make_video(self, title="A Test Video", year=2024, **kwargs):
would raise), and the video snippet embeds it. date is set so
get_most_recent_artifact_date() has something to sort on.
"""
from datetime import date as _date
from website.models import Video
kwargs.setdefault("date", _date(year, 1, 1))
kwargs.setdefault("video_url", "https://www.youtube.com/watch?v=dQw4w9WgXcQ")
return Video.objects.create(title=title, **kwargs)
return VideoFactory(title=title, **kwargs)

def make_news_item(self, title="Test News", author=None, **kwargs):
"""
Create and return a News item. `author` is intentionally optional
(the FK is nullable with on_delete=SET_NULL) so tests can exercise
the authorless code path that caused the original /news/158/ bug.
"""
from datetime import date as _date
from website.models import News
kwargs.setdefault("date", _date(2024, 1, 1))
kwargs.setdefault("content", "Test news body.")
return News.objects.create(title=title, author=author, **kwargs)
return NewsItemFactory(title=title, author=author, **kwargs)

def make_project(self, name="A Test Project", short_name=None,
with_thumbnail=False, **kwargs):
Expand All @@ -149,12 +110,11 @@ def make_project(self, name="A Test Project", short_name=None,
visibility backfill. Defaults to False to avoid touching the
filesystem unnecessarily.
"""
from website.models import Project
if short_name is None:
short_name = name.lower().replace(" ", "")
if with_thumbnail:
kwargs.setdefault(
"gallery_image",
_make_image_upload(f"{short_name}_thumb.gif"),
image_upload(f"{short_name}_thumb.gif"),
)
return Project.objects.create(name=name, short_name=short_name, **kwargs)
return ProjectFactory(name=name, short_name=short_name, **kwargs)
Loading
Loading