Testing UUIDs: The IdGenerator Pattern for Deterministic Tests

Testing UUIDs: The IdGenerator Pattern for Deterministic Tests

December 6, 2025

One of the core principles of good testing is determinism: tests should produce the same results every time they run. But what happens when your code generates UUIDs? Each call to uuid.uuid4() returns a different value, making it impossible to write assertions about the exact IDs created. This non-determinism makes tests fragile and harder to debug.

Consider a common scenario: you’re building a system that creates user accounts with unique IDs. How do you test that the user was created without knowing the correct ID? How do you verify relationships between entities when their IDs are randomly generated?

The Problem with Direct UUID Generation

Let’s look at a simple user creation function:

import uuid
from dataclasses import dataclass


@dataclass
class User:
    id: str
    name: str
    email: str


def create_user(name: str, email: str) -> User:
    return User(
        id=str(uuid.uuid4()),
        name=name,
        email=email
    )

How do we test this? We can’t assert on the exact ID:

def test_create_user():
    user = create_user("Alice", "alice@example.com")

    # This fails - we don't know what ID was generated!
    assert user.id == "???"

    # We can only check it's a valid UUID format
    assert len(user.id) == 36
    assert user.id.count("-") == 4

This test is weak. We’re only checking the format, not the actual behavior. What if we want to test that a user’s posts are linked to their ID? What if we need to verify database queries with specific IDs?

The IdGenerator Pattern

The solution is to abstract ID generation behind an interface. This gives us complete control over IDs in our tests while maintaining random generation in production.

from typing import Protocol


class IdGenerator(Protocol):
    def generate_id(self) -> str:
        """Generate a unique identifier"""
        pass

Now let’s create our production implementation:

class UUIDGenerator(IdGenerator):
    def generate_id(self) -> str:
        return str(uuid.uuid4())


UUID_GENERATOR = UUIDGenerator()

Let’s update our function to accept an IdGenerator:

def create_user(name: str, email: str, id_gen: IdGenerator = UUID_GENERATOR) -> User:
    return User(
        id=id_gen.generate_id(),
        name=name,
        email=email
    )

Test Implementations

Now we can create test implementations that give us full control:

FixedIdGenerator - For Predictable IDs

When you need to assert on specific IDs:

class FixedIdGenerator(IdGenerator):
    def __init__(self, fixed_id: str):
        self.fixed_id = fixed_id

    def generate_id(self) -> str:
        return self.fixed_id

Usage in tests:

def test_create_user_with_fixed_id():
    id_gen = FixedIdGenerator("user-123")
    user = create_user("Alice", "alice@example.com", id_gen)

    # Now we can assert on the exact ID!
    assert user.id == "user-123"
    assert user.name == "Alice"
    assert user.email == "alice@example.com"

IncrementingIdGenerator - For Unique IDs in Tests

When you need multiple unique IDs within a test scope:

class IncrementingIdGenerator(IdGenerator):
    def __init__(self, start: int = 0):
        self.counter = start

    def generate_id(self) -> str:
        id_value = str(self.counter)
        self.counter += 1
        return id_value

This is particularly useful when testing relationships between entities:

@dataclass
class Post:
    id: str
    user_id: str
    title: str
    content: str


def create_post(user_id: str, title: str, content: str, id_gen: IdGenerator = UUID_GENERATOR) -> Post:
    return Post(
        id=id_gen.generate_id(),
        user_id=user_id,
        title=title,
        content=content
    )


def test_user_posts_relationship():
    id_gen = IncrementingIdGenerator()

    # Create users with predictable IDs
    alice = create_user("Alice", "alice@example.com", id_gen)
    bob = create_user("Bob", "bob@example.com", id_gen)

    assert alice.id == "0"
    assert bob.id == "1"

    # Create posts linked to users
    post1 = create_post(alice.id, "Alice's First Post", "Hello world", id_gen)
    post2 = create_post(alice.id, "Alice's Second Post", "Testing IDs", id_gen)
    post3 = create_post(bob.id, "Bob's Post", "Hey there", id_gen)

    assert post1.id == "2"
    assert post1.user_id == "0"  # Alice's ID

    assert post2.id == "3"
    assert post2.user_id == "0"  # Alice's ID

    assert post3.id == "4"
    assert post3.user_id == "1"  # Bob's ID

A More Complex Example

Let’s look at a service that creates multiple related entities:

@dataclass
class Order:
    id: str
    user_id: str
    items: list[str]


class OrderService:
    def __init__(self, id_gen: IdGenerator = UUID_GENERATOR):
        self.id_gen = id_gen

    def create_order(self, user_id: str, items: list[str]) -> Order:
        return Order(
            id=self.id_gen.generate_id(),
            user_id=user_id,
            items=items
        )

    def create_bulk_orders(self, user_id: str, item_batches: list[list[str]]) -> list[Order]:
        return [
            self.create_order(user_id, items)
            for items in item_batches
        ]

Testing with the IncrementingIdGenerator:

def test_bulk_order_creation():
    id_gen = IncrementingIdGenerator(start=1000)
    service = OrderService(id_gen)

    orders = service.create_bulk_orders(
        user_id="user-123",
        item_batches=[
            ["item-a", "item-b"],
            ["item-c"],
            ["item-d", "item-e", "item-f"]
        ]
    )

    assert len(orders) == 3

    # We can assert on exact IDs because they're predictable
    assert orders[0].id == "1000"
    assert orders[0].user_id == "user-123"
    assert orders[0].items == ["item-a", "item-b"]

    assert orders[1].id == "1001"
    assert orders[1].items == ["item-c"]

    assert orders[2].id == "1002"
    assert orders[2].items == ["item-d", "item-e", "item-f"]

Why Not Just Mock uuid.uuid4()?

You might wonder: “Can’t I just mock uuid.uuid4() in my tests?” While technically possible, there are several compelling reasons to use the IdGenerator pattern instead:

Explicitness

When a function or class accepts an id_gen parameter, it’s immediately clear that it generates IDs. This is self-documenting code. Mocking uuid.uuid4() globally hides this dependency.

Type Safety

Type checkers understand the IdGenerator protocol perfectly. Mocking often requires disabling type checking with # type: ignore comments.

Test Isolation

Mocking uuid.uuid4() affects global state. If one test doesn’t clean up properly, it can cause mysterious failures in other tests. Each test using its own IdGenerator instance is completely isolated.

Control and Flexibility

The IncrementingIdGenerator gives you sequential IDs that are easy to reason about. The FixedIdGenerator lets you test specific ID values. Try doing that cleanly with a mock!

Works Everywhere

You can use these test implementations in development environments to debug ID-related issues. You can’t do that with test mocks.

Conclusion

The IdGenerator pattern is a simple but powerful technique for making UUID-dependent code testable. By abstracting ID generation behind a protocol, you gain:

  • Deterministic tests - no more random UUIDs making assertions difficult
  • Better test readability - sequential or fixed IDs are easy to understand
  • Explicit dependencies - functions that generate IDs declare it clearly
  • Type safety - no mocking tricks that break type checking
  • Test isolation - each test controls its own ID generation

This pattern, like the Clock pattern for time-dependent code, is language-agnostic and can be applied wherever you need to test code that generates unique identifiers.

I’d love to hear how you handle testing UUIDs in your codebase - reach out on Slack or email to share your approach!