Recap
In the previous four articles we built a complete test suite for a Django orders application. Article 1 established the mental model: test pyramid, test types, properties of good tests. Articles 2 and 3 covered unit tests in both unittest and pytest. Article 4 moved to integration tests: views, APIs, URL routing, serializer validation. Article 5 went deep on auth and permissions: login flows, token and JWT authentication, custom permission classes, and role-based access control.
Until now, we have been writing tests after the fact: code exists, we test it. TDD reverses that order. You write the test first, watch it fail, then write only the code needed to make it pass. Then you clean up.
This article is entirely hands-on. We build a product review feature from scratch, test-first, one red-green-refactor cycle at a time. By the end you will have a working feature and a full test suite that grew alongside it, never ahead, never behind.
What is TDD?
Test-driven development is a development technique with three steps that repeat in a tight loop:
- Red: Write a test for behaviour that does not exist yet. Run it. It fails. The failure is expected and required.
- Green: Write the minimum amount of code to make the test pass. Do not write more than necessary. Resist the urge to add features the test does not demand.
- Refactor: Clean up the code without changing its behaviour. Rename things, extract duplication, improve structure. The tests stay green throughout.
The cycle is short. Each iteration adds one small piece of behaviour. The accumulated tests become a safety net that makes every subsequent refactor safe.
TDD is not about testing. It is about design. The tests are a side effect of thinking clearly about what you want the code to do before you write it.
When you write a test first, you are forced to think about the interface before the implementation. What should I call this method? What arguments does it take? What does it return? What happens when the input is invalid? These are design questions, and TDD surfaces them before you have invested hours in an implementation that answers them wrong.
When TDD helps
TDD is most valuable in these situations:
- Business logic with clear rules. When you can articulate "given X input, the output should be Y", TDD is a natural fit. Discount calculations, validation rules, state machine transitions, and aggregation logic all fall into this category.
- Bug fixes. Write a test that reproduces the bug first. The test fails. Fix the bug. The test passes. Now the bug can never silently return.
- APIs with a defined contract. When you know exactly what the endpoint should accept and return, writing the tests first ensures the implementation matches the contract rather than drifting from it.
- Refactoring with confidence. A green test suite is the precondition for safe refactoring. If you have good coverage, TDD keeps that coverage tight throughout.
When TDD gets in the way
TDD is a tool, not a religion. There are contexts where it slows you down without adding value:
- Exploratory prototyping. When you are genuinely unsure what the right abstraction is, writing tests upfront can lock you into a design before you understand the problem. Explore first, then test.
- UI-heavy work. Testing layout, animations, and visual appearance through unit or integration tests is painful and brittle. Test the data and logic layer; verify the UI manually or with targeted snapshot tests.
- Generated code. If a migration, a scaffold, or a code generator produces the file, testing the output of the generator is not your responsibility.
- Trivial glue code. A view that calls one service function and returns its result has no logic to test at the unit level. An integration test covers it adequately.
The goal is always a test suite you can trust. TDD is one path to that goal. Apply it where it accelerates the work, not as a ritual applied uniformly.
The feature we are building
We are adding a product review system to the orders application. The requirements are:
- Authenticated users can leave a review on a product.
- A review has a rating (integer, 1 to 5) and an optional text body.
- A user can only leave one review per product. A second submission should update the existing review, not create a duplicate.
- The
Productmodel should expose anaverage_ratingthat reflects all current reviews. - The API endpoint accepts POST to create or update a review and GET to list reviews for a product.
- Only the author of a review can update or delete it.
We start with nothing. No model, no serializer, no view. Just tests.
# Create the test file before any application code exists
reviews/
└── tests/
├── __init__.py
└── test_reviews.pyCycle 1: the Review model
Red. Write the first test. We want to confirm that a Review model can be created with a user, a product, a rating, and an optional body.
import pytest
from decimal import Decimal
from django.contrib.auth import get_user_model
from orders.models import Product
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="alice", password="pass")
@pytest.fixture
def product(db):
return Product.objects.create(name="Widget", price=Decimal("25.00"), stock=10)
@pytest.mark.django_db
def test_review_can_be_created_with_required_fields(user, product):
from reviews.models import Review
review = Review.objects.create(
user=user,
product=product,
rating=4,
)
assert review.pk is not None
assert review.rating == 4
assert review.body == ""
@pytest.mark.django_db
def test_review_can_store_a_text_body(user, product):
from reviews.models import Review
review = Review.objects.create(
user=user,
product=product,
rating=5,
body="Excellent product. Highly recommended.",
)
assert review.body == "Excellent product. Highly recommended."Run the tests. They fail with ModuleNotFoundError: No module named 'reviews'. That is the red phase. Now write the minimum code to make them pass.
Green.
python manage.py startapp reviewsfrom django.db import models
from django.contrib.auth import get_user_model
from orders.models import Product
User = get_user_model()
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="reviews")
rating = models.PositiveSmallIntegerField()
body = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)python manage.py makemigrations reviews
pytest reviews/ # both tests passGreen. The model does exactly what the tests demand. Nothing more. We have not added __str__, no Meta class, no ordering. Those will come when a test requires them.
Cycle 2: rating validation
Red. The rating must be between 1 and 5. Write tests for the boundaries.
import pytest
from django.core.exceptions import ValidationError
@pytest.mark.django_db
def test_rating_of_1_is_valid(user, product):
from reviews.models import Review
review = Review(user=user, product=product, rating=1)
review.full_clean() # should not raise
@pytest.mark.django_db
def test_rating_of_5_is_valid(user, product):
from reviews.models import Review
review = Review(user=user, product=product, rating=5)
review.full_clean()
@pytest.mark.django_db
def test_rating_of_0_is_invalid(user, product):
from reviews.models import Review
review = Review(user=user, product=product, rating=0)
with pytest.raises(ValidationError):
review.full_clean()
@pytest.mark.django_db
def test_rating_of_6_is_invalid(user, product):
from reviews.models import Review
review = Review(user=user, product=product, rating=6)
with pytest.raises(ValidationError):
review.full_clean()
@pytest.mark.django_db
def test_rating_of_0_error_mentions_rating_field(user, product):
from reviews.models import Review
review = Review(user=user, product=product, rating=0)
with pytest.raises(ValidationError) as exc_info:
review.full_clean()
assert "rating" in exc_info.value.message_dictRun the tests. The boundary tests fail because the model accepts any positive integer. Green:
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
from django.contrib.auth import get_user_model
from orders.models import Product
User = get_user_model()
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="reviews")
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)pytest reviews/ # all tests passNote
full_clean() vs save()
Django validators only run when full_clean() is called. They do not run automatically on save(). In unit tests, call full_clean() directly to test validation. In serializer and form tests, validation runs as part of the serializer's is_valid() or the form's is_valid() call.
Cycle 3: one review per user per product
Red. A user can only leave one review per product. A second attempt should raise an error at the database level.
from django.db import IntegrityError
@pytest.mark.django_db
def test_user_can_only_review_a_product_once(user, product):
from reviews.models import Review
Review.objects.create(user=user, product=product, rating=4)
with pytest.raises(IntegrityError):
Review.objects.create(user=user, product=product, rating=5)
@pytest.mark.django_db
def test_two_users_can_each_review_the_same_product(product, db):
from reviews.models import Review
user_a = User.objects.create_user(username="alice", password="pass")
user_b = User.objects.create_user(username="bob", password="pass")
Review.objects.create(user=user_a, product=product, rating=4)
Review.objects.create(user=user_b, product=product, rating=3)
assert Review.objects.count() == 2
@pytest.mark.django_db
def test_user_can_review_two_different_products(user, db):
from reviews.models import Review
product_a = Product.objects.create(name="Widget A", price=Decimal("10.00"), stock=5)
product_b = Product.objects.create(name="Widget B", price=Decimal("20.00"), stock=5)
Review.objects.create(user=user, product=product_a, rating=4)
Review.objects.create(user=user, product=product_b, rating=3)
assert Review.objects.count() == 2The first test fails because the database allows duplicates. Green: add a unique constraint.
class Review(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="reviews")
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="reviews")
rating = models.PositiveSmallIntegerField(
validators=[MinValueValidator(1), MaxValueValidator(5)]
)
body = models.TextField(default="", blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "product"],
name="unique_review_per_user_per_product",
)
]python manage.py makemigrations reviews
pytest reviews/ # all tests passCycle 4: average rating on Product
Red. The Product model should expose an average_rating property. We need to think carefully about the edge cases: what does average_rating return when there are no reviews?
@pytest.mark.django_db
def test_product_average_rating_with_no_reviews_is_none(product):
assert product.average_rating is None
@pytest.mark.django_db
def test_product_average_rating_with_one_review(user, product):
from reviews.models import Review
Review.objects.create(user=user, product=product, rating=4)
assert product.average_rating == 4.0
@pytest.mark.django_db
def test_product_average_rating_with_multiple_reviews(product, db):
from reviews.models import Review
user_a = User.objects.create_user(username="alice", password="pass")
user_b = User.objects.create_user(username="bob", password="pass")
user_c = User.objects.create_user(username="carol", password="pass")
Review.objects.create(user=user_a, product=product, rating=5)
Review.objects.create(user=user_b, product=product, rating=3)
Review.objects.create(user=user_c, product=product, rating=4)
assert product.average_rating == 4.0
@pytest.mark.django_db
def test_product_average_rating_updates_when_review_is_added(user, product):
from reviews.models import Review
Review.objects.create(user=user, product=product, rating=2)
assert product.average_rating == 2.0
user_b = User.objects.create_user(username="bob", password="pass")
Review.objects.create(user=user_b, product=product, rating=4)
assert product.average_rating == 3.0These tests fail because Product has no average_rating. Green:
from django.db.models import Avg
class Product(models.Model):
name = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
stock = models.PositiveIntegerField(default=0)
def is_in_stock(self):
return self.stock > 0
@property
def average_rating(self):
result = self.reviews.aggregate(avg=Avg("rating"))["avg"]
return round(result, 1) if result is not None else Nonepytest reviews/ orders/tests/test_models.py # all tests passNotice the test for None when there are no reviews. That is not an afterthought. We wrote the test first, which forced us to decide: should average_rating return None, 0, or raise? We chose None before writing a single line of implementation. That is TDD working as intended.
Cycle 5: the API endpoint
Red. We want a POST endpoint at /api/products/{id}/reviews/ that creates a review. Start with the most basic integration test: authenticated users can post a review and get a 201 back.
import pytest
from django.urls import reverse
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def api_client():
return APIClient()
@pytest.fixture
def authenticated_api_client(api_client, user):
api_client.force_authenticate(user=user)
return api_client
@pytest.mark.django_db
def test_authenticated_user_can_create_review(authenticated_api_client, product):
response = authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 4, "body": "Great product."},
format="json",
)
assert response.status_code == 201
@pytest.mark.django_db
def test_anonymous_user_cannot_create_review(api_client, product):
response = api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 4},
format="json",
)
assert response.status_code == 401
@pytest.mark.django_db
def test_create_review_writes_to_database(authenticated_api_client, product):
from reviews.models import Review
authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 4, "body": "Great product."},
format="json",
)
assert Review.objects.count() == 1
review = Review.objects.first()
assert review.rating == 4
assert review.product == productThe tests fail because neither the URL nor the view exists. Green:
from rest_framework import serializers
from .models import Review
class ReviewSerializer(serializers.ModelSerializer):
author = serializers.CharField(source="user.username", read_only=True)
class Meta:
model = Review
fields = ["id", "author", "rating", "body", "created_at", "updated_at"]
read_only_fields = ["id", "author", "created_at", "updated_at"]from rest_framework import generics, permissions
from django.shortcuts import get_object_or_404
from orders.models import Product
from .models import Review
from .serializers import ReviewSerializer
class ProductReviewListCreateView(generics.ListCreateAPIView):
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
return Review.objects.filter(product_id=self.kwargs["product_pk"])
def perform_create(self, serializer):
product = get_object_or_404(Product, pk=self.kwargs["product_pk"])
serializer.save(user=self.request.user, product=product)from django.urls import path
from .views import ProductReviewListCreateView
app_name = "reviews"
urlpatterns = [
path(
"products/<int:product_pk>/reviews/",
ProductReviewListCreateView.as_view(),
name="product-reviews",
),
]pytest reviews/ # all tests passCycle 6: business rules in the serializer
Red. Two business rules need serializer-level enforcement: the rating must be 1 to 5, and submitting a second review for the same product should update the existing one rather than fail with a database error.
@pytest.mark.django_db
def test_rating_below_1_returns_400(authenticated_api_client, product):
response = authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 0},
format="json",
)
assert response.status_code == 400
assert "rating" in response.data
@pytest.mark.django_db
def test_rating_above_5_returns_400(authenticated_api_client, product):
response = authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 6},
format="json",
)
assert response.status_code == 400
assert "rating" in response.data
@pytest.mark.django_db
def test_submitting_second_review_updates_existing_review(authenticated_api_client, user, product):
from reviews.models import Review
authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 3, "body": "Decent."},
format="json",
)
authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 5, "body": "Changed my mind. Excellent."},
format="json",
)
assert Review.objects.count() == 1
review = Review.objects.get(user=user, product=product)
assert review.rating == 5
assert review.body == "Changed my mind. Excellent."
@pytest.mark.django_db
def test_second_review_submission_returns_200_not_201(authenticated_api_client, product):
authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 3},
format="json",
)
response = authenticated_api_client.post(
reverse("api:product-reviews", kwargs={"product_pk": product.pk}),
data={"rating": 5},
format="json",
)
assert response.status_code == 200The rating validation tests pass because the model validators fire through the serializer. But the upsert tests fail: the second create raises an IntegrityError. Green: implement the upsert in the view.
from rest_framework import generics, permissions, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from orders.models import Product
from .models import Review
from .serializers import ReviewSerializer
class ProductReviewListCreateView(generics.ListCreateAPIView):
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
return Review.objects.filter(product_id=self.kwargs["product_pk"])
def create(self, request, *args, **kwargs):
product = get_object_or_404(Product, pk=self.kwargs["product_pk"])
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
review, created = Review.objects.update_or_create(
user=request.user,
product=product,
defaults={
"rating": serializer.validated_data["rating"],
"body": serializer.validated_data.get("body", ""),
},
)
output = ReviewSerializer(review)
http_status = status.HTTP_201_CREATED if created else status.HTTP_200_OK
return Response(output.data, status=http_status)pytest reviews/ # all tests passCycle 7: permissions
Red. Only the author of a review should be able to delete it. Anonymous users get 401. Other authenticated users get 403.
@pytest.mark.django_db
def test_author_can_delete_their_review(authenticated_api_client, user, product):
from reviews.models import Review
review = Review.objects.create(user=user, product=product, rating=4)
response = authenticated_api_client.delete(
reverse("api:product-review-detail", kwargs={
"product_pk": product.pk,
"pk": review.pk,
})
)
assert response.status_code == 204
assert not Review.objects.filter(pk=review.pk).exists()
@pytest.mark.django_db
def test_anonymous_user_cannot_delete_review(api_client, user, product):
from reviews.models import Review
review = Review.objects.create(user=user, product=product, rating=4)
response = api_client.delete(
reverse("api:product-review-detail", kwargs={
"product_pk": product.pk,
"pk": review.pk,
})
)
assert response.status_code == 401
assert Review.objects.filter(pk=review.pk).exists()
@pytest.mark.django_db
def test_non_author_cannot_delete_review(api_client, user, product, db):
from reviews.models import Review
review = Review.objects.create(user=user, product=product, rating=4)
other_user = User.objects.create_user(username="bob", password="pass")
api_client.force_authenticate(user=other_user)
response = api_client.delete(
reverse("api:product-review-detail", kwargs={
"product_pk": product.pk,
"pk": review.pk,
})
)
assert response.status_code in (403, 404)
assert Review.objects.filter(pk=review.pk).exists()The tests fail because the detail endpoint does not exist. Green:
from rest_framework.permissions import BasePermission, SAFE_METHODS
class IsAuthorOrReadOnly(BasePermission):
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
return obj.user == request.userfrom .permissions import IsAuthorOrReadOnly
class ProductReviewDetailView(generics.RetrieveDestroyAPIView):
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
def get_queryset(self):
return Review.objects.filter(product_id=self.kwargs["product_pk"])from django.urls import path
from .views import ProductReviewListCreateView, ProductReviewDetailView
app_name = "reviews"
urlpatterns = [
path(
"products/<int:product_pk>/reviews/",
ProductReviewListCreateView.as_view(),
name="product-reviews",
),
path(
"products/<int:product_pk>/reviews/<int:pk>/",
ProductReviewDetailView.as_view(),
name="product-review-detail",
),
]pytest reviews/ # all tests passThe refactor phase
We have reached green on all cycles. The tests pass and the feature works. Now we refactor: clean up the code without changing any behaviour. The tests stay green throughout. If any test turns red during refactoring, we have broken something.
Here is what the refactor catches in our implementation:
Move the upsert logic to a service function
The create method on ProductReviewListCreateView contains business logic. That logic belongs in a service function, not a view. The view should orchestrate, not decide.
from .models import Review
def upsert_review(user, product, rating: int, body: str = "") -> tuple:
return Review.objects.update_or_create(
user=user,
product=product,
defaults={"rating": rating, "body": body},
)from rest_framework import generics, permissions, status
from rest_framework.response import Response
from django.shortcuts import get_object_or_404
from orders.models import Product
from .models import Review
from .serializers import ReviewSerializer
from .permissions import IsAuthorOrReadOnly
from .services import upsert_review
class ProductReviewListCreateView(generics.ListCreateAPIView):
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
return Review.objects.filter(product_id=self.kwargs["product_pk"])
def create(self, request, *args, **kwargs):
product = get_object_or_404(Product, pk=self.kwargs["product_pk"])
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
review, created = upsert_review(
user=request.user,
product=product,
rating=serializer.validated_data["rating"],
body=serializer.validated_data.get("body", ""),
)
output = ReviewSerializer(review)
http_status = status.HTTP_201_CREATED if created else status.HTTP_200_OK
return Response(output.data, status=http_status)
class ProductReviewDetailView(generics.RetrieveDestroyAPIView):
serializer_class = ReviewSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsAuthorOrReadOnly]
def get_queryset(self):
return Review.objects.filter(product_id=self.kwargs["product_pk"])pytest reviews/ # still all greenAdd ordering to querysets
The review list currently returns results in database order, which is unpredictable. Write a test first, then add ordering.
@pytest.mark.django_db
def test_reviews_are_returned_newest_first(authenticated_api_client, product, db):
from reviews.models import Review
from django.utils import timezone
from datetime import timedelta
user_a = User.objects.create_user(username="alice", password="pass")
user_b = User.objects.create_user(username="bob", password="pass")
older = Review.objects.create(user=user_a, product=product, rating=3)
newer = Review.objects.create(user=user_b, product=product, rating=5)
response = authenticated_api_client.get(
reverse("api:product-reviews", kwargs={"product_pk": product.pk})
)
ids = [item["id"] for item in response.data["results"]]
assert ids[0] == newer.pk def get_queryset(self):
return Review.objects.filter(
product_id=self.kwargs["product_pk"]
).order_by("-created_at")pytest reviews/ # still all greenWhat TDD gave us
We built the entire feature test-first. Look at what we got for free:
- The upsert behaviour was designed before implementation. We wrote the test for "second review updates the first" before we wrote a single line of view code. When it came time to implement, the expected behaviour was unambiguous.
- The
Nonecase for average_rating was an explicit decision. The test forced us to answer "what happens with no reviews?" before we touched the property. That decision is now documented in the test suite permanently. - Every boundary has a test. Ratings of 0 and 6, anonymous deletes, non-author deletes, the 200 vs 201 distinction on upsert. None of these were afterthoughts. They were the first thing we wrote.
- The refactor was safe. We extracted the upsert logic to a service function and added ordering to the queryset. The tests stayed green throughout. We knew immediately when nothing broke.
- The test suite is the specification. Every requirement in "the feature we are building" has a corresponding test. If a test fails, a requirement is broken. This is the documentation that cannot go stale.
✦ Tip
The final test count for this feature
Model tests: 9. API integration tests: 12. Permission tests: 3. Service tests: 0 (covered by integration tests). Total: 24 tests for a complete product review feature, each one written before the code it tests.
Summary
- TDD is a development technique, not a testing technique. Its primary output is better-designed code. The tests are a valuable side effect.
- The three steps are red (write a failing test), green (write the minimum code to pass it), and refactor (clean up without breaking anything).
- TDD works best for business logic with clear rules, bug fixes, and API contracts. It adds friction for exploratory prototyping and UI-heavy work.
- Write the test for the edge case first. "What happens with no reviews?" is a question TDD forces you to answer before you write the property. Without TDD, you discover the gap in production.
- The refactor phase is not optional. Writing tests first produces code that works. The refactor phase produces code that works and is maintainable. Skip it and technical debt accumulates one cycle at a time.
- When you fix a bug, write a test that reproduces it first. The test failing is proof the bug is real. The test passing is proof the fix works. The test staying green forever is proof the bug cannot return.
- TDD does not mean 100% coverage. It means that every behaviour the tests demand is implemented, and nothing more. Coverage that comes from TDD is meaningful because every test was written for a reason.
In the next article we shift focus to test data and isolation: factory_boy, pytest fixture scoping, and strategies for keeping your test suite fast and hermetically isolated as it grows to hundreds of tests.