Building factory_boy Spatial Factories
factory_boy turns fixture creation from hand-written geometry literals into declarative factories that produce reproducible, parameterized spatial records on demand. This guide sits beneath synthetic vector data generation and shows how to build spatial factories: geometry attributes backed by seeded coordinate generators, CRS-aware traits, integration with GeoDjango or SQLAlchemy models, and sub-factories that emit the edge cases a suite must survive. The reason a spatial factory needs its own treatment is determinism — a factory that generates random coordinates without a fixed seed produces a different fixture every run, which is the opposite of what a regression test needs.
Why factories beat geometry literals
Hand-written fixtures — a Polygon([(0,0),(1,0),...]) pasted into each test — do not scale and do not vary: they are tedious to author, impossible to parameterize, and encourage copy-paste that drifts. A factory declares how to build a record once, then produces as many variants as a test needs, with only the fields under test overridden. For spatial data the payoff is sharper because geometry is verbose; a factory hides the coordinate generation behind a named trait like “valid parcel” or “self-intersecting” so tests read as intent, not coordinates.
What a factory replaces, and why it holds up better
A geometry literal pinned in a test file is a small liability that compounds. It encodes a CRS nobody wrote down, it cannot be varied without copy-paste, and when the schema gains a required field every literal in the suite has to be edited by hand. A factory turns each of those from an edit into a parameter.
Factory component reference
| Concern | factory_boy primitive | Spatial use |
|---|---|---|
| Reproducibility | factory.random.reseed_random |
Fixed seed → identical geometry |
| Derived field | LazyAttribute |
Build geometry from other fields |
| Variant | trait / Params |
“polar”, “anti_meridian”, “invalid” |
| Nested record | SubFactory |
Feature with related attributes |
| Bulk | create_batch |
A collection for a coverage test |
| Sequence | Sequence |
Unique ids across a batch |
Step-by-step implementation
The pattern targets factory_boy 3.x, Shapely 2.x and a seeded generator so fixtures are byte-reproducible.
Step 1 — Seed for determinism
import factory
# Call once in conftest.py so every run builds identical geometry.
def pytest_configure(config):
factory.random.reseed_random("geo-suite-seed")
Step 2 — A geometry factory with a LazyAttribute
Build the geometry from generated coordinates so the shape is derived, reproducible and parameterizable.
import factory
from shapely.geometry import Point, box
from shapely import to_wkt
class ParcelFactory(factory.Factory):
class Meta:
model = dict # or a GeoDjango / SQLAlchemy model
id = factory.Sequence(lambda n: n + 1)
srid = 25832
_x = factory.Faker("pyfloat", min_value=400000, max_value=600000)
_y = factory.Faker("pyfloat", min_value=5600000, max_value=5700000)
land_use = factory.Faker("random_element",
elements=["residential", "commercial", "agricultural"])
geom = factory.LazyAttribute(
lambda o: to_wkt(box(o._x, o._y, o._x + 50, o._y + 50))) # 50 m square
Step 3 — Traits for edge cases
Name the pathological variants as traits so a test asks for the defect it wants to exercise, echoing the edge-case catalogue in edge case spatial data creation.
class ParcelWithTraits(ParcelFactory):
class Params:
invalid = factory.Trait(
geom=factory.LazyAttribute(
lambda o: "POLYGON((0 0,1 1,1 0,0 1,0 0))")) # self-intersecting
empty = factory.Trait(geom="POLYGON EMPTY")
Step 4 — Build collections for coverage tests
parcels = ParcelFactory.create_batch(1000) # a reproducible coverage
invalid = ParcelWithTraits(invalid=True) # one known-bad record
Designing traits so the suite stays readable
Traits are where a spatial factory earns or loses its keep. A well-chosen trait names a defect class and can be composed with any other; a badly-chosen one names a specific test’s data and multiplies until nobody can tell the variants apart.
The rule that keeps them clean: a trait changes one property and says which. invalid_ring makes the exterior ring self-intersect and nothing else. anti_meridian places the geometry across ±180° and nothing else. null_attributes empties the optional fields and nothing else. Because each touches a disjoint property, they compose — an anti-meridian polygon with an invalid ring is one expression, not a new factory — and a reader of the test knows exactly what is unusual about the object without opening the factory.
Two constraints keep traits genuinely orthogonal. First, a trait must not change the CRS or the schema — those are properties of the factory, not of a variant, and a trait that alters them will interact with every other trait in ways nobody can predict. Second, a trait must remain deterministic: it draws from the same seeded generator as the base factory, so requesting the same combination twice yields the same object. A trait that reaches for the global random module reintroduces exactly the non-reproducibility the factory was adopted to remove.
Verification pattern
Prove reproducibility: two factory runs under the same seed must produce identical geometry, and a trait must produce the defect it names.
from shapely import from_wkt, is_valid
def test_factory_is_reproducible():
factory.random.reseed_random("geo-suite-seed")
a = ParcelFactory()["geom"]
factory.random.reseed_random("geo-suite-seed")
b = ParcelFactory()["geom"]
assert a == b # identical under the same seed
def test_invalid_trait_is_invalid():
assert not is_valid(from_wkt(ParcelWithTraits(invalid=True)["geom"]))
Build, create, and the trap in between
factory_boy distinguishes building an object from persisting one, and for spatial work the distinction carries more weight than it does for ordinary models — because persistence is where the database’s own coercion rules apply.
An object built in memory has whatever geometry the factory produced. The same object persisted to PostGIS has passed through SRID coercion, may have been snapped by a precision setting on the column, and will come back from a query as a different Python object with its own WKB round-trip behind it. Tests that build are testing your factory and your code; tests that create are additionally testing the column definition, the SRID declaration, and the driver.
The practical guidance follows directly: default to build and reach for create deliberately, when the database’s behaviour is part of what you are asserting. Defaulting the other way makes every unit test require a database, which is the single fastest way to turn a two-second suite into a two-minute one for no additional coverage.
Failure modes and edge cases
- Unseeded randomness. Without
reseed_random, every run builds different geometry and regression baselines never match; seed inconftest.py. - Faker locale drift. A Faker provider whose output depends on locale can vary across machines; pin the locale or use numeric providers for coordinates.
- Geometry as a plain string. Storing WKT without a CRS field lets a fixture be reused in the wrong SRID; carry
sridon the factory. - Sequence collisions across batches. Reusing a factory across test modules without resetting the sequence can duplicate ids; scope the sequence or reset per test.
- Traits that overlap. Requesting
invalid=True, empty=Truetogether yields an ambiguous geometry; make edge-case traits mutually exclusive or document precedence.
Recording the seed where a failure can find it
A seeded factory only pays off if the seed survives the failure. The seed has to appear somewhere an engineer reading a CI log will see it, and the most reliable place is the test’s own output rather than a fixture’s internals.
Emit it from a session-scoped fixture at the start of the run and include it in the failure message of any assertion that consumed generated geometry. A line reading seed=20260810 factory=ParcelFactory traits=[anti_meridian] turns an intermittent failure into a command anyone can paste. Without it, the standard recovery is to re-run and hope, which works until the failure is one in fifty and then costs an afternoon.
There is a second habit that pairs with it: when a generated case does find a real defect, promote that exact combination into a named test with the seed pinned as a literal. The random exploration found it; the pinned case is what stops it coming back. Leaving it to chance means the regression is only caught on the runs where the generator happens to produce that shape again, which is not a regression test at all.
Conclusion
A factory_boy spatial factory replaces brittle geometry literals with seeded, declarative, trait-driven fixtures that are reproducible across runs and expressive about intent. With a fixed seed, derived geometry via LazyAttribute, and named edge-case traits, a suite gets exactly the spatial records it needs without pasting coordinates. For the broader generation context, return to synthetic vector data generation.
One final note on scope: a factory belongs to the test suite, not to the application. Importing production model classes into it is tempting and creates a dependency that makes the application harder to change; declaring the shape the tests need, and asserting separately that it matches the production schema, keeps the two able to move independently.
Where a factory and a production serializer must agree on a schema, assert that agreement in one test rather than duplicating the field list in both.