Build a Tested Ecommerce Store
Build a full-stack ecommerce app with Django, React, PostgreSQL, and CI.
Introduction
30 Second Summary
Every online store you've ever shopped at has the same three things under the hood: a product catalog, a shopping cart, and a checkout process. Building one from scratch is how professional developers prove they can ship real software.
In this project, you will build a full-stack ecommerce store called "ShopStack" with a Django backend, a React frontend, and automated tests across every layer. You'll containerize it with Docker and validate everything through a CI pipeline.
What You'll Build
ShopStack is a tested ecommerce application with a Django REST API serving product, cart, and order data from PostgreSQL, a React storefront for browsing and purchasing, and Docker Compose running everything together.
By the end of this project, you'll have:
- A Django REST API with product listings, cart management, and order processing backed by PostgreSQL and secured with JWT authentication.
- A React storefront with a product grid, cart sidebar, and checkout flow connected to the live API.
- Automated tests across both layers using pytest and Vitest, running in a GitHub Actions CI pipeline.
- Secret Mission: Add product search with filtering and pagination, tested end-to-end.
Are there any prerequisites?
This project assumes basic Python knowledge, basic JavaScript/React knowledge, and command line familiarity. You don't need prior experience with Django, PostgreSQL, or Docker.
This is a multi-session project designed to take 3-4 hours total. Each step is self-contained enough to pause between sessions.
Set Up the Backend Environment
Before you can build any API endpoints or write tests, you need a solid development environment.
In this step, you will install Python and PostgreSQL, create a Django project, and configure it to talk to a real database. This foundation supports every feature you build in the rest of the project.
In this step, get ready to:
- Install Python 3.12.3 and PostgreSQL 16 with a virtual environment and all project dependencies.
- Create the Django project structure with a products app and configure settings for DRF, CORS, and PostgreSQL.
- Create the PostgreSQL database, run migrations, and verify the DRF browsable API loads.
Install Python 3.12.3 and PostgreSQL 16
This project requires a specific Python version and a production-grade database. Python 3.12.3 gives you the latest language features that Django 5.2 supports.
PostgreSQL is the database engine your API will use in both development and production.
- Check your Python version by running:
python3 --version
✔️ I see version 3.12
You are ready to continue. Any 3.12.x patch version works for this project.
ⓧ I see a version lower than 3.12
Your system has an older Python. Install Python 3.12.3 using pyenv, which lets you manage multiple versions side by side.
- Install pyenv (if you do not already have it) by running:
brew install pyenv
- Install Python 3.12.3 and set it as the local version by running:
pyenv install 3.12.3
pyenv local 3.12.3
- Confirm it worked by running python3 --version again. You should see Python 3.12.3.
Why pyenv?
macOS ships with an older Python version by default. pyenv lets you install the exact version this project needs without overwriting your system Python.
ⓧ Command not found
Python is not installed. Install it using pyenv.
- Install pyenv with Homebrew by running:
brew install pyenv
- Add pyenv to your shell by running:
echo 'eval "$(pyenv init --path)"' >> ~/.zshrc
source ~/.zshrc
- Install Python 3.12.3 and set it as the local version by running:
pyenv install 3.12.3
pyenv local 3.12.3
- Verify Python is available by running:
python3 --version
You should see Python 3.12.3 in the output.
- Check your PostgreSQL version by running:
psql --version
✔️ I see version 16
PostgreSQL 16 is installed. Make sure the service is running by executing:
brew services start postgresql@16
You are ready to continue.
ⓧ I see an older version
You have PostgreSQL installed, but you need version 16 for this project.
- Install PostgreSQL 16 by running:
brew install postgresql@16
- Add PostgreSQL 16 to your PATH by running:
echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
- Start the PostgreSQL 16 service by running:
brew services start postgresql@16
- Confirm the version by running psql --version again. You should see version 16.
ⓧ Command not found
PostgreSQL is not installed. Install it with Homebrew.
- Install PostgreSQL 16 by running:
brew install postgresql@16
- Add the PostgreSQL binaries to your PATH by running:
echo 'export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc
- Start the PostgreSQL service by running:
brew services start postgresql@16
- Verify the installation by running:
psql --version
You should see a version number starting with 16.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into the backend directory by running these commands:
mkdir shopstack
cd shopstack
mkdir backend
cd backend
- Create a virtual environment and activate it by running:
python3 -m venv venv
source venv/bin/activate
What is a virtual environment?
A virtual environment is an isolated Python installation for this project. Every package you install goes into the venv/ folder instead of your system Python. This keeps your projects from interfering with each other.
- Install all backend dependencies by running:
pip install django==5.2 djangorestframework==3.17.1 psycopg[binary] dj-database-url django-cors-headers pytest pytest-django factory-boy
What are these packages?
django is the web framework. djangorestframework adds REST API tooling. psycopg[binary] is the PostgreSQL adapter that lets Django talk to your database.
django-cors-headers handles cross-origin requests from your React frontend. pytest and pytest-django run your test suite. factory-boy generates test data.
- Confirm the packages installed by running:
pip list | grep -i django
You should see Django, djangorestframework, and django-cors-headers in the output.
Pip install failing?
Make sure your virtual environment is active. You should see (venv) at the beginning of your terminal prompt. If not, run source venv/bin/activate again.
Help me troubleshoot
Create the Django project and configure settings
Now you will scaffold the Django project and the products app. Then you will wire up Django REST Framework, CORS, and your PostgreSQL connection in one go.
- Create the Django project by running:
django-admin startproject shopstack .
What does the dot mean?
The . at the end tells Django to create the project files in the current directory instead of nesting another folder. Your manage.py ends up at backend/manage.py and settings at backend/shopstack/settings.py.
- Create the products app by running:
python manage.py startapp products
- Confirm the file structure by running:
ls
You should see manage.py, shopstack/, products/, and venv/ listed.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the backend folder in VS Code by running this in your terminal:
code .
- In VS Code, open shopstack/settings.py from the Explorer sidebar.
- Replace the entire file contents with the following code:
"""Django settings for shopstack project."""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-shopstack-dev-key-change-in-production'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'corsheaders',
# Local apps
'products',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'shopstack.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'shopstack.wsgi.application'
# Database - PostgreSQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'shopstack',
'USER': 'postgres',
'PASSWORD': '',
'HOST': 'localhost',
'PORT': '5432',
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = 'static/'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Django REST Framework
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
}
# CORS - allow all origins in development
CORS_ALLOW_ALL_ORIGINS = True
What does this settings file do?
INSTALLED_APPS registers Django REST Framework (rest_framework), CORS middleware (corsheaders), and your products app so Django knows they exist.
The DATABASES block points Django at a PostgreSQL database named shopstack on localhost. CORS_ALLOW_ALL_ORIGINS = True lets your React frontend (running on a different port) make requests to this API during development.
CorsMiddleware is placed first in MIDDLEWARE so it can add the correct headers before any other middleware processes the response.
- Save shopstack/settings.py.
Next, configure pytest to find your Django settings. This requires two small files at the backend/ root.
- Create a new file called pytest.ini in the backend/ folder (the same level as manage.py) and paste the following:
[pytest]
DJANGO_SETTINGS_MODULE = shopstack.settings
python_files = tests.py test_*.py *_tests.py
What does pytest.ini do?
DJANGO_SETTINGS_MODULE tells pytest-django which settings file to load when running tests. python_files defines the filename patterns pytest uses to discover test files.
- Create another new file called conftest.py in the backend/ folder and paste the following:
import pytest
@pytest.fixture(scope='session')
def django_db_modify_db_settings():
"""Set a dedicated test database name."""
from django.conf import settings
settings.DATABASES['default']['TEST'] = {
'NAME': 'test_shopstack',
}
What does conftest.py do?
This overrides pytest-django's django_db_modify_db_settings fixture to set a dedicated test database called test_shopstack. This keeps your development data safe by running tests against a separate database.
- Save both pytest.ini and conftest.py.
VS Code not showing the files?
Make sure you opened the backend/ folder in VS Code (not the parent shopstack/ folder). The pytest.ini and conftest.py files should sit next to manage.py.
Help me fix my file structure
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before continuing.
ⓧ I'd like to double check the full code
Here is the full shopstack/settings.py:
"""Django settings for shopstack project."""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-shopstack-dev-key-change-in-production'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'corsheaders',
# Local apps
'products',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'shopstack.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'shopstack.wsgi.application'
# Database - PostgreSQL
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'shopstack',
'USER': 'postgres',
'PASSWORD': '',
'HOST': 'localhost',
'PORT': '5432',
}
}
# Password validation
AUTH_PASSWORD_VALIDATORS = [
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
]
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files
STATIC_URL = 'static/'
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Django REST Framework
REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny',
],
}
# CORS - allow all origins in development
CORS_ALLOW_ALL_ORIGINS = True
Here is pytest.ini:
[pytest]
DJANGO_SETTINGS_MODULE = shopstack.settings
python_files = tests.py test_*.py *_tests.py
Here is conftest.py:
import pytest
@pytest.fixture(scope='session')
def django_db_modify_db_settings():
"""Set a dedicated test database name."""
from django.conf import settings
settings.DATABASES['default']['TEST'] = {
'NAME': 'test_shopstack',
}
Create the database and verify the setup
Your Django settings point at a PostgreSQL database called shopstack that does not exist yet. You need to create it before Django can connect.
- Create the PostgreSQL database by running:
createdb shopstack
What does createdb do?
createdb is a PostgreSQL utility that creates a new database on your local server. It creates an empty database named shopstack that Django will use to store your product, cart, and order data.
Seeing a connection refused error?
Make sure PostgreSQL is running. Run brew services start postgresql@16 and try again.
If you see a "role postgres does not exist" error, your default PostgreSQL user may match your macOS username instead. Try running createdb -U $(whoami) shopstack and update the USER field in settings.py to match your macOS username.
Help me fix my PostgreSQL connection
- Run Django migrations to create the default database tables:
python manage.py migrate
You should see output listing each migration being applied, ending with OK on each line.
- Now you need a URL endpoint for the DRF browsable API. Open shopstack/urls.py in VS Code and replace its contents with:
"""URL configuration for shopstack project."""
from django.contrib import admin
from django.urls import path, include
from rest_framework.routers import DefaultRouter
# Create a router for API endpoints
router = DefaultRouter()
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include(router.urls)),
]
What does this URL configuration do?
The DefaultRouter from DRF automatically creates a browsable API root at /api/. As you add viewsets in later steps, the router will register them and list all available endpoints on that page.
- Save shopstack/urls.py.
- Start the Django development server by running:
python manage.py runserver
- Open your browser and go to http://127.0.0.1:8000/api/.
You should see the Django REST Framework browsable API page with the DRF header bar and an empty JSON response.
- Press Ctrl+C in your terminal to stop the server.
Server not starting?
If you see a database connection error, double check that PostgreSQL is running (brew services list) and that the database shopstack exists (psql -l lists all databases).
If you see an import error for rest_framework, confirm your virtual environment is active. Look for (venv) in your terminal prompt.
Help me troubleshoot my Django server
Your backend foundation is solid. You have Django, DRF, and PostgreSQL all talking to each other. Next up, you will define the Product model and build your first API endpoints with full test coverage.
Build the Product API with Tests
Your Django project is set up and talking to PostgreSQL. Now you need actual data to serve.
Every ecommerce store starts with products. In this step, you will define a Product model, expose it through a REST API, and write automated tests that prove the API works correctly against a real database.
In this step, get ready to:
- Define the Product model and wire up a read-only API endpoint.
- Write pytest tests for the model and API using factory-boy.
- Run the full test suite against PostgreSQL and confirm everything passes.
Define the Product model, serializer, and viewset
The Product model defines the shape of product data stored in PostgreSQL. The serializer converts model instances to JSON. The viewset handles HTTP requests.
- Open backend/products/models.py in VS Code and replace its contents with the following:
from django.db import models
class Product(models.Model):
# Core product information
name = models.CharField(max_length=255)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
image_url = models.URLField(blank=True)
category = models.CharField(max_length=100)
stock = models.PositiveIntegerField(default=0)
def __str__(self):
return self.name
What does this code do?
Each field maps to a column in your PostgreSQL database. DecimalField avoids floating-point rounding errors on prices. PositiveIntegerField prevents negative stock values at the database level.
The __str__ method gives each product a readable name in the admin panel and test output.
- Save models.py.
- Generate and apply the migration by running these commands in your terminal (make sure your virtual environment is active and you are inside the backend/ folder):
python manage.py makemigrations products
python manage.py migrate
- You should see output showing a migration file was created (something like 0001_initial.py) and applied successfully.
Migration failed?
Make sure your PostgreSQL server is running and the shopstack database exists. Check that the DATABASES settings in settings.py match your local PostgreSQL credentials.
help me debug a Django migration error against PostgreSQL
- Now create the serializer. Create a new file at backend/products/serializers.py and add the following:
from rest_framework import serializers
from .models import Product
class ProductSerializer(serializers.ModelSerializer):
"""Converts Product model instances to/from JSON."""
class Meta:
model = Product
fields = '__all__'
What does this code do?
ModelSerializer automatically generates serializer fields from the model. Setting fields = '__all__' exposes every field (id, name, description, price, image_url, category, stock) in the API response.
- Save serializers.py.
- Now create the viewset. Open backend/products/views.py and replace its contents with:
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.permissions import AllowAny
from .models import Product
from .serializers import ProductSerializer
class ProductViewSet(ReadOnlyModelViewSet):
"""Provides list and retrieve actions for products."""
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [AllowAny]
Why ReadOnlyModelViewSet?
ReadOnlyModelViewSet provides list (GET all products) and retrieve (GET one product) actions. Products are managed by store admins, not shoppers, so the public API only needs read access for now.
AllowAny makes the product catalog public. Authentication will be added to cart and order endpoints in a later step.
- Save views.py.
- Create a new file at backend/products/urls.py and add the following to register the viewset with a router:
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet
# Register the viewset at /products/
router = DefaultRouter()
router.register(r'products', ProductViewSet)
urlpatterns = router.urls
What does this code do?
DefaultRouter automatically generates URL patterns for all the actions on the viewset. Registering with the prefix r'products' creates /products/ (list) and /products/{id}/ (detail) endpoints.
- Save urls.py.
- Now connect the app URLs to the project. Open backend/shopstack/urls.py and replace its contents with:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('products.urls')),
]
What does this code do?
The include('products.urls') mounts the products router under /api/. Combined with the router prefix, your product endpoints are now at /api/products/ and /api/products/{id}/.
- Save shopstack/urls.py.
- Verify the API is working by running the dev server:
python manage.py runserver
- Open http://127.0.0.1:8000/api/products/ in your browser. You should see the DRF browsable API with an empty product list ([]).
- Stop the server with Ctrl+C.
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point:
from django.db import models
class Product(models.Model):
# Core product information
name = models.CharField(max_length=255)
description = models.TextField()
price = models.DecimalField(max_digits=10, decimal_places=2)
image_url = models.URLField(blank=True)
category = models.CharField(max_length=100)
stock = models.PositiveIntegerField(default=0)
def __str__(self):
return self.name
from rest_framework import serializers
from .models import Product
class ProductSerializer(serializers.ModelSerializer):
"""Converts Product model instances to/from JSON."""
class Meta:
model = Product
fields = '__all__'
from rest_framework.viewsets import ReadOnlyModelViewSet
from rest_framework.permissions import AllowAny
from .models import Product
from .serializers import ProductSerializer
class ProductViewSet(ReadOnlyModelViewSet):
"""Provides list and retrieve actions for products."""
queryset = Product.objects.all()
serializer_class = ProductSerializer
permission_classes = [AllowAny]
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet
# Register the viewset at /products/
router = DefaultRouter()
router.register(r'products', ProductViewSet)
urlpatterns = router.urls
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('products.urls')),
]
Write tests for the Product model and API
Tests prove your API works and catch regressions early. You will use pytest with factory-boy to generate realistic test data without manually constructing objects.
- Create the tests package inside the products app by running:
mkdir -p products/tests
touch products/tests/__init__.py
- Confirm the folder exists by running:
ls products/tests/
- You should see __init__.py in the output.
- Create the test factory file at backend/products/tests/factories.py with the following:
import factory
from products.models import Product
class ProductFactory(factory.django.DjangoModelFactory):
"""Generates realistic Product instances for testing."""
class Meta:
model = Product
# Sequence ensures unique names across tests
name = factory.Sequence(lambda n: f"Product {n}")
description = factory.Faker("paragraph")
price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
image_url = factory.Faker("image_url")
category = factory.Faker("word")
stock = factory.Faker("random_int", min=0, max=100)
What does this code do?
DjangoModelFactory knows how to save objects to the database. Each field uses a Faker provider to generate random but valid data. Sequence ensures each product gets a unique name, preventing test collisions.
Calling ProductFactory() creates and saves a product to the test database in a single line.
- Save factories.py.
- Create the model test file at backend/products/tests/test_models.py with:
import pytest
from products.tests.factories import ProductFactory
@pytest.mark.django_db
def test_product_creation():
"""Verify a product can be created and has a valid string representation."""
product = ProductFactory()
assert product.pk is not None
assert str(product) == product.name
What does this code do?
The @pytest.mark.django_db decorator gives this test access to the PostgreSQL test database. The test creates a product via the factory, then asserts it was saved (has a primary key) and that __str__ returns the product name.
- Save test_models.py.
- Create the API test file at backend/products/tests/test_api.py with:
import pytest
from rest_framework.test import APIClient
from products.tests.factories import ProductFactory
@pytest.fixture
def api_client():
"""Provide a DRF test client for making API requests."""
return APIClient()
@pytest.mark.django_db
def test_list_products(api_client):
"""GET /api/products/ returns all products."""
ProductFactory.create_batch(3)
response = api_client.get("/api/products/")
assert response.status_code == 200
assert len(response.data) == 3
@pytest.mark.django_db
def test_retrieve_product(api_client):
"""GET /api/products/{id}/ returns a single product."""
product = ProductFactory()
response = api_client.get(f"/api/products/{product.pk}/")
assert response.status_code == 200
assert response.data["name"] == product.name
What does this code do?
The api_client fixture provides DRF's test client for making HTTP requests without a running server. test_list_products creates 3 products and verifies the list endpoint returns all of them.
test_retrieve_product creates one product and fetches it by primary key. It checks both the status code and that the returned name matches what was created.
- Save test_api.py.
✔️ Awesome, I've got everything!
Great. Make sure all three test files are saved.
ⓧ I'd like to double check the full code
Here are the complete test files:
import factory
from products.models import Product
class ProductFactory(factory.django.DjangoModelFactory):
"""Generates realistic Product instances for testing."""
class Meta:
model = Product
# Sequence ensures unique names across tests
name = factory.Sequence(lambda n: f"Product {n}")
description = factory.Faker("paragraph")
price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
image_url = factory.Faker("image_url")
category = factory.Faker("word")
stock = factory.Faker("random_int", min=0, max=100)
import pytest
from products.tests.factories import ProductFactory
@pytest.mark.django_db
def test_product_creation():
"""Verify a product can be created and has a valid string representation."""
product = ProductFactory()
assert product.pk is not None
assert str(product) == product.name
import pytest
from rest_framework.test import APIClient
from products.tests.factories import ProductFactory
@pytest.fixture
def api_client():
"""Provide a DRF test client for making API requests."""
return APIClient()
@pytest.mark.django_db
def test_list_products(api_client):
"""GET /api/products/ returns all products."""
ProductFactory.create_batch(3)
response = api_client.get("/api/products/")
assert response.status_code == 200
assert len(response.data) == 3
@pytest.mark.django_db
def test_retrieve_product(api_client):
"""GET /api/products/{id}/ returns a single product."""
product = ProductFactory()
response = api_client.get(f"/api/products/{product.pk}/")
assert response.status_code == 200
assert response.data["name"] == product.name
Run the test suite
Time to run the tests and confirm everything passes against the real PostgreSQL test database.
- Run the pytest suite from the backend/ directory:
pytest -v
- You should see output showing 3 tests collected and all passing:
products/tests/test_models.py::test_product_creation PASSED
products/tests/test_api.py::test_list_products PASSED
products/tests/test_api.py::test_retrieve_product PASSED
What happens when pytest runs?
pytest creates a fresh test_shopstack database, runs migrations on it, executes your tests, and then destroys it. Each test function gets its own transaction that rolls back after the test finishes, keeping tests isolated.
Tests failing or not found?
If pytest says "no tests collected", make sure products/tests/__init__.py exists. Without it, Python does not recognize the folder as a package.
If you see a database connection error, confirm PostgreSQL is running and that your user has permission to create databases (pytest-django needs to create test_shopstack).
help me debug failing pytest-django tests against PostgreSQL
You now have a working, tested Product API. Next up, you will add cart and order endpoints so shoppers can actually buy things.
Add Cart and Order APIs with Tests
You have a working Product API that serves product data to anyone who asks. But an ecommerce store needs more than a catalog.
Customers need to add products to a shopping cart, adjust quantities, and eventually place an order. In this step, you will build the cart and order layer of the API with full test coverage.
In this step, get ready to:
- Create Cart, CartItem, and Order models in a new Django app.
- Build serializers and viewsets for cart operations and order creation.
- Write pytest tests covering cart manipulation and order flows.
Create the cart app and define models
Django organizes related functionality into apps. The cart, cart items, and orders all share the same domain, so they belong together in a single cart app.
- Create the new app by running this command from your backend directory:
python manage.py startapp cart
- Create a tests directory inside cart so pytest can discover your test files:
mkdir cart/tests
touch cart/tests/__init__.py
- Remove the default cart/tests.py file that Django generated (you will use the tests/ directory instead):
rm cart/tests.py
- Confirm the new files exist by running:
ls cart/tests/
- You should see __init__.py in the output.
- Register the new app by opening shopstack/settings.py and finding the INSTALLED_APPS list.
- Add "cart" after "products" so the list looks like this:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"corsheaders",
"products",
"cart",
]
What does this do?
Adding "cart" to INSTALLED_APPS tells Django to include this app's models in migrations and register its URL routes.
- Save shopstack/settings.py.
Now define the three models that power the shopping experience.
- Open cart/models.py and replace its contents with the following:
from django.db import models
from django.conf import settings
from products.models import Product
# Shopping cart tied to a single user
class Cart(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Cart for {self.user.username}"
# Individual item inside a cart
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
def __str__(self):
return f"{self.quantity}x {self.product.name}"
# Completed order snapshot
class Order(models.Model):
STATUS_CHOICES = [
("pending", "Pending"),
("completed", "Completed"),
("cancelled", "Cancelled"),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders"
)
total_price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Order {self.pk} - {self.status}"
What does this code do?
Cart uses a OneToOneField so each user has exactly one cart. CartItem links a product to a cart with a quantity. The related_name="items" lets you access all items from a cart with cart.items.all().
Order captures a completed purchase. The status field uses Django's choices parameter to restrict values to pending, completed, or cancelled.
- Save cart/models.py.
- Generate and apply the migrations for your new models by running:
python manage.py makemigrations cart
python manage.py migrate
- You should see output showing the new migration being created and applied, ending with OK for each migration step.
Seeing an import error?
If you see a ModuleNotFoundError mentioning products, make sure you are running the command from the backend directory where manage.py lives.
help me fix this migration error
Build serializers and viewsets for cart operations
The models store data, but your React frontend needs HTTP endpoints to interact with them. You will create serializers to convert model instances to JSON, then viewsets to handle the request logic.
- Create cart/serializers.py and add the following:
from rest_framework import serializers
from .models import Cart, CartItem, Order
from products.models import Product
from products.serializers import ProductSerializer
class CartItemSerializer(serializers.ModelSerializer):
# Read: return full product details
product = ProductSerializer(read_only=True)
# Write: accept a product ID to add to cart
product_id = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all(), write_only=True
)
class Meta:
model = CartItem
fields = ["id", "product", "product_id", "quantity"]
class CartSerializer(serializers.ModelSerializer):
# Nested list of items in the cart
items = CartItemSerializer(many=True, read_only=True)
class Meta:
model = Cart
fields = ["id", "items", "created_at"]
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ["id", "total_price", "status", "created_at"]
What does this code do?
CartItemSerializer uses a dual-field pattern. When reading (GET), the product field returns full product details. When writing (POST), the product_id field accepts just a primary key.
CartSerializer nests the items so a single request returns the entire cart. OrderSerializer exposes only the fields needed for order listing and creation.
- Save cart/serializers.py.
Next, create the viewsets that handle the actual HTTP logic.
- Open cart/views.py and replace its contents with:
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Cart, CartItem, Order
from .serializers import CartSerializer, CartItemSerializer, OrderSerializer
class CartViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
def get_or_create_cart(self):
"""Get the user's cart or create one if it doesn't exist."""
cart, _ = Cart.objects.get_or_create(user=self.request.user)
return cart
@action(detail=False, methods=["get"])
def my_cart(self, request):
"""Return the current user's cart with all items."""
cart = self.get_or_create_cart()
serializer = CartSerializer(cart)
return Response(serializer.data)
@action(detail=False, methods=["post"])
def add_item(self, request):
"""Add a product to the cart or increment its quantity."""
cart = self.get_or_create_cart()
serializer = CartItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
product = serializer.validated_data["product_id"]
cart_item, created = CartItem.objects.get_or_create(
cart=cart, product=product
)
if not created:
cart_item.quantity += 1
cart_item.save()
return Response(CartSerializer(cart).data)
@action(detail=False, methods=["delete"])
def remove_item(self, request):
"""Remove an item from the cart by product ID."""
cart = self.get_or_create_cart()
product_id = request.data.get("product_id")
CartItem.objects.filter(cart=cart, product_id=product_id).delete()
return Response(CartSerializer(cart).data)
class OrderViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""List all orders for the current user."""
orders = Order.objects.filter(user=request.user)
serializer = OrderSerializer(orders, many=True)
return Response(serializer.data)
def create(self, request):
"""Create an order from the current cart contents."""
serializer = OrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
Why use GenericViewSet with custom actions?
A standard ModelViewSet assumes basic CRUD on a single resource. Cart operations are more nuanced: viewing the cart, adding an item, and removing an item are all actions on the same cart object rather than separate resource instances.
GenericViewSet gives you a blank canvas with DRF's authentication and permission machinery. The @action decorator creates custom endpoints like /api/cart/my_cart/ and /api/cart/add_item/.
- Save cart/views.py.
Finally, wire up the URLs so Django knows how to route requests to your new viewsets.
- Create cart/urls.py with the following:
from rest_framework.routers import DefaultRouter
from .views import CartViewSet, OrderViewSet
# Register cart and order endpoints
router = DefaultRouter()
router.register(r"cart", CartViewSet, basename="cart")
router.register(r"orders", OrderViewSet, basename="orders")
urlpatterns = router.urls
What does this code do?
The router generates URL patterns automatically from your viewsets. The basename parameter is required because GenericViewSet does not define a queryset attribute for the router to infer names from.
- Open shopstack/urls.py and add the cart URLs. Find the existing urlpatterns list:
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("products.urls")),
]
- Add the new cart.urls include so the file looks like this:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("products.urls")),
path("api/", include("cart.urls")),
]
- Save shopstack/urls.py and cart/urls.py.
- Verify the server still starts by running:
python manage.py runserver
- You should see the development server start without errors. Press Ctrl+C to stop it.
Seeing a URL conflict or missing basename error?
Make sure your cart/urls.py uses basename="cart" and basename="orders" in the router registrations. Without basenames, the router cannot infer names from a GenericViewSet that has no queryset.
help me fix my cart URL configuration
Write tests and run the full suite
Your models and endpoints are wired up, but untested code is unreliable code. You will create factory-boy fixtures for the new models, then write model and API tests.
- Create cart/tests/factories.py with the following test data factories:
import factory
from django.contrib.auth.models import User
from cart.models import Cart, CartItem, Order
from products.tests.factories import ProductFactory
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user{n}")
email = factory.Faker("email")
password = factory.PostGenerationMethodCall("set_password", "password123")
class CartFactory(factory.django.DjangoModelFactory):
class Meta:
model = Cart
user = factory.SubFactory(UserFactory)
class CartItemFactory(factory.django.DjangoModelFactory):
class Meta:
model = CartItem
cart = factory.SubFactory(CartFactory)
product = factory.SubFactory(ProductFactory)
quantity = 1
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
total_price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
status = "pending"
What does this code do?
Each factory generates a complete, valid database record for testing. UserFactory uses PostGenerationMethodCall to call set_password after the user is created, which properly hashes the password.
SubFactory automatically creates related objects. When you call CartItemFactory(), it creates a Cart (which creates a User) and a Product in one call.
- Save cart/tests/factories.py.
- Create cart/tests/test_models.py with model creation tests:
import pytest
from .factories import CartFactory, CartItemFactory, OrderFactory
@pytest.mark.django_db
def test_cart_creation():
cart = CartFactory()
assert cart.pk is not None
assert cart.user is not None
@pytest.mark.django_db
def test_cart_item_creation():
item = CartItemFactory()
assert item.pk is not None
assert item.cart is not None
assert item.product is not None
@pytest.mark.django_db
def test_order_creation():
order = OrderFactory()
assert order.pk is not None
assert order.status == "pending"
What does this code do?
Each test verifies that a model can be created and persisted to the PostgreSQL test database. The @pytest.mark.django_db decorator gives the test function access to the database.
- Save cart/tests/test_models.py.
- Create cart/tests/test_api.py with endpoint tests:
import pytest
from rest_framework.test import APIClient
from .factories import UserFactory, CartFactory, CartItemFactory
from products.tests.factories import ProductFactory
@pytest.fixture
def auth_client():
"""Return an API client authenticated as a new user."""
user = UserFactory()
client = APIClient()
client.force_authenticate(user=user)
client.user = user
return client
@pytest.mark.django_db
def test_view_cart(auth_client):
response = auth_client.get("/api/cart/my_cart/")
assert response.status_code == 200
@pytest.mark.django_db
def test_add_item_to_cart(auth_client):
product = ProductFactory()
response = auth_client.post("/api/cart/add_item/", {"product_id": product.pk})
assert response.status_code == 200
@pytest.mark.django_db
def test_create_order(auth_client):
response = auth_client.post("/api/orders/", {"total_price": "29.99"})
assert response.status_code == 201
What does this code do?
The auth_client fixture creates a user and authenticates the test client using force_authenticate. This bypasses token-based auth (which you will add in the next step) and lets you test the view logic in isolation.
Each test hits a real endpoint against the PostgreSQL test database, verifying the full request-response cycle works correctly.
- Save cart/tests/test_api.py.
- Run the full backend test suite by running:
pytest -v
- You should see all tests pass. Look for output showing 9 passed (3 product tests plus 3 cart model tests plus 3 cart API tests).
Tests failing with 403 Forbidden?
If your cart API tests return 403 instead of 200, check that auth_client calls client.force_authenticate(user=user) before making requests. Without authentication, the IsAuthenticated permission class rejects the request.
help me debug my failing cart API tests
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the complete cart/models.py:
from django.db import models
from django.conf import settings
from products.models import Product
# Shopping cart tied to a single user
class Cart(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Cart for {self.user.username}"
# Individual item inside a cart
class CartItem(models.Model):
cart = models.ForeignKey(Cart, on_delete=models.CASCADE, related_name="items")
product = models.ForeignKey(Product, on_delete=models.CASCADE)
quantity = models.PositiveIntegerField(default=1)
def __str__(self):
return f"{self.quantity}x {self.product.name}"
# Completed order snapshot
class Order(models.Model):
STATUS_CHOICES = [
("pending", "Pending"),
("completed", "Completed"),
("cancelled", "Cancelled"),
]
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="orders"
)
total_price = models.DecimalField(max_digits=10, decimal_places=2)
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="pending")
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"Order {self.pk} - {self.status}"
Here is the complete cart/serializers.py:
from rest_framework import serializers
from .models import Cart, CartItem, Order
from products.models import Product
from products.serializers import ProductSerializer
class CartItemSerializer(serializers.ModelSerializer):
# Read: return full product details
product = ProductSerializer(read_only=True)
# Write: accept a product ID to add to cart
product_id = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all(), write_only=True
)
class Meta:
model = CartItem
fields = ["id", "product", "product_id", "quantity"]
class CartSerializer(serializers.ModelSerializer):
# Nested list of items in the cart
items = CartItemSerializer(many=True, read_only=True)
class Meta:
model = Cart
fields = ["id", "items", "created_at"]
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ["id", "total_price", "status", "created_at"]
Here is the complete cart/views.py:
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from .models import Cart, CartItem, Order
from .serializers import CartSerializer, CartItemSerializer, OrderSerializer
class CartViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
def get_or_create_cart(self):
"""Get the user's cart or create one if it doesn't exist."""
cart, _ = Cart.objects.get_or_create(user=self.request.user)
return cart
@action(detail=False, methods=["get"])
def my_cart(self, request):
"""Return the current user's cart with all items."""
cart = self.get_or_create_cart()
serializer = CartSerializer(cart)
return Response(serializer.data)
@action(detail=False, methods=["post"])
def add_item(self, request):
"""Add a product to the cart or increment its quantity."""
cart = self.get_or_create_cart()
serializer = CartItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
product = serializer.validated_data["product_id"]
cart_item, created = CartItem.objects.get_or_create(
cart=cart, product=product
)
if not created:
cart_item.quantity += 1
cart_item.save()
return Response(CartSerializer(cart).data)
@action(detail=False, methods=["delete"])
def remove_item(self, request):
"""Remove an item from the cart by product ID."""
cart = self.get_or_create_cart()
product_id = request.data.get("product_id")
CartItem.objects.filter(cart=cart, product_id=product_id).delete()
return Response(CartSerializer(cart).data)
class OrderViewSet(viewsets.GenericViewSet):
permission_classes = [IsAuthenticated]
def list(self, request):
"""List all orders for the current user."""
orders = Order.objects.filter(user=request.user)
serializer = OrderSerializer(orders, many=True)
return Response(serializer.data)
def create(self, request):
"""Create an order from the current cart contents."""
serializer = OrderSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
Here is the complete cart/urls.py:
from rest_framework.routers import DefaultRouter
from .views import CartViewSet, OrderViewSet
# Register cart and order endpoints
router = DefaultRouter()
router.register(r"cart", CartViewSet, basename="cart")
router.register(r"orders", OrderViewSet, basename="orders")
urlpatterns = router.urls
Here is the complete cart/tests/factories.py:
import factory
from django.contrib.auth.models import User
from cart.models import Cart, CartItem, Order
from products.tests.factories import ProductFactory
class UserFactory(factory.django.DjangoModelFactory):
class Meta:
model = User
username = factory.Sequence(lambda n: f"user{n}")
email = factory.Faker("email")
password = factory.PostGenerationMethodCall("set_password", "password123")
class CartFactory(factory.django.DjangoModelFactory):
class Meta:
model = Cart
user = factory.SubFactory(UserFactory)
class CartItemFactory(factory.django.DjangoModelFactory):
class Meta:
model = CartItem
cart = factory.SubFactory(CartFactory)
product = factory.SubFactory(ProductFactory)
quantity = 1
class OrderFactory(factory.django.DjangoModelFactory):
class Meta:
model = Order
user = factory.SubFactory(UserFactory)
total_price = factory.Faker("pydecimal", left_digits=3, right_digits=2, positive=True)
status = "pending"
Here is the complete cart/tests/test_models.py:
import pytest
from .factories import CartFactory, CartItemFactory, OrderFactory
@pytest.mark.django_db
def test_cart_creation():
cart = CartFactory()
assert cart.pk is not None
assert cart.user is not None
@pytest.mark.django_db
def test_cart_item_creation():
item = CartItemFactory()
assert item.pk is not None
assert item.cart is not None
assert item.product is not None
@pytest.mark.django_db
def test_order_creation():
order = OrderFactory()
assert order.pk is not None
assert order.status == "pending"
Here is the complete cart/tests/test_api.py:
import pytest
from rest_framework.test import APIClient
from .factories import UserFactory, CartFactory, CartItemFactory
from products.tests.factories import ProductFactory
@pytest.fixture
def auth_client():
"""Return an API client authenticated as a new user."""
user = UserFactory()
client = APIClient()
client.force_authenticate(user=user)
client.user = user
return client
@pytest.mark.django_db
def test_view_cart(auth_client):
response = auth_client.get("/api/cart/my_cart/")
assert response.status_code == 200
@pytest.mark.django_db
def test_add_item_to_cart(auth_client):
product = ProductFactory()
response = auth_client.post("/api/cart/add_item/", {"product_id": product.pk})
assert response.status_code == 200
@pytest.mark.django_db
def test_create_order(auth_client):
response = auth_client.post("/api/orders/", {"total_price": "29.99"})
assert response.status_code == 201
Here is the updated shopstack/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path("admin/", admin.site.urls),
path("api/", include("products.urls")),
path("api/", include("cart.urls")),
]
Your backend now has a complete shopping cart and order system with full test coverage. Next up, you will secure these endpoints with JWT authentication so only logged-in users can manage their carts and place orders.
Add JWT Authentication with Tests
Your API has Product, Cart, and Order endpoints. But right now, anyone could manipulate another user's cart or place orders on their behalf.
In this step, you'll add JWT (JSON Web Token) authentication to protect the cart and order endpoints. You'll also create a registration endpoint so new users can sign up and receive tokens immediately.
In this step, get ready to:
- Install and configure djangorestframework-simplejwt for token-based authentication.
- Build a registration endpoint that returns JWT tokens on sign-up.
- Write pytest tests covering registration, login, token access, and rejection of unauthenticated requests.
Install djangorestframework-simplejwt and configure token authentication
JWT authentication works by issuing signed tokens when a user logs in. The client sends that token with every subsequent request to prove identity, without needing to store sessions server-side.
- Install the simplejwt package by running this command in your terminal (with your virtual environment active):
pip install djangorestframework-simplejwt
- Open backend/shopstack/settings.py and add rest_framework_simplejwt to INSTALLED_APPS:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'corsheaders',
'rest_framework_simplejwt',
# Local apps
'products',
'cart',
]
What does this do?
Adding rest_framework_simplejwt to INSTALLED_APPS enables the package's management commands and integrations with Django's admin.
Now configure DRF to use JWT as its default authentication method. Add the following settings below INSTALLED_APPS in the same file:
- Add the REST_FRAMEWORK and SIMPLE_JWT configuration blocks by copying this code below your existing settings:
from datetime import timedelta
# Django REST Framework configuration
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
}
# JWT token lifetime settings
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}
What does this configuration do?
DEFAULT_AUTHENTICATION_CLASSES tells DRF to check for a JWT in the Authorization: Bearer <token> header on every request.
ACCESS_TOKEN_LIFETIME controls how long a token stays valid (60 minutes here). REFRESH_TOKEN_LIFETIME controls how long a refresh token can be used to get a new access token (7 days).
Place the import at the top
The from datetime import timedelta line should go at the very top of settings.py with the other imports.
Next, wire up the token endpoints. Simplejwt provides built-in views for obtaining and refreshing tokens.
- Open backend/shopstack/urls.py and update it to include the authentication URLs:
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from cart.views import RegisterView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('products.urls')),
path('api/', include('cart.urls')),
# Authentication endpoints
path('api/auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/auth/register/', RegisterView.as_view(), name='register'),
]
What are these three auth endpoints?
- /api/auth/token/ accepts a username and password, then returns an access token and a refresh token.
- /api/auth/token/refresh/ accepts a refresh token and returns a fresh access token.
- /api/auth/register/ lets new users sign up (you will build this view next).
Seeing an ImportError for RegisterView?
You have not created RegisterView yet. That is expected. The import will resolve after the next substep. If you want to confirm settings.py is valid first, temporarily comment out the RegisterView import and its URL line.
help me fix an ImportError in my Django urls.py
Build the registration endpoint
Simplejwt handles login (token obtain) out of the box, but it does not include user registration. You need a custom view that creates a user and immediately returns JWT tokens so the frontend can authenticate right away.
- Open backend/cart/serializers.py and add the RegisterSerializer at the top of the file, above the existing serializers:
from django.contrib.auth.models import User
from rest_framework import serializers
from products.serializers import ProductSerializer
from .models import Cart, CartItem, Order
class RegisterSerializer(serializers.ModelSerializer):
"""Serializer for user registration with write-only password."""
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ('username', 'email', 'password')
def create(self, validated_data):
# Use create_user to properly hash the password
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password'],
)
return user
Why use create_user instead of create?
Django's create_user method hashes the password before saving. If you used the standard create method, the password would be stored in plain text and authentication would never succeed.
- Now open backend/cart/views.py and add the RegisterView at the top of the file, above the existing viewsets:
from rest_framework import viewsets, status
from rest_framework.generics import CreateAPIView
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from .models import Cart, CartItem, Order
from .serializers import (
RegisterSerializer,
CartSerializer,
CartItemSerializer,
OrderSerializer,
)
class RegisterView(CreateAPIView):
"""Register a new user and return JWT tokens."""
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# Generate JWT tokens for the new user
refresh = RefreshToken.for_user(user)
return Response(
{
'refresh': str(refresh),
'access': str(refresh.access_token),
},
status=status.HTTP_201_CREATED,
)
What does RegisterView do?
It accepts a POST with username, email, and password. After creating the user, it immediately generates a refresh and access token pair using RefreshToken.for_user().
This means the frontend can register and authenticate in a single request without a separate login step.
- Save both cart/serializers.py and cart/views.py.
- Verify the server starts without errors by running:
python manage.py runserver
You should see the development server start with no import errors or configuration warnings.
Getting a ModuleNotFoundError?
Make sure you installed djangorestframework-simplejwt in the same virtual environment that is running the server. Check that your virtual environment is active (you should see (venv) in your terminal prompt).
help me fix a ModuleNotFoundError for rest_framework_simplejwt
Write tests for authentication flows
Your auth system has four scenarios to cover: a new user can register, an existing user can log in, a valid token grants access to protected endpoints, and a missing token gets rejected.
- Create a new file backend/cart/tests/test_auth.py and add the following test code:
import pytest
from django.contrib.auth.models import User
from rest_framework.test import APIClient
@pytest.mark.django_db
class TestAuthentication:
"""Test suite for JWT authentication flows."""
def setup_method(self):
"""Set up a fresh API client for each test."""
self.client = APIClient()
def test_register(self):
"""A new user can register and receives JWT tokens."""
response = self.client.post(
'/api/auth/register/',
{'username': 'newuser', 'email': 'new@example.com', 'password': 'testpass123'},
)
assert response.status_code == 201
assert 'access' in response.data
assert 'refresh' in response.data
def test_login(self):
"""An existing user can log in and receives JWT tokens."""
# Create a user first
User.objects.create_user(
username='testuser', password='testpass123'
)
response = self.client.post(
'/api/auth/token/',
{'username': 'testuser', 'password': 'testpass123'},
)
assert response.status_code == 200
assert 'access' in response.data
def test_protected_endpoint_with_token(self):
"""A valid token grants access to protected endpoints."""
# Register to get a token
response = self.client.post(
'/api/auth/register/',
{'username': 'authuser', 'email': 'auth@example.com', 'password': 'testpass123'},
)
token = response.data['access']
# Access protected endpoint with the token
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get('/api/cart/my_cart/')
assert response.status_code == 200
def test_unauthenticated_request_rejected(self):
"""Requests without a token are rejected with 401."""
response = self.client.get('/api/cart/my_cart/')
assert response.status_code == 401
What do these tests cover?
- test_register verifies that POSTing to /api/auth/register/ returns a 201 with both an access and refresh token.
- test_login creates a user, then confirms that POSTing credentials to /api/auth/token/ returns a 200 with an access token.
- test_protected_endpoint_with_token registers a user, takes the access token, and confirms a protected cart endpoint responds with 200.
- test_unauthenticated_request_rejected confirms that hitting a protected endpoint without any token returns 401.
- Save test_auth.py and run the full test suite:
pytest -v
You should see all tests passing, including the four new auth tests alongside your existing model and API tests.
Auth tests failing with 401 on register?
Check that RegisterView has permission_classes = [AllowAny]. Without this, the global JWT authentication setting blocks unauthenticated users from reaching the registration endpoint.
help me debug why my register endpoint returns 401 in tests
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before continuing.
ⓧ I'd like to double check the full code
Here is the complete backend/shopstack/settings.py JWT configuration (showing only the new additions):
from datetime import timedelta
# ... existing settings above ...
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
# Third-party apps
'rest_framework',
'corsheaders',
'rest_framework_simplejwt',
# Local apps
'products',
'cart',
]
# ... MIDDLEWARE, DATABASES, CORS settings ...
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
),
}
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}
Here is the complete backend/shopstack/urls.py:
from django.contrib import admin
from django.urls import path, include
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
from cart.views import RegisterView
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('products.urls')),
path('api/', include('cart.urls')),
# Authentication endpoints
path('api/auth/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
path('api/auth/register/', RegisterView.as_view(), name='register'),
]
Here is the complete backend/cart/serializers.py:
from django.contrib.auth.models import User
from rest_framework import serializers
from products.serializers import ProductSerializer
from .models import Cart, CartItem, Order
class RegisterSerializer(serializers.ModelSerializer):
"""Serializer for user registration with write-only password."""
password = serializers.CharField(write_only=True)
class Meta:
model = User
fields = ('username', 'email', 'password')
def create(self, validated_data):
# Use create_user to properly hash the password
user = User.objects.create_user(
username=validated_data['username'],
email=validated_data.get('email', ''),
password=validated_data['password'],
)
return user
class CartItemSerializer(serializers.ModelSerializer):
"""Serializer for individual cart items with nested product data."""
product = ProductSerializer(read_only=True)
product_id = serializers.PrimaryKeyRelatedField(
queryset=Cart.objects.none(), write_only=True
)
class Meta:
model = CartItem
fields = ('id', 'product', 'product_id', 'quantity')
class CartSerializer(serializers.ModelSerializer):
"""Serializer for the cart with nested items."""
items = CartItemSerializer(many=True, read_only=True)
class Meta:
model = Cart
fields = ('id', 'items', 'created_at')
class OrderSerializer(serializers.ModelSerializer):
"""Serializer for orders."""
class Meta:
model = Order
fields = ('id', 'total_price', 'status', 'created_at')
Here is the complete backend/cart/views.py:
from rest_framework import viewsets, status
from rest_framework.generics import CreateAPIView
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
from .models import Cart, CartItem, Order
from .serializers import (
RegisterSerializer,
CartSerializer,
CartItemSerializer,
OrderSerializer,
)
class RegisterView(CreateAPIView):
"""Register a new user and return JWT tokens."""
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = serializer.save()
# Generate JWT tokens for the new user
refresh = RefreshToken.for_user(user)
return Response(
{
'refresh': str(refresh),
'access': str(refresh.access_token),
},
status=status.HTTP_201_CREATED,
)
class CartViewSet(viewsets.GenericViewSet):
"""ViewSet for cart operations (requires authentication)."""
permission_classes = [IsAuthenticated]
serializer_class = CartSerializer
def get_or_create_cart(self):
"""Get or create a cart for the current user."""
cart, _ = Cart.objects.get_or_create(user=self.request.user)
return cart
@action(detail=False, methods=['get'])
def my_cart(self, request):
"""Retrieve the current user's cart."""
cart = self.get_or_create_cart()
serializer = self.get_serializer(cart)
return Response(serializer.data)
@action(detail=False, methods=['post'])
def add_item(self, request):
"""Add an item to the current user's cart."""
cart = self.get_or_create_cart()
serializer = CartItemSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(cart=cart)
return Response(CartSerializer(cart).data)
@action(detail=False, methods=['delete'])
def remove_item(self, request):
"""Remove an item from the current user's cart."""
cart = self.get_or_create_cart()
item_id = request.data.get('item_id')
CartItem.objects.filter(cart=cart, id=item_id).delete()
return Response(CartSerializer(cart).data)
class OrderViewSet(viewsets.GenericViewSet):
"""ViewSet for order operations (requires authentication)."""
permission_classes = [IsAuthenticated]
serializer_class = OrderSerializer
def list(self, request):
"""List the current user's orders."""
orders = Order.objects.filter(user=request.user)
serializer = self.get_serializer(orders, many=True)
return Response(serializer.data)
def create(self, request):
"""Create a new order for the current user."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED)
Here is the complete backend/cart/tests/test_auth.py:
import pytest
from django.contrib.auth.models import User
from rest_framework.test import APIClient
@pytest.mark.django_db
class TestAuthentication:
"""Test suite for JWT authentication flows."""
def setup_method(self):
"""Set up a fresh API client for each test."""
self.client = APIClient()
def test_register(self):
"""A new user can register and receives JWT tokens."""
response = self.client.post(
'/api/auth/register/',
{'username': 'newuser', 'email': 'new@example.com', 'password': 'testpass123'},
)
assert response.status_code == 201
assert 'access' in response.data
assert 'refresh' in response.data
def test_login(self):
"""An existing user can log in and receives JWT tokens."""
# Create a user first
User.objects.create_user(
username='testuser', password='testpass123'
)
response = self.client.post(
'/api/auth/token/',
{'username': 'testuser', 'password': 'testpass123'},
)
assert response.status_code == 200
assert 'access' in response.data
def test_protected_endpoint_with_token(self):
"""A valid token grants access to protected endpoints."""
# Register to get a token
response = self.client.post(
'/api/auth/register/',
{'username': 'authuser', 'email': 'auth@example.com', 'password': 'testpass123'},
)
token = response.data['access']
# Access protected endpoint with the token
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {token}')
response = self.client.get('/api/cart/my_cart/')
assert response.status_code == 200
def test_unauthenticated_request_rejected(self):
"""Requests without a token are rejected with 401."""
response = self.client.get('/api/cart/my_cart/')
assert response.status_code == 401
What about the existing cart tests?
Your existing tests in test_api.py use force_authenticate which bypasses the authentication backend entirely. They continue to pass because DRF's force_authenticate overrides any authentication class, including JWT.
Your API is now secured. Products remain publicly accessible while cart and order endpoints require a valid JWT. Next up, you'll build the React frontend that connects to this API.
Set Up the React Frontend with Vite
Your Django backend is built, tested, and ready to serve data. Now you need a frontend that customers can actually interact with to browse products, manage their cart, and place orders.
You'll use React for the UI and Vite as the build tool. Vite's dev server gives you instant hot-reload while coding, and its native test runner (Vitest) shares the same configuration so there's zero extra setup overhead.
In this step, get ready to:
- Scaffold a React project with Vite and install dependencies.
- Configure Vitest and React Testing Library for component testing.
- Create the project directory structure and verify the dev server runs.
Scaffold a React project with Vite
Vite requires Node.js 22.12 or higher. Let's confirm you have Node.js 22 LTS installed before scaffolding the frontend.
- Check your Node.js version by running this command:
node --version
✔️ I see version 22 or higher
You're all set. Move on to scaffolding the React project below.
ⓧ I see an older version
Vite 8 requires Node.js 22.12 or higher. Your current version won't work. If you use nvm, upgrade by running:
nvm install 22
nvm use 22
If you don't use nvm, download the Node.js 22 LTS installer from nodejs.org/en/download and run it. After installing, close and reopen your terminal, then run node --version again to confirm you see v22 or higher.
ⓧ Command not found
Node.js is not installed. Download the Node.js 22 LTS installer from nodejs.org/en/download and run it.
After the installer finishes, close and reopen your terminal. Run node --version again to confirm you see v22 or higher.
Now you'll scaffold a new React project inside a frontend/ directory that sits alongside your existing backend/ directory.
- Navigate to the folder that contains your backend/ directory (one level up from backend/) by running:
cd ..
- Scaffold the React project by running this command:
npm create vite@latest frontend -- --template react
What does this command do?
npm create vite@latest runs the Vite project scaffolder. frontend is the directory name it creates. --template react tells Vite to use the React starter template with JSX support preconfigured.
- Move into the new frontend directory by running:
cd frontend
- Install the scaffolded project's base dependencies by running:
npm install
- Install axios (for API calls) and react-router-dom (for page navigation) by running:
npm install axios react-router-dom
- Confirm both packages appear in your package.json by running:
cat package.json
You should see axios and react-router-dom listed under dependencies.
Seeing 'npm: command not found'?
This means Node.js is not in your terminal's PATH. Close and reopen your terminal after installing Node.js. If you installed via nvm, run nvm use 22 first.
Help me troubleshoot my npm installation
Configure Vitest and React Testing Library
Vitest is Vite's native test runner. It shares the same config file and transformation pipeline, so your tests run with zero extra build configuration.
- Install the testing dependencies as dev dependencies by running:
npm install --save-dev vitest @vitest/ui jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
What are these packages?
- vitest is the test runner that integrates directly with Vite.
- @vitest/ui provides a browser-based dashboard for viewing test results.
- jsdom simulates a browser DOM so components can render in Node.js.
- @testing-library/react provides utilities for rendering and querying React components.
- @testing-library/jest-dom adds custom matchers like toBeInTheDocument().
- @testing-library/user-event simulates real user interactions (clicks, typing).
Next, you'll update the Vite config to tell Vitest how to run your tests and add a proxy so the frontend can reach your Django backend.
- Open frontend/vite.config.js in your editor. Replace its entire contents with this:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Vite config: plugins, dev server proxy, and test settings
export default defineConfig({
plugins: [react()],
server: {
// Forward /api requests to the Django backend during development
proxy: {
'/api': 'http://127.0.0.1:8000'
}
},
test: {
// Use jsdom to simulate a browser environment in tests
globals: true,
environment: 'jsdom',
// Load custom matchers before each test file
setupFiles: './src/setupTests.js'
}
})
What does this config do?
- plugins: [react()] enables JSX transformation so React components compile correctly.
- server.proxy forwards any request starting with /api to your Django server at http://127.0.0.1:8000. This avoids CORS issues during local development.
- test.globals: true makes describe, it, and expect available without importing them in every test file.
- test.setupFiles runs the specified file before each test suite, loading the custom DOM matchers.
- Create the test setup file by running:
echo "import '@testing-library/jest-dom'" > src/setupTests.js
What does setupTests.js do?
This single import adds custom DOM matchers (like toBeInTheDocument() and toHaveTextContent()) to every test file automatically. Without it, you'd need to import these matchers in each test individually.
Finally, add a test script to your package.json so you can run tests with a simple command.
- In frontend/package.json, find the scripts section. It currently looks like this:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
}
- Add a test entry after preview, so the section looks like this:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest"
}
- Save package.json and vite.config.js.
Seeing a JSON syntax error?
Make sure you added a comma after the "preview": "vite preview" line. JSON requires commas between all entries except the last one.
Help me fix my package.json syntax
✔️ Awesome, I've got everything!
Great. Make sure you've saved both files before moving on.
ⓧ I'd like to double check the full code
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Vite config: plugins, dev server proxy, and test settings
export default defineConfig({
plugins: [react()],
server: {
// Forward /api requests to the Django backend during development
proxy: {
'/api': 'http://127.0.0.1:8000'
}
},
test: {
// Use jsdom to simulate a browser environment in tests
globals: true,
environment: 'jsdom',
// Load custom matchers before each test file
setupFiles: './src/setupTests.js'
}
})
import '@testing-library/jest-dom'
Create the project structure and verify the dev server
A clean directory structure keeps your components, pages, and API service files separated. This makes the codebase easier to navigate as it grows.
- Create the three directories for your frontend code by running:
mkdir src/components src/pages src/services
What goes in each directory?
- src/components/ holds reusable UI pieces like ProductCard and CartSidebar.
- src/pages/ holds full page layouts that map to routes (product listing, checkout).
- src/services/ holds API call functions that talk to your Django backend.
- Confirm the directories exist by running:
ls src
You should see components, pages, and services listed alongside the existing files like App.jsx and main.jsx.
- Start the Vite dev server to verify everything works by running:
npm run dev
- Open http://localhost:5173 in your browser.
You should see the default Vite + React welcome page with the Vite and React logos and a counter button.
Dev server not starting?
Make sure you're inside the frontend/ directory when you run npm run dev. If port 5173 is already in use, Vite will try the next available port and show it in the terminal output.
Help me troubleshoot my Vite dev server
- Press Ctrl+C in the terminal to stop the dev server.
Your React frontend is scaffolded, configured for testing, and connected to your Django backend via the proxy. Next up, you'll build the product listing page and write your first component tests.
Build the Product Listing with Tests
Your React project is scaffolded and ready to display real data. The Django backend already serves product data at /api/products/. Now you need components that fetch and render that data.
In this step, you will build an API service layer, a reusable ProductCard component, and a ProductGrid page that ties them together. You will also write Vitest tests to verify ProductCard renders correctly before connecting to live data.
In this step, get ready to:
- Create a ProductCard component and a ProductGrid page that fetches from the Django API.
- Write Vitest tests for ProductCard using mock data.
- Wire up App.jsx and verify products display in the browser.
Create the API service and ProductCard component
The frontend needs a centralized place to make HTTP requests to the backend. An Axios instance configured with a base URL keeps all API calls consistent and easy to update later.
- Create the API service file by opening frontend/src/services/api.js in VS Code and adding the following code:
import axios from 'axios'
// Create an Axios instance with the base URL for all API requests
const api = axios.create({
baseURL: '/api'
})
// Fetch all products from the backend
export const getProducts = () => api.get('/products/').then(res => res.data)
export default api
What does this code do?
The axios.create() call creates a reusable HTTP client with baseURL: '/api'. Every request made through this instance automatically prepends /api to the path.
The getProducts() function calls /api/products/ and unwraps the Axios response to return just the data array. The Vite proxy you configured in the previous step forwards /api requests to your Django server at http://127.0.0.1:8000.
- Save frontend/src/services/api.js.
Next, you need a component that displays a single product's details. This component accepts a product object and an add-to-cart callback as props.
- Create the ProductCard component by opening frontend/src/components/ProductCard.jsx and adding the following code:
// ProductCard displays a single product's details
function ProductCard({ product, onAddToCart }) {
return (
<div className="product-card">
{product.image_url && <img src={product.image_url} alt={product.name} />}
<h3>{product.name}</h3>
<p>{product.category}</p>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product)}>Add to Cart</button>
</div>
)
}
export default ProductCard
What does this code do?
ProductCard is a presentational component. It receives a product prop (with fields like name, price, category) and an onAddToCart callback prop.
When the user clicks the button, onAddToCart(product) fires, passing the full product object up to the parent. The conditional {product.image_url && ...} only renders the image tag if a URL exists.
- Save frontend/src/components/ProductCard.jsx.
Now create the page component that fetches products and renders a grid of ProductCards.
- Create the ProductGrid page by opening frontend/src/pages/ProductGrid.jsx and adding the following code:
import { useState, useEffect } from 'react'
import { getProducts } from '../services/api'
import ProductCard from '../components/ProductCard'
// ProductGrid fetches and displays all products in a grid layout
function ProductGrid({ onAddToCart }) {
const [products, setProducts] = useState([])
useEffect(() => {
// Fetch products from the API when the component mounts
getProducts().then(data => setProducts(data))
}, [])
return (
<div className="product-grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
)
}
export default ProductGrid
What does this code do?
ProductGrid uses useState to hold the products array. The useEffect hook calls getProducts() once on mount (the empty dependency array [] ensures it runs only once).
Each product in the array gets rendered as a ProductCard. The key={product.id} prop helps React efficiently update the list when data changes. The onAddToCart prop is passed through from the parent to each card.
- Save frontend/src/pages/ProductGrid.jsx.
Seeing import errors in your editor?
If VS Code shows red underlines on the import paths, make sure you saved api.js before opening ProductGrid.jsx. The editor resolves imports from files on disk.
Help me debug import errors in my React project
Write Vitest tests for ProductCard
Testing components in isolation catches bugs before they reach the browser. You will use React Testing Library to render ProductCard with mock data and verify it outputs the correct elements.
- Create the test file by opening frontend/src/components/__tests__/ProductCard.test.jsx in VS Code and adding the following code:
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductCard from '../ProductCard'
// Mock product data matching the Django model fields
const mockProduct = {
id: 1,
name: 'Test Product',
price: 29.99,
category: 'Electronics',
stock: 10,
image_url: ''
}
describe('ProductCard', () => {
it('renders product name', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('Test Product')).toBeInTheDocument()
})
it('renders product price', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('$29.99')).toBeInTheDocument()
})
it('renders Add to Cart button', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('Add to Cart')).toBeInTheDocument()
})
it('calls onAddToCart when button clicked', async () => {
const mockFn = vi.fn()
render(<ProductCard product={mockProduct} onAddToCart={mockFn} />)
await userEvent.click(screen.getByText('Add to Cart'))
expect(mockFn).toHaveBeenCalledWith(mockProduct)
})
})
What do these tests verify?
- The first three tests render ProductCard with mockProduct and check that the name, price, and button text appear in the DOM.
- The fourth test uses vi.fn() to create a mock callback, simulates a button click with userEvent.click(), and asserts the callback was called with the full product object.
- Because Vitest is configured with globals: true in your Vite config, vi, describe, it, and expect are available without imports.
- Save frontend/src/components/__tests__/ProductCard.test.jsx.
- Run the test suite by running this command from the frontend directory:
npm test
- You should see all 4 tests pass with green checkmarks in the terminal output.
Tests failing?
If you see an error about missing @testing-library/jest-dom matchers, make sure frontend/src/setupTests.js contains the import and that your vite.config.js has setupFiles: './src/setupTests.js' in the test config.
If the vi.fn() test fails, confirm your Vite config includes globals: true under the test key. Without globals enabled, you would need to import vi from vitest explicitly.
Help me debug my failing Vitest tests
Wire up App.jsx and verify products display
The components exist but nothing renders them yet. You need to update App.jsx to be the entry point that renders ProductGrid and handles the add-to-cart action.
- Replace the contents of frontend/src/App.jsx with the following code:
import ProductGrid from './pages/ProductGrid'
// Main App component that renders the storefront
function App() {
const handleAddToCart = (product) => {
console.log('Added to cart:', product)
}
return (
<div>
<h1>ShopStack</h1>
<ProductGrid onAddToCart={handleAddToCart} />
</div>
)
}
export default App
What does this code do?
App is the top-level component. It defines a handleAddToCart function that logs the product to the console for now. In the next step, you will replace this with real cart logic.
ProductGrid receives handleAddToCart as the onAddToCart prop, which flows down to each ProductCard button.
- Save frontend/src/App.jsx.
- Make sure your Django backend is running in one terminal (python manage.py runserver from the backend directory).
- Start the Vite dev server in a separate terminal from the frontend directory by running:
npm run dev
- Open http://localhost:5173 in your browser.
- You should see the heading "ShopStack" and a list of products from your database displayed as cards with names, categories, and prices.
No products showing up?
If the page loads but no products appear, make sure you have products in your PostgreSQL database. You can add them through the DRF browsable API at http://127.0.0.1:8000/api/products/ or the Django admin.
Seeing a network error in the console?
Open the browser DevTools console. If you see a 404 or connection refused error, confirm the Django server is running on port 8000 and the Vite proxy in vite.config.js is set to forward /api to http://127.0.0.1:8000.
Help me debug my proxy configuration
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file created in this step.
import axios from 'axios'
// Create an Axios instance with the base URL for all API requests
const api = axios.create({
baseURL: '/api'
})
// Fetch all products from the backend
export const getProducts = () => api.get('/products/').then(res => res.data)
export default api
// ProductCard displays a single product's details
function ProductCard({ product, onAddToCart }) {
return (
<div className="product-card">
{product.image_url && <img src={product.image_url} alt={product.name} />}
<h3>{product.name}</h3>
<p>{product.category}</p>
<p>${product.price}</p>
<button onClick={() => onAddToCart(product)}>Add to Cart</button>
</div>
)
}
export default ProductCard
import { useState, useEffect } from 'react'
import { getProducts } from '../services/api'
import ProductCard from '../components/ProductCard'
// ProductGrid fetches and displays all products in a grid layout
function ProductGrid({ onAddToCart }) {
const [products, setProducts] = useState([])
useEffect(() => {
// Fetch products from the API when the component mounts
getProducts().then(data => setProducts(data))
}, [])
return (
<div className="product-grid">
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={onAddToCart}
/>
))}
</div>
)
}
export default ProductGrid
import ProductGrid from './pages/ProductGrid'
// Main App component that renders the storefront
function App() {
const handleAddToCart = (product) => {
console.log('Added to cart:', product)
}
return (
<div>
<h1>ShopStack</h1>
<ProductGrid onAddToCart={handleAddToCart} />
</div>
)
}
export default App
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import ProductCard from '../ProductCard'
// Mock product data matching the Django model fields
const mockProduct = {
id: 1,
name: 'Test Product',
price: 29.99,
category: 'Electronics',
stock: 10,
image_url: ''
}
describe('ProductCard', () => {
it('renders product name', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('Test Product')).toBeInTheDocument()
})
it('renders product price', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('$29.99')).toBeInTheDocument()
})
it('renders Add to Cart button', () => {
render(<ProductCard product={mockProduct} onAddToCart={() => {}} />)
expect(screen.getByText('Add to Cart')).toBeInTheDocument()
})
it('calls onAddToCart when button clicked', async () => {
const mockFn = vi.fn()
render(<ProductCard product={mockProduct} onAddToCart={mockFn} />)
await userEvent.click(screen.getByText('Add to Cart'))
expect(mockFn).toHaveBeenCalledWith(mockProduct)
})
})
Your storefront now displays real products from the database and has tested components backing it up. Next, you will build the cart and checkout flow so users can actually buy things.
Build the Cart and Checkout with Tests
Your product listing is live, but a store without a cart is just a gallery. In this step, you'll build the shopping cart experience from context to checkout.
You'll create a React Context for shared cart state, a sidebar that shows what's in the cart, and a checkout page that places orders. Then you'll test the whole flow using MSW (Mock Service Worker) to intercept API calls without needing a live backend.
In this step, get ready to:
- Create a CartContext, CartSidebar component, and Checkout page.
- Write integration tests using MSW to mock API responses.
- Connect the frontend to the live Django cart API and verify the full user flow.
Create a CartContext, CartSidebar, and Checkout page
Before building UI components, you need the API functions they'll call. Your api.js file currently only fetches products. You'll extend it with cart and order endpoints.
- In frontend/src/services/api.js, find the existing getProducts function.
- Add the following functions below it, before the export default api line:
// Fetch the current user's cart
export function getCart() {
return api.get('/cart/my_cart/').then((res) => res.data);
}
// Add a product to the cart
export function addToCart(productId, quantity = 1) {
return api.post('/cart/add_item/', { product_id: productId, quantity }).then((res) => res.data);
}
// Create an order from the current cart
export function createOrder() {
return api.post('/orders/').then((res) => res.data);
}
// Log in and receive a JWT token pair
export function login(username, password) {
return api.post('/auth/token/', { username, password }).then((res) => res.data);
}
What do these functions do?
- getCart() fetches the authenticated user's cart contents from the Django API.
- addToCart() sends a product ID and quantity to the backend to add an item.
- createOrder() converts the current cart into a confirmed order.
- login() authenticates a user and returns JWT tokens for protected endpoints.
- Save api.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file.
ⓧ I'd like to double check the full code
import axios from 'axios';
// Axios instance pointed at our Django API
const api = axios.create({
baseURL: '/api',
});
// Fetch all products
export function getProducts() {
return api.get('/products/').then((res) => res.data);
}
// Fetch the current user's cart
export function getCart() {
return api.get('/cart/my_cart/').then((res) => res.data);
}
// Add a product to the cart
export function addToCart(productId, quantity = 1) {
return api.post('/cart/add_item/', { product_id: productId, quantity }).then((res) => res.data);
}
// Create an order from the current cart
export function createOrder() {
return api.post('/orders/').then((res) => res.data);
}
// Log in and receive a JWT token pair
export function login(username, password) {
return api.post('/auth/token/', { username, password }).then((res) => res.data);
}
export default api;
Now you need a way to share cart state across multiple components. React Context lets any component read and update the cart without passing props through every layer.
- Create a new folder frontend/src/context/.
- Create a file called CartContext.jsx inside it and paste the following code:
import { createContext, useContext, useState, useMemo } from 'react';
// Context for sharing cart state across components
const CartContext = createContext();
export function CartProvider({ children }) {
const [cartItems, setCartItems] = useState([]);
// Add a product or increment its quantity if already in cart
function addItem(product) {
setCartItems((prev) => {
const existing = prev.find((item) => item.id === product.id);
if (existing) {
return prev.map((item) =>
item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
);
}
return [...prev, { ...product, quantity: 1 }];
});
}
// Remove a product from the cart entirely
function removeItem(productId) {
setCartItems((prev) => prev.filter((item) => item.id !== productId));
}
// Empty the cart after checkout
function clearCart() {
setCartItems([]);
}
// Compute the cart total from item prices and quantities
const total = useMemo(
() => cartItems.reduce((sum, item) => sum + item.price * item.quantity, 0).toFixed(2),
[cartItems]
);
return (
<CartContext.Provider value={{ cartItems, addItem, removeItem, clearCart, total }}>
{children}
</CartContext.Provider>
);
}
// Custom hook for consuming cart context
export function useCart() {
return useContext(CartContext);
}
What does this code do?
CartProvider holds an array of cart items in state and exposes four operations: addItem, removeItem, clearCart, and a computed total.
addItem checks if the product already exists in the cart. If yes, it increments the quantity. If not, it adds the product with quantity 1.
The useCart hook is a shortcut so components can access cart state without importing CartContext and useContext separately.
- Save CartContext.jsx.
Seeing an import error?
Make sure you created the context folder inside frontend/src/, not at the project root. The file path should be frontend/src/context/CartContext.jsx.
help me debug a CartContext import error
With cart state managed, you can now build the sidebar that displays it.
- Create frontend/src/components/CartSidebar.jsx and paste the following code:
import { useCart } from '../context/CartContext';
// Sidebar displaying current cart contents and total
function CartSidebar({ onCheckout }) {
const { cartItems, removeItem, total } = useCart();
return (
<div className="cart-sidebar">
<h2>Cart</h2>
{cartItems.length === 0 ? (
<p>Your cart is empty.</p>
) : (
<ul>
{cartItems.map((item) => (
<li key={item.id}>
{item.name} x {item.quantity} - ${(item.price * item.quantity).toFixed(2)}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
)}
<p>Total: ${total}</p>
<button onClick={onCheckout} disabled={cartItems.length === 0}>
Checkout
</button>
</div>
);
}
export default CartSidebar;
What does this code do?
CartSidebar pulls cartItems, removeItem, and total from the cart context via the useCart hook.
When the cart is empty, it shows a message. Otherwise it renders each item with a Remove button. The Checkout button is disabled until the cart has at least one item.
- Save CartSidebar.jsx.
The final component is the checkout page where users confirm their order.
- Create frontend/src/pages/Checkout.jsx and paste the following code:
import { useState } from 'react';
import { useCart } from '../context/CartContext';
import { createOrder } from '../services/api';
// Checkout page that submits the order and clears the cart
function Checkout() {
const { cartItems, total, clearCart } = useCart();
const [orderPlaced, setOrderPlaced] = useState(false);
// Submit the order to the backend, then reset local state
async function handlePlaceOrder() {
await createOrder();
clearCart();
setOrderPlaced(true);
}
if (orderPlaced) {
return <p>Order placed successfully!</p>;
}
return (
<div>
<h2>Checkout</h2>
<ul>
{cartItems.map((item) => (
<li key={item.id}>
{item.name} x {item.quantity}
</li>
))}
</ul>
<p>Total: ${total}</p>
<button onClick={handlePlaceOrder}>Place Order</button>
</div>
);
}
export default Checkout;
What does this code do?
handlePlaceOrder calls the createOrder() API function, clears the local cart state, and flips orderPlaced to true.
Once the order is placed, the component switches from showing the order summary to displaying a success message.
- Save Checkout.jsx.
Seeing a missing module error?
Check that your import path for CartContext uses the correct relative path. From pages/Checkout.jsx, the path is ../context/CartContext.
help me fix my Checkout import paths
Write integration tests using MSW
Your components call API functions, but you don't want tests to depend on a running Django server. MSW intercepts network requests at the process level and returns mock responses.
This means your tests can verify the full flow (component renders, user clicks, API responds, UI updates) without touching a real backend.
- Install MSW as a dev dependency by running this command from your frontend folder:
npm i msw --save-dev
- Confirm the install succeeded by checking that msw appears in your package.json under devDependencies.
Install failing?
Make sure you're running the command from the frontend/ directory, not the project root. Run cd frontend first if needed.
help me fix my npm install for msw
Now you'll create mock handlers that define how your fake API responds during tests.
- Create a new folder frontend/src/mocks/.
- Create frontend/src/mocks/handlers.js and paste the following code:
import { http, HttpResponse } from 'msw';
// Mock API handlers for testing without a live backend
export const handlers = [
http.get('/api/cart/my_cart/', () => {
return HttpResponse.json({ id: 1, items: [] });
}),
http.post('/api/cart/add_item/', () => {
return HttpResponse.json({ id: 1, items: [{ id: 1, product: 1, quantity: 1 }] });
}),
http.post('/api/orders/', () => {
return HttpResponse.json({ id: 1, status: 'created' }, { status: 201 });
}),
];
- Create frontend/src/mocks/server.js and paste the following code:
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// MSW server for intercepting API calls in tests
export const server = setupServer(...handlers);
What do these files do?
handlers.js defines mock responses for each API endpoint your frontend calls. The http.get and http.post functions from MSW intercept matching requests and return the JSON you specify.
server.js creates a setupServer instance that activates these handlers in Node.js (where Vitest runs). Your test files will call server.listen() to start intercepting.
- Save both files.
With mock handlers ready, you can write tests for CartSidebar.
- Create frontend/src/components/__tests__/CartSidebar.test.jsx and paste the following code:
import { render, screen } from '@testing-library/react';
import { CartProvider } from '../../context/CartContext';
import CartSidebar from '../CartSidebar';
// Wrap CartSidebar in its required provider
function renderCartSidebar() {
render(
<CartProvider>
<CartSidebar onCheckout={() => {}} />
</CartProvider>
);
}
describe('CartSidebar', () => {
it('shows empty cart message when no items', () => {
renderCartSidebar();
expect(screen.getByText('Your cart is empty.')).toBeInTheDocument();
});
it('displays the total', () => {
renderCartSidebar();
expect(screen.getByText('Total: $0.00')).toBeInTheDocument();
});
});
What does this test do?
CartSidebar requires a CartProvider ancestor because it calls useCart(). The renderCartSidebar helper wraps the component in the provider so each test starts with a fresh, empty cart.
The first test verifies the empty state message appears. The second confirms the total renders as $0.00 when no items exist.
- Save CartSidebar.test.jsx.
Next, write the Checkout test that uses the MSW server to mock the order creation endpoint.
- Create frontend/src/pages/__tests__/Checkout.test.jsx and paste the following code:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CartProvider } from '../../context/CartContext';
import Checkout from '../Checkout';
import { server } from '../../mocks/server';
// Start MSW server before tests run
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe('Checkout', () => {
it('renders checkout heading', () => {
render(
<CartProvider>
<Checkout />
</CartProvider>
);
expect(screen.getByText('Checkout')).toBeInTheDocument();
});
it('shows success message after placing order', async () => {
render(
<CartProvider>
<Checkout />
</CartProvider>
);
const button = screen.getByText('Place Order');
await userEvent.click(button);
expect(await screen.findByText('Order placed successfully!')).toBeInTheDocument();
});
});
What does this test do?
The beforeAll block starts the MSW server, which intercepts any POST /api/orders/ request and returns mock data. This means clicking Place Order triggers a real-looking async flow without a Django server.
The second test uses findByText (with await) because the success message appears asynchronously after the mock API responds.
- Save Checkout.test.jsx.
- Run the full test suite by running this command from the frontend folder:
npm test
- You should see all tests passing, including your new CartSidebar and Checkout tests.
Tests failing?
If you see an error about msw/node not being found, confirm that msw is listed in your package.json devDependencies. Try deleting node_modules and running npm install again.
If tests fail because of missing context, make sure every test that renders CartSidebar or Checkout wraps the component in <CartProvider>.
help me debug my failing Vitest tests for CartSidebar or Checkout
Connect to the live Django API and verify the full flow
Your components and tests work in isolation. Now you'll wire everything together in App.jsx so the real application uses CartProvider, shows the sidebar, and navigates to checkout.
- Open frontend/src/App.jsx and replace its entire contents with the following code:
import { useState } from 'react';
import { CartProvider, useCart } from './context/CartContext';
import ProductGrid from './pages/ProductGrid';
import CartSidebar from './components/CartSidebar';
import Checkout from './pages/Checkout';
// Inner component that consumes cart context
function ShopApp() {
const { addItem } = useCart();
const [showCheckout, setShowCheckout] = useState(false);
return (
<div>
<h1>ShopStack</h1>
{showCheckout ? (
<Checkout />
) : (
<ProductGrid onAddToCart={addItem} />
)}
<CartSidebar onCheckout={() => setShowCheckout(true)} />
</div>
);
}
// App wraps the entire store in CartProvider for shared state
function App() {
return (
<CartProvider>
<ShopApp />
</CartProvider>
);
}
export default App;
What does this code do?
App wraps everything in CartProvider so all child components share the same cart state. The actual rendering logic lives in ShopApp, which needs to be inside the provider to use useCart.
When showCheckout is false, the product grid displays. Clicking Checkout in the CartSidebar flips it to true, which swaps the grid for the Checkout page.
- Save App.jsx.
- Make sure your Django backend is still running at http://127.0.0.1:8000/.
- Start the Vite dev server (if not already running) from the frontend folder by running:
npm run dev
- Open your browser to the URL shown by Vite (typically http://localhost:5173).
- You should see the ShopStack heading and your product grid. Click an Add to Cart button and watch the CartSidebar update with the item.
- Click Checkout in the sidebar to see the checkout page with your order summary.
Cart not updating when you click Add to Cart?
Check that App.jsx passes addItem (from useCart) as the onAddToCart prop to ProductGrid. The old version used a console.log handler. Make sure you replaced the full file.
Also confirm that ShopApp is inside CartProvider. If useCart is called outside the provider, it returns undefined.
help me debug my cart not updating in the browser
✔️ Awesome, I've got everything!
Great. Double check you've saved all files.
ⓧ I'd like to double check the full code
import { useState } from 'react';
import { CartProvider, useCart } from './context/CartContext';
import ProductGrid from './pages/ProductGrid';
import CartSidebar from './components/CartSidebar';
import Checkout from './pages/Checkout';
// Inner component that consumes cart context
function ShopApp() {
const { addItem } = useCart();
const [showCheckout, setShowCheckout] = useState(false);
return (
<div>
<h1>ShopStack</h1>
{showCheckout ? (
<Checkout />
) : (
<ProductGrid onAddToCart={addItem} />
)}
<CartSidebar onCheckout={() => setShowCheckout(true)} />
</div>
);
}
// App wraps the entire store in CartProvider for shared state
function App() {
return (
<CartProvider>
<ShopApp />
</CartProvider>
);
}
export default App;
Your cart and checkout flow is fully functional. You can add items, see them in the sidebar, and place orders. Next up, you'll containerize the entire stack with Docker.
Containerize with Docker Compose
Your Django API and React frontend both work locally, but they depend on a specific set of tools and configurations on your machine. Containerization packages each service into a self-contained unit that runs identically everywhere.
In this step, you will write Dockerfiles for both the backend and frontend, then orchestrate them alongside PostgreSQL using Docker Compose. The result is a single command that spins up your entire application in production mode.
In this step, get ready to:
- Write Dockerfiles for the Django backend and React frontend.
- Create a Docker Compose configuration that runs all three services together.
- Build and run the containers, verifying the full application in production mode.
Write the backend and frontend Dockerfiles
Before writing any Dockerfiles, you need Docker installed on your machine. Docker Desktop includes both the Docker engine and the built-in Compose plugin.
- Check whether Docker is installed by running this command:
docker compose version
✔️ I see a version number
Docker and Compose are ready. Continue to the next instruction below.
ⓧ Command not found
You need to install Docker Desktop, which includes Docker Engine and the Compose plugin.
- Download Docker Desktop from the official install page.
- Run the installer and follow the on-screen prompts.
- Once installed, open Docker Desktop and wait for the engine to start (you will see a green status indicator).
- Run docker compose version again to confirm it works.
The backend needs a production-grade WSGI server to replace Django's development server. Gunicorn is the standard choice for serving Django applications in containers.
- Open backend/requirements.txt and add gunicorn to the list of dependencies so the full file looks like this:
Django==5.2
djangorestframework==3.17
psycopg[binary]
dj-database-url
django-cors-headers
djangorestframework-simplejwt
gunicorn
pytest
pytest-django
factory-boy
What does this change do?
Adding gunicorn ensures the Docker image installs a production server. Django's built-in runserver is not designed for production traffic.
- Save backend/requirements.txt.
Now create the Dockerfile that packages the Django application into a container image.
- Create a new file called Dockerfile inside your backend/ folder, then paste the following code:
FROM python:3.12.3-slim
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source
COPY . .
# Collect static files for production
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "shopstack.wsgi:application", "--bind", "0.0.0.0:8000"]
What does this Dockerfile do?
- FROM python:3.12.3-slim starts from a lightweight Python image matching your project version.
- COPY requirements.txt and pip install install dependencies in a cached layer so rebuilds are fast.
- collectstatic gathers Django's static assets so they can be served without the development server.
- The CMD launches Gunicorn on port 8000, binding to all interfaces so Docker can forward traffic.
- Save backend/Dockerfile.
The frontend needs an Nginx configuration file so the container knows how to serve the built React app and proxy API requests back to Django.
- Create a new file called nginx.conf inside your frontend/ folder, then paste this configuration:
server {
listen 80;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
What does this configuration do?
- location /api/ forwards any request starting with /api/ to the Django backend container using its Docker network name backend.
- location / serves the built React files. The try_files directive falls back to index.html for client-side routing.
- Save frontend/nginx.conf.
Now create the frontend Dockerfile using a multi-stage build. The first stage builds the Vite app, and the second stage serves the output with Nginx.
- Create a new file called Dockerfile inside your frontend/ folder, then paste the following code:
# Stage 1: Build the React application
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Build the application
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built files from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
What does this Dockerfile do?
- The build stage uses node:22-alpine to install dependencies with npm ci and compile the Vite app into static files.
- The production stage copies only the built dist/ folder into a tiny nginx:alpine image. Node.js and node_modules are discarded, keeping the final image small.
- The custom nginx.conf replaces Nginx's default so API proxying and client-side routing work correctly.
- Save frontend/Dockerfile.
- Confirm all four new files exist by running:
ls backend/Dockerfile backend/requirements.txt frontend/Dockerfile frontend/nginx.conf
You should see all four file paths listed in the output with no errors.
Don't see one of the files?
Make sure you created each file in the correct folder. Dockerfile has no extension. nginx.conf lives inside frontend/, not at the project root.
help me figure out why my Dockerfile or nginx.conf is missing
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files before moving on.
ⓧ I'd like to double check the full code
Here is backend/requirements.txt:
Django==5.2
djangorestframework==3.17
psycopg[binary]
dj-database-url
django-cors-headers
djangorestframework-simplejwt
gunicorn
pytest
pytest-django
factory-boy
Here is backend/Dockerfile:
FROM python:3.12.3-slim
WORKDIR /app
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application source
COPY . .
# Collect static files for production
RUN python manage.py collectstatic --noinput
EXPOSE 8000
CMD ["gunicorn", "shopstack.wsgi:application", "--bind", "0.0.0.0:8000"]
Here is frontend/nginx.conf:
server {
listen 80;
location /api/ {
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
Here is frontend/Dockerfile:
# Stage 1: Build the React application
FROM node:22-alpine AS build
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Build the application
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
# Copy built files from the build stage
COPY --from=build /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
Create the Docker Compose configuration
Docker Compose lets you define all three services in a single file and start them with one command. Each service gets its own container, but they share a network so they can communicate by name.
- Create a new file called docker-compose.yml at the root of your project (the same level as backend/ and frontend/), then paste this configuration:
version: "3.9"
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: shopstack
POSTGRES_USER: shopstack_user
POSTGRES_PASSWORD: shopstack_password
backend:
build: ./backend
depends_on:
- db
environment:
DATABASE_URL: postgres://shopstack_user:shopstack_password@db:5432/shopstack
SECRET_KEY: django-insecure-docker-dev-key
ALLOWED_HOSTS: localhost,backend
CORS_ALLOWED_ORIGINS: http://localhost:3000
ports:
- "8000:8000"
command: >
sh -c "python manage.py migrate && gunicorn shopstack.wsgi:application --bind 0.0.0.0:8000"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "3000:80"
volumes:
pgdata:
What does this configuration do?
- db runs PostgreSQL 16 with a named volume pgdata so your data persists across container restarts.
- backend builds from your Django Dockerfile, waits for the database, runs migrations, then starts Gunicorn.
- frontend builds from your React Dockerfile and maps port 3000 on your machine to port 80 inside the Nginx container.
- The DATABASE_URL uses db as the hostname because Docker Compose creates a network where each service is reachable by its service name.
Why does the backend command run migrate first?
The containerized PostgreSQL starts empty. Running python manage.py migrate before Gunicorn ensures the database schema exists before the app serves requests.
- Save docker-compose.yml.
Seeing YAML syntax errors later?
YAML is sensitive to indentation. Make sure you use spaces (not tabs) and that each nested level is indented by exactly two spaces.
help me fix YAML indentation errors in my docker-compose.yml
✔️ Awesome, I've got everything!
Great. Make sure you saved docker-compose.yml at the project root.
ⓧ I'd like to double check the full code
version: "3.9"
services:
db:
image: postgres:16
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_DB: shopstack
POSTGRES_USER: shopstack_user
POSTGRES_PASSWORD: shopstack_password
backend:
build: ./backend
depends_on:
- db
environment:
DATABASE_URL: postgres://shopstack_user:shopstack_password@db:5432/shopstack
SECRET_KEY: django-insecure-docker-dev-key
ALLOWED_HOSTS: localhost,backend
CORS_ALLOWED_ORIGINS: http://localhost:3000
ports:
- "8000:8000"
command: >
sh -c "python manage.py migrate && gunicorn shopstack.wsgi:application --bind 0.0.0.0:8000"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "3000:80"
volumes:
pgdata:
Build and run the containers
Everything is wired up. Time to build the images and start all three services together.
- Make sure Docker Desktop is running (check for the whale icon in your system tray or menu bar).
- From the project root (where docker-compose.yml lives), build and start the containers by running:
docker compose up --build
What does this command do?
The --build flag forces Docker to rebuild the images from your Dockerfiles. Without it, Docker reuses cached images and might not pick up your latest code changes.
The first build takes a few minutes because Docker downloads base images and installs all dependencies. Subsequent builds are much faster thanks to layer caching.
- Wait for the logs to show the backend server starting. You will see output containing Listening at: http://0.0.0.0:8000 from the backend container.
- Open your browser and navigate to http://localhost:3000/.
You should see your ShopStack storefront loading products from the Django API, now running entirely inside Docker containers with PostgreSQL as the database.
Seeing a connection error or blank page?
- Check that Docker Desktop is running. If the engine is stopped, docker compose cannot start containers.
- Look at the terminal logs for error messages. A common issue is the backend failing to connect to the database because PostgreSQL has not finished starting. Stop the containers with Ctrl+C and run docker compose up --build again.
- If you see a collectstatic error during the backend build, make sure your Django STATIC_URL and STATIC_ROOT settings are configured in settings.py.
help me debug why my Docker containers are not starting correctly
Your entire application now runs in containers. Next up, you will set up a CI pipeline with GitHub Actions to run all your tests automatically on every push.
Set Up CI with GitHub Actions
Your full-stack application works locally and runs in Docker containers. The next challenge is making sure it stays working as you make changes.
Continuous Integration (CI) automatically runs your test suites every time you push code. In this step, you'll create a GitHub Actions workflow that spins up a PostgreSQL database, runs your backend pytest suite, and runs your frontend Vitest suite on every push.
In this step, get ready to:
- Create a GitHub Actions CI workflow with a PostgreSQL service container.
- Push the project to GitHub and verify the pipeline passes.
- Add a test coverage threshold that fails the build below 80%.
Create the GitHub Actions workflow
GitHub Actions workflows live in a .github/workflows/ directory at the root of your repository. Each YAML file in that directory defines a pipeline that GitHub runs automatically.
- Create the workflow directory and file by running these commands from your project root:
mkdir -p .github/workflows
touch .github/workflows/ci.yml
- Confirm the file exists by running:
ls .github/workflows/
You should see ci.yml in the output.
- Open .github/workflows/ci.yml in VS Code and paste the following workflow:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_shopstack
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12.3'
- name: Install dependencies
run: pip install -r requirements.txt
working-directory: backend
- name: Run tests
run: pytest
working-directory: backend
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_shopstack
SECRET_KEY: ci-secret-key
DJANGO_SETTINGS_MODULE: shopstack.settings
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Run tests
run: npm test -- --run --coverage
working-directory: frontend
What does this workflow do?
This workflow triggers on every push or pull request to main. It defines two independent jobs that run in parallel.
- backend-tests spins up a PostgreSQL 16 service container, installs Python 3.12.3, installs your backend dependencies, and runs pytest against the test database.
- frontend-tests installs Node.js 22, runs npm ci for a clean dependency install, and executes Vitest with the --run flag (no watch mode) and --coverage for a report.
- The options block on the postgres service adds a health check so the job waits for PostgreSQL to be ready before running tests.
- Save .github/workflows/ci.yml.
YAML indentation errors?
YAML is whitespace-sensitive. Make sure you use spaces (not tabs) and that each nested level is indented by exactly 2 spaces. If your editor shows red squiggles, check that services is indented under backend-tests and steps is at the same level as services.
help me fix YAML indentation in my GitHub Actions workflow
Push to GitHub and verify the CI pipeline
The workflow file is ready, but GitHub Actions only triggers when code is pushed to a GitHub repository. You need to create a repo and push your project.
- Go to github.com/new in your browser.
- Enter shopstack as the repository name.
- Leave the repository set to Public.
- Do NOT check any boxes for README, .gitignore, or license (your project already has files).
- Click Create repository.
GitHub shows you the quick setup instructions. You'll use the commands for pushing an existing repository.
- Initialize a git repository and push your project by running these commands from your project root:
git init
git add .
git commit -m "Initial commit: full-stack ShopStack with tests"
git branch -M main
git remote add origin https://github.com/[[GITHUB_USERNAME="your-github-username"]]/shopstack.git
git push -u origin main
What do these commands do?
- git init creates a new git repository in your project folder.
- git add . stages all your files for the first commit.
- git branch -M main renames the default branch to main (matching the workflow trigger).
- git push -u origin main uploads everything to GitHub, which triggers the CI workflow.
Create a .gitignore first
If you haven't already, create a .gitignore in your project root with entries for __pycache__/, node_modules/, .env, and venv/ before running git add .. This prevents pushing thousands of dependency files to GitHub.
- Open your repository on GitHub in your browser.
- Click the Actions tab at the top of the repository page.
You should see a workflow run in progress or completed. A green checkmark means both jobs passed. A red X means something failed.
Seeing a red X on the workflow?
Click the failed workflow run, then click the failed job to see the logs. Common causes:
- Missing requirements.txt or package.json in the expected directory. Check that working-directory matches your folder names (backend and frontend).
- Database connection error. Verify the DATABASE_URL in your workflow matches the service container credentials exactly.
- Tests that pass locally but fail in CI often have hardcoded file paths or environment-specific assumptions.
help me debug my failing GitHub Actions workflow for ShopStack
Add a test coverage threshold
Your CI pipeline runs the tests, but it doesn't enforce code coverage. A coverage threshold ensures no one can merge code that drops test coverage below a minimum percentage.
- Add pytest-cov to your backend dependencies by opening backend/requirements.txt and adding this line at the end:
pytest-cov
What is pytest-cov?
pytest-cov is a pytest plugin that measures how much of your code is executed during tests. The --cov-fail-under flag makes the test run fail if coverage drops below the specified percentage.
- Save backend/requirements.txt.
- In .github/workflows/ci.yml, find the Run tests step in the backend-tests job. Find this line:
run: pytest
- Replace it with this line to enable coverage measurement and the 80% threshold:
run: pytest --cov=. --cov-fail-under=80
- Save .github/workflows/ci.yml.
- Push the changes to GitHub by running:
git add .
git commit -m "Add test coverage threshold"
git push
- Open the Actions tab on your GitHub repository.
You should see a new workflow run. When it completes with a green checkmark, your backend tests are passing with at least 80% code coverage and your frontend tests are all green.
Coverage below 80%?
If the backend job fails with a coverage error, your tests don't exercise enough of your code. Check the coverage report in the job logs to see which files have low coverage. Add tests for uncovered model methods, serializer validations, or view edge cases.
help me increase my pytest coverage above 80% for my Django ecommerce API
✔️ Awesome, I've got everything!
Great. Double check you've saved both .github/workflows/ci.yml and backend/requirements.txt.
ⓧ I'd like to double check the full code
Here is the final .github/workflows/ci.yml:
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend-tests:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test_shopstack
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12.3'
- name: Install dependencies
run: pip install -r requirements.txt
working-directory: backend
- name: Run tests
run: pytest --cov=. --cov-fail-under=80
working-directory: backend
env:
DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_shopstack
SECRET_KEY: ci-secret-key
DJANGO_SETTINGS_MODULE: shopstack.settings
frontend-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Run tests
run: npm test -- --run --coverage
working-directory: frontend
And here is the full backend/requirements.txt:
Django==5.2
djangorestframework==3.17
psycopg[binary]
dj-database-url
django-cors-headers
djangorestframework-simplejwt
gunicorn
pytest
pytest-django
factory-boy
pytest-cov
Your tests now run automatically on every push with enforced coverage. Next up, take on the Secret Mission and add product search with filtering and pagination.
Secret mission
Add Product Search and Filtering
Your store works, but customers cannot find what they are looking for. Add production-grade search powered by PostgreSQL full-text search, category filtering with django-filter, and test every layer end-to-end.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing cloud costs. However, Docker containers and PostgreSQL use disk space and system memory while running.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- PostgreSQL database (shopstack).
- Docker containers, images, and volumes.
- Local project files (Django backend, React frontend, virtual environment).
Keep everything running
No action needed. Choose this if you're still actively building or plan to continue working on ShopStack soon.
- Your Docker containers will keep running and serving the app.
- PostgreSQL will continue using memory in the background.
- Your project files, virtual environment, and node_modules remain on disk.
Pause - I'll come back to this later
Stop running containers to free up memory while keeping your data intact. Your database contents are preserved in Docker volumes.
- Stop all project containers by running:
docker compose down
This removes the containers and networks but preserves your database volume. When you're ready to resume, run docker compose up again and your data will still be there.
- If PostgreSQL is running as a local service (installed via Homebrew), stop it by running:
brew services stop postgresql@16
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent and cannot be undone.
- Stop and remove all containers, networks, and database volumes by running:
docker compose down -v
- Remove the Docker images built for this project by running:
docker image prune -a
Be selective with image pruning
The command above removes all unused images, not just ShopStack's. If you have other Docker projects, list images first with docker images and remove specific ones by ID instead.
- If you created a local PostgreSQL database outside of Docker, drop it by running:
dropdb shopstack
- Delete the project files on macOS or Linux by running:
cd ~/Desktop
rm -rf shopstack
- On Windows, delete the project files by running:
cd ~\Desktop
rmdir /s /q shopstack
This removes the Django backend, React frontend, virtual environment, and all dependencies.
That's a Wrap!
That's a Wrap!
Congratulations! You just built a fully tested ecommerce application from scratch. You now have a production-ready store with a Django REST API, a React frontend, PostgreSQL persistence, automated tests, and a Docker containerized deployment.
You've learned how to:
- Design and build a Django REST API with Product, Cart, and Order resources, secured with JWT authentication and backed by PostgreSQL, with a comprehensive pytest test suite covering models, serializers, views, and auth flows.
- Build a React storefront with product browsing, cart management, and checkout, tested with Vitest and React Testing Library using Mock Service Worker for integration tests.
- Containerize the full stack with Docker Compose and automate testing with a GitHub Actions CI pipeline using a PostgreSQL service container and coverage thresholds.
- Add product search and filtering with PostgreSQL full-text search, tested end-to-end across both the backend API and React frontend.
Ready to quiz yourself?