The hardest part of adding tests to existing code — whether AI-generated or legacy — isn't technical. It's knowing where to start without getting overwhelmed.
The wrong approach is starting with unit tests for individual functions. The right approach is starting with integration tests that cover the paths that would hurt most if they broke.
The Starting Point: Critical Path Identification
Before writing a single test, list the things that must not break:
- Authentication — users can sign up, log in, log out, reset passwords
- The core action — whatever the app is for: placing an order, submitting a form, creating a record
- Payment flows — if there's billing, every payment path
- Data integrity — mutations that write to the database
These are your first tests. Everything else can wait.
Setup: pytest-django
Django's built-in test runner works, but pytest-django is better for existing codebases — it's faster, has better fixtures, and produces readable output.
pip install pytest pytest-django factory-boy
# pytest.ini (or pyproject.toml [tool.pytest.ini_options])
[pytest]
DJANGO_SETTINGS_MODULE = myapp.settings
python_files = tests.py test_*.py *_tests.py
# conftest.py — shared fixtures
import pytest
from django.test import Client
@pytest.fixture
def client():
return Client()
@pytest.fixture
def auth_client(db, django_user_model):
user = django_user_model.objects.create_user(
username="testuser",
email="[email protected]",
password="testpass123"
)
client = Client()
client.login(username="testuser", password="testpass123")
return client, user
Your First Test: Authentication Works
# tests/test_auth.py
import pytest
from django.urls import reverse
@pytest.mark.django_db
def test_user_can_register(client):
response = client.post(reverse("register"), {
"username": "newuser",
"email": "[email protected]",
"password1": "complex_password_123",
"password2": "complex_password_123",
})
assert response.status_code in [200, 302]
from django.contrib.auth import get_user_model
User = get_user_model()
assert User.objects.filter(username="newuser").exists()
@pytest.mark.django_db
def test_user_can_login(client, django_user_model):
django_user_model.objects.create_user(username="existing", password="pass123")
response = client.post(reverse("login"), {
"username": "existing",
"password": "pass123"
}, follow=True)
assert response.status_code == 200
@pytest.mark.django_db
def test_login_required_redirects_to_login(client):
response = client.get(reverse("dashboard"))
assert response.status_code == 302
assert "/login" in response["Location"]
Run them: pytest tests/test_auth.py -v
Factory Boy: Creating Test Data Without Fixtures
Fixtures (JSON/YAML) break when your models change. Factory Boy creates test data programmatically:
# tests/factories.py
import factory
from factory.django import DjangoModelFactory
from django.contrib.auth import get_user_model
from myapp.models import Order, Product
class UserFactory(DjangoModelFactory):
class Meta:
model = get_user_model()
username = factory.Sequence(lambda n: f"user{n}")
email = factory.LazyAttribute(lambda o: f"{o.username}@example.com")
password = factory.PostGenerationMethodCall("set_password", "password123")
class ProductFactory(DjangoModelFactory):
class Meta:
model = Product
name = factory.Sequence(lambda n: f"Product {n}")
price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
stock = 10
class OrderFactory(DjangoModelFactory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
product = factory.SubFactory(ProductFactory)
quantity = 1
# tests/test_orders.py
import pytest
from .factories import UserFactory, OrderFactory, ProductFactory
@pytest.mark.django_db
def test_order_reduces_stock():
product = ProductFactory(stock=5)
order = OrderFactory(product=product, quantity=2)
order.process() # whatever your processing logic is
product.refresh_from_db()
assert product.stock == 3
@pytest.mark.django_db
def test_order_belongs_to_user(auth_client):
client, user = auth_client
other_user_order = OrderFactory() # belongs to a different user
response = client.get(f"/orders/{other_user_order.id}/")
assert response.status_code in [403, 404] # not 200 — that's the bug
The last test is the most important one to write for AI-generated code — it catches IDOR (accessing another user's data), which AI almost always generates incorrectly.
Testing AI-Generated Views: What to Focus On
AI-generated views have predictable failure modes. Test these specifically:
Permission gates
@pytest.mark.django_db
def test_unauthenticated_cannot_access_dashboard(client):
response = client.get("/dashboard/")
assert response.status_code == 302 # redirect to login
@pytest.mark.django_db
def test_user_cannot_access_other_users_data(auth_client):
client, user = auth_client
other_order = OrderFactory() # created by a different user via SubFactory
response = client.get(f"/orders/{other_order.id}/")
assert response.status_code in [403, 404]
Form validation
@pytest.mark.django_db
def test_form_rejects_empty_required_fields(auth_client):
client, user = auth_client
response = client.post("/submit/", {"title": ""}) # required field empty
assert response.status_code == 200 # form re-renders, not redirects
assert "required" in response.content.decode().lower()
@pytest.mark.django_db
def test_form_rejects_negative_price(auth_client):
client, user = auth_client
response = client.post("/products/create/", {
"name": "Widget",
"price": "-10.00",
"stock": 5,
})
assert response.status_code == 200 # not 302, not processing it
API endpoints return correct status codes
@pytest.mark.django_db
def test_api_returns_404_for_nonexistent(auth_client):
client, user = auth_client
response = client.get("/api/orders/99999/")
assert response.status_code == 404
@pytest.mark.django_db
def test_api_post_requires_csrf_token(client):
response = client.post(
"/api/submit/",
data='{"title": "test"}',
content_type="application/json",
enforce_csrf_checks=True,
)
assert response.status_code == 403 # CSRF check should block this
Testing Celery Tasks
AI-generated async tasks often have bugs because they're never actually tested:
# tests/test_tasks.py
import pytest
from myapp.tasks import send_welcome_email
@pytest.mark.django_db
def test_send_welcome_email_task(mailoutbox, django_user_model):
user = django_user_model.objects.create_user(
username="taskuser",
email="[email protected]",
password="pass123"
)
send_welcome_email(user.id) # call synchronously in tests
assert len(mailoutbox) == 1
assert mailoutbox[0].to == ["[email protected]"]
Use CELERY_TASK_ALWAYS_EAGER = True in test settings so tasks run synchronously:
# settings.py
if TESTING := os.environ.get("TESTING"):
CELERY_TASK_ALWAYS_EAGER = True
Running Tests in CI
Add a GitHub Actions workflow so tests run on every push:
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: testdb
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports: ["5432:5432"]
options: --health-cmd pg_isready --health-interval 10s
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest --tb=short
env:
DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb
DJANGO_SECRET_KEY: test-secret-key-not-real
TESTING: "1"
The 20% That Covers 80% of the Risk
If your app has zero tests and you need to ship:
- Test that every auth-required view redirects unauthenticated users
- Test that users can only access their own objects (the IDOR check)
- Test the core happy path end-to-end
- Test that the payment/billing flow completes correctly
Those four test categories will catch the bugs that cause incidents. Write them first, then expand coverage from there.
If you need tests added to your AI-built Django codebase before it goes to production, hire me for a code review and test suite setup. I'll write the critical path tests and set up CI so they run automatically.