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.

Maintenance cost: literals versus a factory Four maintenance events compared across two columns. Adding a required attribute costs an edit in every test that holds a literal, against a single edit to the factory definition. Changing the coordinate reference system means locating every hard-coded coordinate, against changing one parameter. Adding an edge-case variant means authoring a new literal from scratch, against declaring a trait on the existing factory. Reproducing a reported failure means copying coordinates out of a log, against re-running the factory with the seed that was recorded. A footer observes that the literal column scales with the number of tests while the factory column stays constant, so the two cross very early. WHEN THIS HAPPENS WITH LITERALS WITH A FACTORY A required attribute is added edit every test that has a literal one line in the factory The CRS changes find every hard-coded coordinate change one parameter An edge case is needed author a new literal by hand declare a trait A reported failure is reproduced copy coordinates out of a log re-run with the recorded seed The left column scales with the number of tests; the right column is constant. The two cross at about the fifth test, which is why factories feel like overkill on day one and like the only sane option by the end of the first month. The seed in the last row is the real prize: it turns “it failed on CI” into a command anyone can run.

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.

Bespoke subclasses versus orthogonal traits Two panels. The left panel shows four separate factory subclasses, each written for one scenario, with a note that a scenario needing two of the properties requires a fifth class and that the count grows without bound. The right panel shows one base factory with four orthogonal traits — invalid ring, anti-meridian, null attributes and coarse precision — and notes that any subset of the traits can be requested in a single expression, so four traits express sixteen scenarios with no additional classes. A footer gives the design rule: one trait changes exactly one property and its name says which. A subclass per scenario InvalidParcelFactory AntiMeridianFactory NullAttrFactory CoarsePrecisionFactory InvalidAntiMeridianF... a fifth class exists only to combine two count grows with scenarios, without bound One factory, orthogonal traits ParcelFactory invalid_ring=True anti_meridian=True null_attributes=True coarse_precision=True any subset in one expression ParcelFactory( invalid_ring=True, anti_meridian=True) 4 traits → 16 scenarios, 0 new classes Design rule: one trait changes exactly one property, and its name says which.

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.

build() versus create() for spatial fixtures Two paths from the same factory. The build path yields an in-memory object and exercises only the factory itself plus the application code under test, making it appropriate for predicate and tolerance assertions. The create path additionally passes through coercion to the column's declared SRID, any precision setting attached to the column, and a WKB serialisation and deserialisation on write and read, making it appropriate for testing the column definition, the SRID declaration and the driver. A warning states that assuming build semantics while using create is the origin of the familiar complaint that a geometry changed when it was saved. ParcelFactory seeded .build() in memory, nothing else runs exercises: the factory + your code use for predicate and tolerance assertions .create() persisted, then read back adds: SRID coercion · column precision · WKB round trip use for column definition and driver assertions Assuming build semantics while calling create is where “the geometry changed when we saved it” comes from — the change was the column doing its job.

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

  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.

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.