Skip to content

Commit 4d357de

Browse files
authored
Merge pull request #1319 from makeabilitylab/1272-factory-boy-fixtures
test(fixtures): factory_boy factories + delegate make_* helpers (#1272)
2 parents cb4c6ec + 754818f commit 4d357de

6 files changed

Lines changed: 389 additions & 82 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ The suite has two complementary styles:
264264
| **Unit** | `SimpleTestCase` + `MagicMock` | Pure logic — formatters, BibTeX generation, single-method behavior. No DB; runs in milliseconds. |
265265
| **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. |
266266
267-
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.
267+
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.
268268
269269
### When to add a test
270270

requirements.txt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,4 +123,19 @@ django-ckeditor==6.7.3
123123
pyOpenSSL==26.0.0
124124

125125
# Requests - HTTP library for Python
126-
requests==2.33.0
126+
requests==2.33.0
127+
128+
# -----------------------------------------------------------------------------
129+
# Testing
130+
# -----------------------------------------------------------------------------
131+
# factory_boy - model fixtures for the test suite (#1272). The de-facto Django
132+
# standard; we use explicit factories (website/tests/factories.py) over
133+
# model_bakery because they're easier to debug with our M2M / SortedManyToMany /
134+
# ProjectRole-through relationship graph. Pulls in Faker (pinned below) as a dep.
135+
# See: https://factoryboy.readthedocs.io/
136+
factory_boy==3.3.3
137+
138+
# Faker - realistic fake data (names, sentences, dates) used by the factories.
139+
# Dependency of factory_boy; pinned explicitly so test data stays reproducible.
140+
# See: https://faker.readthedocs.io/
141+
Faker==40.23.0

website/tests/base.py

Lines changed: 39 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,103 +12,69 @@
1212
coverage incrementally. See #1267 for the broader plan.
1313
"""
1414

15-
from django.core.files.uploadedfile import SimpleUploadedFile
16-
from django.test import TestCase
15+
from datetime import date as _date
1716

17+
from django.test import TestCase
1818

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

2929

30-
def _make_image_upload(name):
31-
"""Return a SimpleUploadedFile that satisfies an ImageField."""
32-
return SimpleUploadedFile(name, _GIF_1PX, content_type="image/gif")
33-
34-
3530
class DatabaseTestCase(TestCase):
3631
"""
3732
Shared base for tests that touch the database. Provides small fixture
38-
helpers (make_person / make_publication / make_news_item) built on
39-
plain Model.objects.create() — no third-party fixture library. Each
40-
test runs inside a transaction and is rolled back, so tests stay
41-
isolated without manual cleanup.
42-
43-
Why a base class instead of module-level helpers: subclasses can
44-
override the defaults in setUp() and the helpers can grow without
45-
cluttering the module namespace.
33+
helpers (make_person / make_publication / make_news_item / ...) that
34+
delegate to the factory_boy factories in :mod:`website.tests.factories`
35+
(#1272). The factories are the single source of truth for building
36+
instances; these helpers preserve the original keyword API (notably the
37+
``year`` shorthand) so the existing suite keeps working unchanged. Each
38+
test runs inside a transaction and is rolled back, so tests stay isolated
39+
without manual cleanup.
40+
41+
Why keep the helpers at all: they encode test-friendly defaults (fixed
42+
dates via ``year``, ``with_thumbnail`` for the project visibility backfill)
43+
and give subclasses a stable seam to override in ``setUp()``. Tests that
44+
want richer fixtures (Faker values, batches, the relationship graph) can
45+
import and use the factories directly.
4646
"""
4747

4848
def make_person(self, first_name="Jane", last_name="Doe", **kwargs):
4949
"""
50-
Create and return a Person. Image fields are pre-populated to
51-
skip Person.save()'s Star Wars fallback (which reads a real file
52-
from media/). Override by passing image=... explicitly.
50+
Create and return a Person. Image fields are pre-populated by
51+
PersonFactory to skip Person.save()'s Star Wars fallback (which reads a
52+
real file from media/). Override by passing image=... explicitly.
5353
"""
54-
from website.models import Person
55-
kwargs.setdefault(
56-
"image", _make_image_upload(f"{first_name}_{last_name}.gif")
57-
)
58-
kwargs.setdefault(
59-
"easter_egg",
60-
_make_image_upload(f"{first_name}_{last_name}_egg.gif"),
61-
)
62-
return Person.objects.create(
54+
return PersonFactory(
6355
first_name=first_name, last_name=last_name, **kwargs
6456
)
6557

6658
def make_publication(self, title="A Test Paper", year=2024, **kwargs):
6759
"""
6860
Create and return a Publication with sensible defaults: post-lab-
69-
formation date, conference venue, a forum name, and a dummy PDF
70-
(display_pub_snippet.html unconditionally renders pub.pdf_file.url,
71-
so tests that go through the publications view need one to render).
72-
Override via kwargs.
61+
formation date (from ``year``), conference venue, a forum name, and a
62+
dummy PDF (display_pub_snippet.html unconditionally renders
63+
pub.pdf_file.url, so tests that go through the publications view need
64+
one to render). Override via kwargs.
7365
"""
74-
from datetime import date as _date
75-
from website.models import Publication
76-
from website.models.publication import PubType
7766
kwargs.setdefault("date", _date(year, 1, 1))
78-
kwargs.setdefault("forum_name", "CHI")
79-
kwargs.setdefault("pub_venue_type", PubType.CONFERENCE)
80-
kwargs.setdefault(
81-
"pdf_file",
82-
SimpleUploadedFile(
83-
f"{title.replace(' ', '_')}.pdf",
84-
b"%PDF-1.4 test",
85-
content_type="application/pdf",
86-
),
87-
)
88-
return Publication.objects.create(title=title, **kwargs)
67+
return PublicationFactory(title=title, **kwargs)
8968

9069
def make_talk(self, title="A Test Talk", year=2024, **kwargs):
9170
"""
9271
Create and return a Talk. Artifact.save() generates a thumbnail
93-
from pdf_file (via ImageMagick) on every save, so we provide a
94-
small valid PDF and let it run; tests that don't care about the
72+
from pdf_file (via ImageMagick) on every save, so the factory provides
73+
a small valid PDF and lets it run; tests that don't care about the
9574
thumbnail just ignore it.
9675
"""
97-
from datetime import date as _date
98-
from website.models import Talk
99-
from website.models.talk import TalkType
10076
kwargs.setdefault("date", _date(year, 1, 1))
101-
kwargs.setdefault("forum_name", "CHI")
102-
kwargs.setdefault("talk_type", TalkType.CONFERENCE_TALK)
103-
kwargs.setdefault(
104-
"pdf_file",
105-
SimpleUploadedFile(
106-
f"{title.replace(' ', '_')}.pdf",
107-
b"%PDF-1.4 test",
108-
content_type="application/pdf",
109-
),
110-
)
111-
return Talk.objects.create(title=title, **kwargs)
77+
return TalkFactory(title=title, **kwargs)
11278

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

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

13899
def make_project(self, name="A Test Project", short_name=None,
139100
with_thumbnail=False, **kwargs):
@@ -149,12 +110,11 @@ def make_project(self, name="A Test Project", short_name=None,
149110
visibility backfill. Defaults to False to avoid touching the
150111
filesystem unnecessarily.
151112
"""
152-
from website.models import Project
153113
if short_name is None:
154114
short_name = name.lower().replace(" ", "")
155115
if with_thumbnail:
156116
kwargs.setdefault(
157117
"gallery_image",
158-
_make_image_upload(f"{short_name}_thumb.gif"),
118+
image_upload(f"{short_name}_thumb.gif"),
159119
)
160-
return Project.objects.create(name=name, short_name=short_name, **kwargs)
120+
return ProjectFactory(name=name, short_name=short_name, **kwargs)

0 commit comments

Comments
 (0)