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.

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

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"]))

Failure modes and edge cases

  1. Unseeded randomness. Without reseed_random, every run builds different geometry and regression baselines never match; seed in conftest.py.
  2. Faker locale drift. A Faker provider whose output depends on locale can vary across machines; pin the locale or use numeric providers for coordinates.
  3. Geometry as a plain string. Storing WKT without a CRS field lets a fixture be reused in the wrong SRID; carry srid on the factory.
  4. Sequence collisions across batches. Reusing a factory across test modules without resetting the sequence can duplicate ids; scope the sequence or reset per test.
  5. Traits that overlap. Requesting invalid=True, empty=True together yields an ambiguous geometry; make edge-case traits mutually exclusive or document precedence.

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.