Testing UUIDs: The IdGenerator Pattern for Deterministic Tests
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("-") == 4This 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"""
passNow 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_idUsage 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_valueThis 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 IDA 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!