{"title":"Build Deterministic Python API Test Data Factories","excerpt":"A duplicate-key violation blamed on parallelism, an assertion broken by a Faker patch bump, and a 41-line payload diff with two relevant fields all come from the same cause: generating data without deciding what is fixed, what varies, and what the test asserts. This guide covers the reseed-to-zero behaviour of the faker fixture, why generated values must never appear as expected outputs, deriving reproducible unique identity from the test node id with uuid5, and the jsonschema format checker that does nothing unless you switch it on.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["python","test-data","faker","pytest","api-testing","json-schema"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-09-23T13:30:00.000Z","dateModified":"2026-09-22T18:48:26.721Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/python-api-test-data-factories-deterministic.webp","alt":"Dark technical illustration. Along the top, an orange line enters from the left edge of the frame and passes through four orange bars of steadily decreasing length, ending at a solid orange dot; a short orange connector then drops from it toward the top of a large green card below. The card has a solid outer outline and a dashed inner one, and holds eight horizontal rows. The first row is an orange pattern of four slots, three filled and one nearly empty. Of the remaining seven rows, two are bright green bars spanning the card's full inner width with a small green dot beside each, and five are shorter mid-grey bars about half that width. To the right stand three narrower cards built the same way, each holding a green four-slot pattern at the top above five short grey bars; the filled slots differ in every one of the three patterns and differ again from the orange pattern in the large card. Across the bottom of the frame, running off both edges, is a row of eleven rounded boxes on a horizontal rule: ten contain a green tick, and the seventh from the left is orange and contains a cross instead."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> Faker's pytest fixture is reseeded to <code class=\"language-text\">0</code> before every test, so \"random\" test data is already deterministic. It's also identical across tests, which is why unique constraints break. Treat generated values as inputs and never as expected outputs, because Faker states its output is not stable across patch versions. Derive identity with <code class=\"language-text\">uuid5</code> from the test's own name rather than drawing it, and have the factory validate its own payload with <code class=\"language-text\">iter_errors</code> and a format checker explicitly enabled.</div>\n\n<p>Three failures, all from the same cause and all attributed to something else:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">psycopg.errors.UniqueViolation: duplicate key value violates unique\nconstraint \"customers_email_key\"\n\nAssertionError: assert 'Margaret Boehm' == 'Danielle Hartman'\n\nFAILED test_order_totals — 41-line payload diff, 2 fields relevant</code></pre>\n\n<p>The first gets blamed on parallelism, the second on a flaky dependency, the third on nothing at all because nobody reads a 41-line diff twice. All three come from generating data without deciding what about it is fixed, what varies, and what the test is actually asserting.</p>\n\n<h2 id=\"the-faker-fixture-hands-every-test-the-same-data\">The faker fixture hands every test the same data</h2>\n\n<p>Start here, because it inverts what most people assume. Faker's pytest fixture is documented as returning \"a session-scoped Faker instance to be used across all tests in your test suite. This instance defaults to the <code class=\"language-text\">en-US</code> locale, it is reseeded using a seed value of <code class=\"language-text\">0</code> prior to each test, and the <code class=\"language-text\">.unique</code> remembered generated values are cleared.\"</p>\n\n<p>Read that twice. The reseed happens <em>before each test</em>, which means the first <code class=\"language-text\">faker.email()</code> in every test returns the same string. Determinism is already the default; what you do not have is uniqueness across tests.</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># Both tests get the same email, because both start from seed 0.\n# Run them against a shared database and the second one violates the\n# unique index — with an error that says nothing about Faker.\ndef test_customer_can_register(db, faker):\n    register(db, email=faker.email())\n\ndef test_customer_can_be_invited(db, faker):\n    invite(db, email=faker.email())</code></pre>\n\n<p>The two knobs are session-scoped autouse fixtures. <code class=\"language-text\">faker_seed</code> changes the seed and <code class=\"language-text\">faker_session_locale</code> changes the locale. The docs show both, and the locale fixture takes a list so multiple-locale mode is a matter of returning more than one:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># conftest.py\n@pytest.fixture(scope=\"session\", autouse=True)\ndef faker_seed():\n    return 12345\n\n@pytest.fixture(scope=\"session\", autouse=True)\ndef faker_session_locale():\n    return [\"it_IT\", \"ja_JP\", \"en_US\"]</code></pre>\n\n<p>Changing the seed does not fix the collision, though. It only moves which identical value every test receives. The collision is structural, and the fix is in the next two sections.</p>\n\n<p>One more distinction to have straight, because it decides whether another test can perturb your factory. <code class=\"language-text\">Faker.seed()</code> is a class method that \"seeds the shared random number generator\", and calling it on an instance is an error: Faker raises <code class=\"language-text\">TypeError: Calling `.seed()` on instances is deprecated. Use the class method `Faker.seed()` instead.</code> The project explains the change as dealing with \"a non-explicit legacy behavior involving a shared <code class=\"language-text\">random.Random</code> instance.\" The per-instance alternative is <code class=\"language-text\">seed_instance()</code>, which switches a generator \"to use its own instance of <code class=\"language-text\">random.Random</code>, separated from the shared one\".</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">from faker import Faker\n\n# Shared: any other test or library calling Faker.seed() moves this.\nFaker.seed(4321)\n\n# Isolated: this generator has its own random.Random and cannot be\n# perturbed by anything else in the process. This is what a factory wants.\nfake = Faker()\nfake.seed_instance(4321)</code></pre>\n\n<h2 id=\"generated-values-are-inputs-never-expected-outputs\">Generated values are inputs, never expected outputs</h2>\n\n<p>The second failure in the opening, <code class=\"language-text\">assert 'Margaret Boehm' == 'Danielle Hartman'</code>, is a test that recorded a seeded value as its expectation. Faker is unambiguous that this is not supported: \"as we keep updating datasets, results are not guaranteed to be consistent across patch versions. If you hardcode results in your test, make sure you pinned the version of Faker down to the patch number.\"</p>\n\n<p>A seed reproduces a run, given the same version. It is not a stable contract, and pinning Faker to a patch release to protect a hard-coded name is trading a real dependency constraint for a test that was asserting the wrong thing anyway.</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># Wrong. The expectation is a fact about Faker's dataset, not about the\n# system under test. A patch bump breaks it, and fixing it teaches nobody\n# anything.\ndef test_customer_name_is_stored(db, faker):\n    Faker.seed(4321)\n    create_customer(db, name=faker.name())\n    assert fetch_customer(db).name == \"Margaret Boehm\"\n\n\n# Right. The assertion is a relationship — what went in comes back out —\n# so it holds for any name the factory produces.\ndef test_customer_name_is_stored(db, faker):\n    name = faker.name()\n    create_customer(db, name=name)\n    assert fetch_customer(db).name == name</code></pre>\n\n<p>The rule generalises past Faker: any value your test did not choose deliberately must not appear on the right-hand side of an assertion. If a test needs a specific value (a name with a combining character, an email at exactly the length limit), that value is part of the test, so write it in the test.</p>\n\n<h2 id=\"derive-identity-rather-than-drawing-it\">Derive identity rather than drawing it</h2>\n\n<p>Uniqueness and reproducibility look like opposites and are not. You get both by computing identity from something already unique (the test's own name) instead of sampling it.</p>\n\n<p><code class=\"language-text\">uuid5</code> is the tool. Python documents it as generating \"a UUID based on the SHA-1 hash of a namespace identifier (which is a UUID) and a name (which is a bytes object or a string that will be encoded using UTF-8)\", per RFC 9562 §5.5. Same inputs give the same UUID; different inputs give a different one. No randomness, so nothing to seed and nothing to record.</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">import uuid\n\n# NAMESPACE_URL is one of the module's predefined namespaces, documented\n# for names that are URLs — so a synthetic URL keyed to the test reads\n# correctly and stays inside the intended use.\ndef derived_id(request, kind: str, index: int = 0) -&gt; uuid.UUID:\n    name = f\"https://tests.invalid/{request.node.nodeid}/{kind}/{index}\"\n    return uuid.uuid5(uuid.NAMESPACE_URL, name)\n\n\n@pytest.fixture\ndef order_factory(request, faker):\n    counter = itertools.count()\n\n    def make(**overrides):\n        index = next(counter)\n        oid = derived_id(request, \"order\", index)\n        base = {\n            \"id\": str(oid),\n            # The idempotency key is derived too, so a retry inside the\n            # test reuses it and a different test cannot collide with it.\n            \"idempotency_key\": str(derived_id(request, \"idem\", index)),\n            \"customer_email\": f\"c-{oid.hex[:12]}@tests.invalid\",\n            \"currency\": \"INR\",\n            \"items\": [{\"sku\": \"SKU-1\", \"qty\": 1, \"unit_price_minor\": 19900}],\n        }\n        return {**base, **overrides}\n\n    return make</code></pre>\n\n<p>Three properties come out of this for free. The email is unique across tests because the node id is. It is identical on every run of the same test, so a failure is reproducible without recording a seed. And it is stable under <code class=\"language-text\">pytest-xdist</code>, because the node id does not depend on which worker picked the test up.</p>\n\n<p>Faker's <code class=\"language-text\">.unique</code> proxy solves a narrower problem and it is worth knowing its limits before reaching for it. It \"guarantee[s] that any generated values are unique for this specific instance\", and <code class=\"language-text\">fake.unique.clear()</code> resets the memory, which the pytest fixture already does before every test, so its guarantee is per-test only. It also fails loudly when the pool is too small: \"to avoid infinite loops, after a number of attempts to find a unique value, Faker will throw a <code class=\"language-text\">UniquenessException</code>\", and the docs add the warning that matters at scale: \"beware of the birthday paradox, collisions are more likely than you'd think.\" <code class=\"language-text\">fake.unique.boolean()</code> raises on the third call, because there are only two booleans. Finally, \"only hashable arguments and return values can be used with <code class=\"language-text\">.unique</code>\".</p>\n\n<p>So: <code class=\"language-text\">.unique</code> for display fields that must differ within one test, derived UUIDs for anything the system treats as an identity.</p>\n\n<h2 id=\"one-valid-baseline-plus-overrides-that-must-be-real\">One valid baseline plus overrides that must be real</h2>\n\n<p>The third opening failure, the 41-line diff, is a factory that builds a maximal payload. Every field it sets is a field a reader has to rule out. The fix is a baseline that is the smallest payload the API accepts, and named scenarios on top of it.</p>\n\n<p>The pitfall that makes factories untrustworthy is silently swallowing overrides that do not exist. <code class=\"language-text\">{**base, **overrides}</code> above happily accepts <code class=\"language-text\">currancy=\"USD\"</code> and gives you a payload with both keys, so the test passes while testing nothing. Give the baseline a real type and the language raises for you:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">from dataclasses import dataclass, replace, asdict\n\n@dataclass(frozen=True, slots=True)\nclass Order:\n    id: str\n    idempotency_key: str\n    customer_email: str\n    currency: str = \"INR\"\n    items: tuple[dict, ...] = ()\n\ndef make(request, **overrides) -&gt; Order:\n    base = Order(**baseline_fields(request))\n    # replace() raises TypeError on a field Order does not declare, so a\n    # misspelled override fails at the call site instead of vanishing.\n    return replace(base, **overrides)</code></pre>\n\n<p>Then express scenarios as functions rather than as flags, because a flag tells the reader nothing about what it changes:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\"># Each name states the rule the test is about. The override list is the\n# diff a reviewer needs to read, and it is two lines rather than forty.\ndef order_over_vat_threshold(request):\n    return make(request, currency=\"EUR\",\n                items=({\"sku\": \"S\", \"qty\": 1, \"unit_price_minor\": 250_00},))\n\ndef order_with_exempt_plan(request):\n    return make(request, items=({\"sku\": \"EDU-1\", \"qty\": 1,\n                                 \"unit_price_minor\": 100_00},))</code></pre>\n\n<p>The cost is a longer factory module and a name to invent per scenario. What you get back is that a failing test's payload is legible without a debugger, and that adding a required field to the API breaks the baseline once rather than in every test.</p>\n\n<h2 id=\"make-the-factory-check-its-own-output\">Make the factory check its own output</h2>\n\n<p>A factory drifts. The API adds a required field, tightens a pattern, or narrows an enum, and the factory keeps producing payloads that were valid last quarter. Validating inside the factory turns that into one failure with a clear message instead of a hundred confusing ones.</p>\n\n<p>Two details from <code class=\"language-text\">jsonschema</code> make the difference between a real check and a decorative one.</p>\n\n<p>First, use <code class=\"language-text\">iter_errors</code> rather than <code class=\"language-text\">validate</code>. <code class=\"language-text\">validate(instance)</code> \"raises <code class=\"language-text\">jsonschema.exceptions.ValidationError</code> if the instance is invalid.\" So it reports the first problem and stops. <code class=\"language-text\">iter_errors(instance)</code> will \"lazily yield each of the validation errors in the given instance\", which is what you want when a stale factory has three fields wrong:</p>\n\n<pre class=\"language-python\"><code class=\"language-python\">from jsonschema.validators import Draft202012Validator\n\ndef validated(payload: dict, schema: dict) -&gt; dict:\n    validator = Draft202012Validator(\n        schema,\n        # Without this, \"format\" is not checked at all.\n        format_checker=Draft202012Validator.FORMAT_CHECKER,\n    )\n    errors = sorted(validator.iter_errors(payload), key=str)\n    if errors:\n        raise AssertionError(\n            \"factory produced an invalid payload:\\n\"\n            + \"\\n\".join(f\"  {e.json_path}: {e.message}\" for e in errors)\n        )\n    return payload</code></pre>\n\n<p>Second, and this is the one that quietly hollows out schema checks: the <code class=\"language-text\">format</code> keyword does nothing by default. The docs are explicit: \"by default, as per the specification, no validation is enforced\", and \"optionally however, validation can be enabled by hooking a format-checking object into a Validator.\" So a schema declaring <code class=\"language-text\">{\"format\": \"email\"}</code> accepts <code class=\"language-text\">\"not-an-email\"</code> unless you pass <code class=\"language-text\">format_checker</code>. Some checks also need extras installed, via <code class=\"language-text\">pip install jsonschema[format]</code> or the GPL-free <code class=\"language-text\">jsonschema[format-nongpl]</code>.</p>\n\n<p>A factory that generates emails and validates them against an email format with the checker disabled is a factory testing nothing about its emails. That is worth a one-line assertion in your own test suite: feed the factory's schema a known-bad value and confirm the validator rejects it.</p>\n\n<h2 id=\"apply-this-now\">Apply this now</h2>\n\n<p>Grep the test tree for <code class=\"language-text\">faker.</code> and <code class=\"language-text\">fake.</code> outside your factory module. Every hit is generation logic living in a test, which is where it becomes invisible in a diff. Move them behind a factory call with named overrides.</p>\n\n<p>Then grep the assertions for any comparison whose right-hand side came from a generator. Those are the tests that will break on a Faker upgrade for reasons unrelated to your code. Rewrite each as an echo-back or a relationship.</p>\n\n<p>A failing test should carry three things in its own output: the factory scenario by name, the resolved payload as the factory produced it, and the validation result. Log those at the point the factory returns, not from the test, so every test gets them without asking. Once that is in place, the reproduction instruction for any failure is the node id: no seed to copy, because identity was derived rather than drawn.</p>\n\n<h2 id=\"questions-about-test-data-factories\">Questions about test data factories</h2>\n\n<h3 id=\"should-the-factory-write-to-the-database-or-return-a-payload\">Should the factory write to the database or return a payload?</h3>\n\n<p>Return the payload, and let a separate fixture persist it. A factory that inserts is doing two jobs, and the moment you need the same payload for an API call rather than an insert you have to duplicate it. Splitting them also keeps the factory usable in tests that assert on validation errors, where nothing should be persisted at all.</p>\n\n<h3 id=\"is-faker-worth-using-if-values-must-not-be-asserted-on\">Is Faker worth using if values must not be asserted on?</h3>\n\n<p>Yes, for two things: producing shapes you would not have thought to type, and making payloads look like production data so that a length or encoding assumption surfaces. What it is not for is identity or expected values. A reasonable division is Faker for the descriptive fields, derived UUIDs for anything with a uniqueness constraint, and hand-written literals for any value the assertion depends on.</p>\n\n<h3 id=\"how-do-i-keep-factories-in-step-with-a-changing-api\">How do I keep factories in step with a changing API?</h3>\n\n<p>Point the factory's validation at the same schema artefact the API publishes rather than a copy. If the schema lives in the service repository, vendor it as a build step so a stale copy fails loudly on the next pull. The failure mode you are designing against is not the API changing. It is the API changing while the factory's private copy of the schema does not.</p>\n\n<h3 id=\"do-derived-uuids-break-when-a-test-is-renamed\">Do derived UUIDs break when a test is renamed?</h3>\n\n<p>They change, which is the correct behaviour and worth understanding rather than working around. The identity is a function of the test's name, so renaming the test produces new data, which is what you want, because a renamed test is being re-run from a clean state. What must not happen is deriving from something that changes between runs of the <em>same</em> test, such as a timestamp or the worker id.</p>\n\n<h3 id=\"what-about-parametrized-tests-sharing-a-node-id-prefix\">What about parametrized tests sharing a node id prefix?</h3>\n\n<p>They do not collide, because a parametrized node id includes its case identifier in brackets, which is one more reason to give parametrized cases explicit IDs rather than letting them be generated. A derived UUID built from a node id ending in <code class=\"language-text\">[a0-b0]</code> is unique but tells you nothing; one ending in <code class=\"language-text\">[eu-above-threshold]</code> is unique and legible in a database row.</p>\n\n<h2 id=\"primary-references\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://faker.readthedocs.io/en/master/pytest-fixtures.html#pytest-fixtures\">Faker — Pytest Fixtures</a>. That the <code class=\"language-text\">faker</code> fixture returns a session-scoped instance defaulting to <code class=\"language-text\">en-US</code>, that \"it is reseeded using a seed value of <code class=\"language-text\">0</code> prior to each test\", and that \"the <code class=\"language-text\">.unique</code> remembered generated values are cleared\" at the same point. Also the session-scoped autouse <code class=\"language-text\">faker_seed</code> and <code class=\"language-text\">faker_session_locale</code> override fixtures, including the list form for multiple locales, and that the fixture is function-scoped and configurable despite the shared instance.</li>\n<li><a href=\"https://faker.readthedocs.io/en/master/index.html#seeding-the-generator\">Faker — Seeding the Generator</a>. That <code class=\"language-text\">Faker.seed()</code> \"seeds the shared random number generator\" and \"a Seed produces the same result when the same methods with the same version of faker are called\"; that <code class=\"language-text\">seed_instance()</code> switches a generator \"to use its own instance of <code class=\"language-text\">random.Random</code>, separated from the shared one\"; and the warning that \"as we keep updating datasets, results are not guaranteed to be consistent across patch versions\", so hard-coded results require pinning Faker to a patch release.</li>\n<li><a href=\"https://faker.readthedocs.io/en/master/index.html#unique-values\">Faker — Unique values</a>. That <code class=\"language-text\">.unique</code> guarantees uniqueness \"for this specific instance\"; that <code class=\"language-text\">fake.unique.clear()</code> clears the seen values; that Faker \"will throw a <code class=\"language-text\">UniquenessException</code>\" after a number of failed attempts, with the explicit \"beware of the birthday paradox\" caution and the <code class=\"language-text\">fake.unique.boolean()</code> example that raises on the third call; and that \"only hashable arguments and return values can be used with <code class=\"language-text\">.unique</code>\".</li>\n<li><a href=\"https://faker.readthedocs.io/en/master/fakerclass.html#upgrade-guide\">Faker — Faker class: breaking change and upgrade guide</a>. The <code class=\"language-text\">TypeError</code> quoted earlier in this article, raised when seeding is attempted through an instance rather than the class, together with the stated rationale of resolving \"a non-explicit legacy behavior involving a shared <code class=\"language-text\">random.Random</code> instance\".</li>\n<li><a href=\"https://docs.python.org/3/library/uuid.html#uuid.uuid5\">Python — uuid.uuid5</a>. That <code class=\"language-text\">uuid5</code> generates \"a UUID based on the SHA-1 hash of a namespace identifier (which is a UUID) and a name (which is a <code class=\"language-text\">bytes</code> object or a string that will be encoded using UTF-8) according to RFC 9562, §5.5.\" The property that makes derived identity both unique and reproducible without a recorded seed.</li>\n<li><a href=\"https://docs.python.org/3/library/uuid.html#uuid.NAMESPACE_URL\">Python — uuid.NAMESPACE_URL</a>. That the module defines namespace identifiers for use with <code class=\"language-text\">uuid3()</code> or <code class=\"language-text\">uuid5()</code>, and that with <code class=\"language-text\">NAMESPACE_URL</code> \"the name string is a URL\", which is why a synthetic URL keyed to the test node id is the right shape for the name argument.</li>\n<li><a href=\"https://python-jsonschema.readthedocs.io/en/stable/validate/#the-validator-protocol\">jsonschema — The Validator Protocol</a>. That <code class=\"language-text\">iter_errors(instance)</code> will \"lazily yield each of the validation errors in the given instance\", with the worked example producing two messages from one instance, while <code class=\"language-text\">validate(instance)</code> \"raises <code class=\"language-text\">jsonschema.exceptions.ValidationError</code> if the instance is invalid\" and therefore stops at the first problem.</li>\n<li><a href=\"https://python-jsonschema.readthedocs.io/en/stable/validate/#validating-formats\">jsonschema — Validating Formats</a>. That for the <code class=\"language-text\">format</code> keyword, \"by default, as per the specification, no validation is enforced\", and that it must be switched on \"by hooking a format-checking object into a Validator\" such as <code class=\"language-text\">Draft202012Validator.FORMAT_CHECKER</code>; plus that some formats need the <code class=\"language-text\">jsonschema[format]</code> or <code class=\"language-text\">jsonschema[format-nongpl]</code> extras installed.</li>\n<li><a href=\"https://docs.python.org/3/library/random.html#random.Random\">Python — random.Random</a>. The class behind both Faker seeding modes: an instantiable generator whose state is independent of the module-level shared instance, which is what <code class=\"language-text\">seed_instance()</code> switches a Faker generator over to.</li>\n</ul>\n"}