Pydantic Integration
factorio works excellently with Pydantic models, making it easy to generate test data for API testing, validation, and serialization scenarios.
Basic Setup
Define Your Models
from pydantic import BaseModel, Field, EmailStr
from datetime import datetime
from typing import Optional
class User(BaseModel):
id: Optional[int] = None
name: str = Field(..., min_length=1, max_length=100)
email: EmailStr
age: int = Field(..., ge=0, le=150)
created_at: datetime = Field(default_factory=datetime.utcnow)
bio: Optional[str] = None
class Post(BaseModel):
id: Optional[int] = None
title: str = Field(..., min_length=1, max_length=200)
content: str
author_id: int
published: bool = False
tags: list[str] = []
Create Factories
from factorio import fields
from factorio.factories import Factory
class UserFactory(Factory[User]):
name = fields.StringField(min_chars=3, max_chars=50)
email = fields.TextField("email")
age = fields.IntegerField(min_value=18, max_value=65)
bio = fields.TextField("paragraph", nb_sentences=2)
# Don't set id or created_at - let Pydantic handle defaults
class PostFactory(Factory[Post]):
title = fields.TextField("sentence", nb_words=5)
content = fields.TextField("paragraphs", nb=2)
author_id = fields.IntegerField(min_value=1, max_value=1000)
published = fields.BooleanField(truth_probability=70)
tags = fields.ListField(fields.TextField("word"), length=3)
Usage in Tests
Building Valid Models
def test_user_creation():
user = UserFactory.build()
# Pydantic validation happens automatically
assert isinstance(user, User)
assert user.name
assert "@" in user.email
assert 18 <= user.age <= 65
# Model methods work as expected
user_dict = user.model_dump()
assert "name" in user_dict
assert "email" in user_dict
Testing Validation
def test_invalid_age_rejected():
# Override with invalid age
try:
user = UserFactory.build(age=200)
# If we get here, validation didn't catch it
assert False, "Should have raised ValidationError"
except Exception as e:
# Pydantic validation error
assert "age" in str(e).lower()
JSON Serialization
import json
def test_user_serialization():
user = UserFactory.build()
# Serialize to JSON
user_json = user.model_dump_json()
parsed = json.loads(user_json)
assert parsed["name"] == user.name
assert parsed["email"] == user.email
assert isinstance(parsed["created_at"], str) # ISO format
Advanced Patterns
Nested Pydantic Models
from pydantic import BaseModel
from typing import List
class Address(BaseModel):
street: str
city: str
country: str
zipcode: str
class UserWithAddress(BaseModel):
name: str
email: EmailStr
address: Address
secondary_addresses: List[Address] = []
class AddressFactory(Factory[Address]):
street = fields.TextField("street_address")
city = fields.TextField("city")
country = fields.TextField("country")
zipcode = fields.TextField("postcode")
class UserWithAddressFactory(Factory[UserWithAddress]):
name = fields.TextField("name")
email = fields.TextField("email")
address = fields.FactoryField(AddressFactory)
secondary_addresses = fields.ListField(
fields.FactoryField(AddressFactory),
length=2
)
def test_nested_models():
user = UserWithAddressFactory.build()
assert isinstance(user.address, Address)
assert user.address.city
assert len(user.secondary_addresses) == 2
assert all(isinstance(addr, Address) for addr in user.secondary_addresses)
Optional Fields
class Profile(BaseModel):
username: str
website: Optional[str] = None
twitter: Optional[str] = None
class ProfileFactory(Factory[Profile]):
username = fields.TextField("user_name")
# Sometimes include optional fields
website = fields.TextField("url") # Will always generate
twitter = fields.ConstantField(None) # Always None
def test_optional_fields():
profile = ProfileFactory.build()
assert profile.username
assert profile.website # Has value
assert profile.twitter is None # Explicitly None
Using Field Defaults
class Settings(BaseModel):
theme: str = "light"
notifications: bool = True
language: str = "en"
class SettingsFactory(Factory[Settings]):
# Override some defaults, keep others
theme = fields.ChoiceField(["light", "dark", "system"])
# Don't define notifications or language - use model defaults
def test_partial_override():
settings = SettingsFactory.build()
assert settings.theme in ["light", "dark", "system"]
assert settings.notifications is True # Default
assert settings.language == "en" # Default
API Testing
FastAPI Integration
from fastapi.testclient import TestClient
from fastapi import FastAPI
app = FastAPI()
@app.post("/users")
def create_user(user: User):
return {"message": f"User {user.name} created", "id": 123}
client = TestClient(app)
def test_create_user_endpoint():
user = UserFactory.build()
response = client.post("/users", json=user.model_dump())
assert response.status_code == 200
assert "created" in response.json()["message"]
Testing Request Validation
def test_invalid_email_rejected():
# Build valid user first
user = UserFactory.build()
# Manually set invalid email
user.email = "not-an-email"
response = client.post("/users", json=user.model_dump())
# FastAPI/Pydantic should reject this
assert response.status_code == 422
assert "email" in str(response.json())
Complete Example
from pydantic import BaseModel, Field, EmailStr, validator
from factorio import fields
from factorio.factories import Factory
# Model with validation
class Product(BaseModel):
name: str = Field(..., min_length=1)
price: float = Field(..., gt=0)
category: str
in_stock: bool = True
tags: list[str] = []
@validator('tags')
def validate_tags(cls, v):
if len(v) > 10:
raise ValueError('Too many tags')
return v
# Factory
class ProductFactory(Factory[Product]):
name = fields.StringField(min_chars=3, max_chars=50)
price = fields.FloatField(min_value=0.01, max_value=999.99)
category = fields.ChoiceField(["electronics", "books", "clothing"])
in_stock = fields.BooleanField(truth_probability=80)
tags = fields.ListField(fields.TextField("word"), length=3, variation=2)
# Tests
def test_valid_product():
product = ProductFactory.build()
assert product.name
assert product.price > 0
assert product.category in ["electronics", "books", "clothing"]
assert len(product.tags) <= 10 # Validator constraint
def test_product_json():
product = ProductFactory.build()
# For API responses
json_data = product.model_dump()
assert "name" in json_data
assert "price" in json_data
# Exclude None values
compact = product.model_dump(exclude_none=True)
assert compact == json_data # No None values in this case
def test_bulk_products():
products = [ProductFactory.build() for _ in range(5)]
# All valid
assert all(p.price > 0 for p in products)
assert all(p.name for p in products)
# Convert to list for API
products_data = [p.model_dump() for p in products]
assert len(products_data) == 5
Best Practices
Use Realistic Data for API Tests
class APIUserFactory(Factory[User]):
# Generate realistic data that matches production patterns
name = fields.TextField("name") # Real names
email = fields.TextField("safe_email") # Safe emails
age = fields.IntegerField(min_value=18, max_value=80) # Realistic ages
bio = fields.TextField("paragraph", nb_sentences=3) # Realistic bios
def test_api_with_realistic_data(client: TestClient):
user = APIUserFactory.build()
response = client.post("/users", json=user.model_dump())
assert response.status_code == 201
# The realistic data helps catch edge cases
assert len(user.name) <= 100 # Field constraint
assert "@" in user.email # Email format
Test Edge Cases
def test_minimum_age():
user = UserFactory.build(age=0) # Edge case
assert user.age == 0
def test_maximum_age():
user = UserFactory.build(age=150) # Edge case
assert user.age == 150
def test_empty_bio():
user = UserFactory.build(bio="") # Empty string
assert user.bio == ""
Type Safety
# factorio's type hints work well with Pydantic
def process_user(user: User) -> str:
return f"{user.name} <{user.email}>"
def test_type_safety():
user = UserFactory.build()
# Type checker knows this is a User
result = process_user(user)
assert isinstance(result, str)
Common Pitfalls
⚠️ Don't Duplicate Validation
# ❌ Wrong - factory generates invalid data
class BadUserFactory(Factory[User]):
age = fields.IntegerField(min_value=-100, max_value=200) # Outside Pydantic constraints!
# ✅ Correct - respect model constraints
class GoodUserFactory(Factory[User]):
age = fields.IntegerField(min_value=0, max_value=150) # Matches Field(ge=0, le=150)
⚠️ Handle Required Fields
# ❌ Missing required field
class IncompleteUserFactory(Factory[User]):
email = fields.TextField("email")
# Forgot name!
# ✅ Include all required fields
class CompleteUserFactory(Factory[User]):
name = fields.TextField("name")
email = fields.TextField("email")
⚠️ Be Careful with Defaults
class Config(BaseModel):
timeout: int = 30
retries: int = 3
# ❌ Overriding when you want defaults
class ConfigFactory(Factory[Config]):
timeout = fields.IntegerField(min_value=1, max_value=100)
retries = fields.IntegerField(min_value=1, max_value=10)
# ✅ Let Pydantic handle defaults when appropriate
class SmartConfigFactory(Factory[Config]):
# Only override what you need to test
timeout = fields.IntegerField(min_value=1, max_value=100)
# retries uses model default (3)