27 Feb 2026
Django community aggregator: Community blog posts
Using tox to Test a Django App Across Multiple Django Versions

Recently, I developed a reusable Django app django-clearplaintext for normalizing plain text in Django templates. And to package and test it properly, I had a fresh look to Tox.
Tox is the standard testing tool that creates isolated virtual environments, installs the exact dependencies you specify, and runs your test suite in each one - all from a single command.
This post walks through a complete, working setup using a minimal example app called django-shorturl.
The Example App: django-shorturl
django-shorturl is a self-contained Django app with one model and one view.
shorturl/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _
class ShortLink(models.Model):
slug = models.SlugField(_("slug"), unique=True)
target_url = models.URLField(_("target URL"))
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
verbose_name = _("short link")
verbose_name_plural = _("short links")
def __str__(self):
return self.slug
shorturl/views.py
from django.shortcuts import get_object_or_404, redirect
from .models import ShortLink
def redirect_link(request, slug):
link = get_object_or_404(ShortLink, slug=slug)
return redirect(link.target_url)
shorturl/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("<slug:slug>/", views.redirect_link, name="redirect_link"),
]
shorturl/admin.py
from django.contrib import admin
from .models import ShortLink
admin.site.register(ShortLink)
Project Layout
django-shorturl/
├── src/
│ └── shorturl/
│ ├── __init__.py
│ ├── admin.py
│ ├── models.py
│ ├── views.py
│ └── urls.py
├── tests/
│ ├── __init__.py
│ └── test_views.py
├── pyproject.toml
├── test_settings.py
└── tox.ini
The source lives under src/ and the tests are at the top level, separate from the package. This separation prevents the tests from accidentally being shipped inside the installed package.
Packaging: pyproject.toml
Tox needs a properly packaged app to install into each environment. With isolated_build = true (more on that below), Tox builds a wheel from your pyproject.toml before running any tests.
pyproject.toml
[project]
name = "django-shorturl"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = [
"Django>=4.2",
]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
The dependencies list here declares the runtime minimum - your app needs Django, but you don't pin a specific version because that is Tox's job during testing.
For the [build-system] section, we can also use uv_build to gain some performance improvements:
[build-system]
requires = ["uv_build >= 0.10.0, <0.11.0"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-name = "shorturl"
Here module-name lets uv_build not to get confused between django-shorturl and shorturl.
Test Settings: test_settings.py
Django requires a settings module to run. As we don't have an associated project, we have to create a minimal one by defining project settings in the project's settings, create a minimal one dedicated to testing. It lives at the repo root so it's easy to point to from anywhere.
test_settings.py
SECRET_KEY = "test"
INSTALLED_APPS = [
"shorturl",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
ROOT_URLCONF = "shorturl.urls"
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
A few deliberate choices here:
SECRET_KEY = "test"- A fixed value, fine for tests, but never use this in production.INSTALLED_APPS- Only include apps that your tests actually need. Nodjango.contrib.admin, no auth, nothing extra.- SQLite in-memory database -
":memory:"means the database is created fresh for every test run and disappears when the process exits. No files left behind, no teardown needed, and it is fast. ROOT_URLCONF- The test client resolves URLs through this setting. Without it,reverse()raisesNoReverseMatchand the test client has no URL configuration to dispatch against. Point it at your app'surls.py.DEFAULT_AUTO_FIELD- Suppresses Django's system check warning about the implicit primary key type. Setting it explicitly keeps the test output clean and makes the expectation clear.
The Core: tox.ini
This is where Tox is configured.
tox.ini
[tox]
envlist =
py{38,39,310,311,312}-django42,
py{310,311,312}-django50,
py{310,311,312,313}-django51,
py{310,311,312,313,314}-django52,
py{312,313,314}-django60
isolated_build = true
[testenv]
deps =
django42: Django>=4.2,<4.3
django50: Django>=5.0,<5.1
django51: Django>=5.1,<5.2
django52: Django>=5.2,<6.0
django60: Django>=6.0,<6.1
commands =
python -m django test
setenv =
DJANGO_SETTINGS_MODULE = test_settings
envlist - the matrix
py{38,39,310,311,312}-django42 is a shortcut used in Tox.
The numbers inside {} are expanded automatically. Tox combines each Python version with django42, creating 5 environments:
py38-django42py39-django42py310-django42py311-django42py312-django42
The full envlist simply lists all Python and Django combinations you want to test, so you can check that your project works in each setup.
Each part separated by a dash in an environment name is called a "factor". You can have as many factors as you like, and they can be named anything. py* factors are a convention for Python versions. Others need to be defined in the [testenv] deps section.
isolated_build = true
This tells tox to build a proper wheel from your pyproject.toml before installing into each environment. Without it, tox would try to install your package with pip install -e ., which bypasses the build system and can hide packaging bugs. With it, each environment tests the package exactly as a user would receive it after pip install django-shorturl.
deps - conditional dependencies
The django42: prefix is a Tox factor condition: the dependency on that line is only installed when the environment name contains the django42 factor. This is how a single [testenv] block handles all Django versions without needing a separate section for each one.
Tox also installs your package itself into each environment (because of isolated_build), so you don't need to list it here.
commands
commands =
python -m django test
python -m django test is Django's built-in test runner. It discovers tests by looking for files matching test*.py under the current directory, which picks up everything in your tests/ folder automatically.
setenv
setenv =
DJANGO_SETTINGS_MODULE = test_settings
Django refuses to run without a settings module. This environment variable tells it where to find yours. Because test_settings.py is at the repo root and tox runs from the repo root, the module name test_settings resolves correctly without any path manipulation.
Writing the Tests
Create test cases for each (critical) component of your app. For example, if you have models, views, and template tags, create tests/test_models.py, tests/test_views.py, and tests/test_templatetags.py.
tests/test_views.py
from django.test import TestCase
from django.urls import reverse
from shorturl.models import ShortLink
class RedirectLinkViewTest(TestCase):
def setUp(self):
ShortLink.objects.create(
slug="dt",
target_url="https://www.djangotricks.com",
)
def test_redirects_to_target_url(self):
response = self.client.get(
reverse(
"redirect_link", kwargs={"slug": "dt"}
)
)
self.assertRedirects(
response,
"https://www.djangotricks.com",
fetch_redirect_response=False,
)
def test_returns_404_for_unknown_slug(self):
response = self.client.get(
reverse(
"redirect_link", kwargs={"slug": "nope"}
)
)
self.assertEqual(response.status_code, 404)
Installing Python Versions with pyenv
Tox needs the actual Python binaries for every version in your envlist. If you try to run tox without them installed, it will fail immediately with an InterpreterNotFound error. pyenv is the standard way to install and manage multiple Python versions side by side.
Install pyenv
Use Homebrew on macOS (or follow the official instructions for Linux):
brew install pyenv
Add the following to your shell config (~/.zshrc, ~/.bashrc, etc.) and restart your shell:
export PYENV_ROOT="$HOME/.pyenv"
export PATH="$PYENV_ROOT/bin:$PATH"
eval "$(pyenv init -)"
Install each Python version
Install every version that appears in your envlist:
pyenv install 3.8
pyenv install 3.9
pyenv install 3.10
pyenv install 3.11
pyenv install 3.12
pyenv install 3.13
pyenv install 3.14
Make them all reachable at once
Tox resolves py312 by looking for a binary named python3.12 on PATH. The trick is pyenv global, which accepts multiple versions and places all of their binaries on your PATH simultaneously:
pyenv global 3.14 3.13 3.12 3.11 3.10 3.9 3.8
List the first (the one python3 and python resolve to) and work downward. After running this, confirm every interpreter is visible:
python3.8 --version # Python 3.8.x
python3.9 --version # Python 3.9.x
python3.10 --version # Python 3.10.x
python3.11 --version # Python 3.11.x
python3.12 --version # Python 3.12.x
python3.13 --version # Python 3.13.x
python3.14 --version # Python 3.14.x
Now tox can find all of them and the full matrix will run without InterpreterNotFound errors.
Running tox
Run the full matrix:
tox
Or run a single environment:
tox -e py312-django52
tox will print a summary at the end showing which environments passed and which failed.
py38-django42: OK (3.25=setup[2.32]+cmd[0.93] seconds)
py39-django42: OK (2.88=setup[2.16]+cmd[0.72] seconds)
py310-django42: OK (2.61=setup[2.02]+cmd[0.59] seconds)
py311-django42: OK (2.70=setup[2.09]+cmd[0.61] seconds)
py312-django42: OK (3.28=setup[2.46]+cmd[0.82] seconds)
py310-django50: OK (2.67=setup[2.09]+cmd[0.58] seconds)
py311-django50: OK (2.61=setup[2.02]+cmd[0.59] seconds)
py312-django50: OK (2.85=setup[2.25]+cmd[0.60] seconds)
py310-django51: OK (2.81=setup[2.27]+cmd[0.54] seconds)
py311-django51: OK (2.85=setup[2.30]+cmd[0.55] seconds)
py312-django51: OK (2.70=setup[2.09]+cmd[0.61] seconds)
py313-django51: OK (2.97=setup[2.29]+cmd[0.68] seconds)
py310-django52: OK (3.03=setup[2.31]+cmd[0.72] seconds)
py311-django52: OK (2.88=setup[2.22]+cmd[0.66] seconds)
py312-django52: OK (2.80=setup[2.13]+cmd[0.67] seconds)
py313-django52: OK (4.70=setup[3.66]+cmd[1.04] seconds)
py314-django52: OK (6.41=setup[5.18]+cmd[1.23] seconds)
py312-django60: OK (5.13=setup[4.06]+cmd[1.07] seconds)
py313-django60: OK (5.35=setup[4.15]+cmd[1.21] seconds)
py314-django60: OK (6.01=setup[4.65]+cmd[1.37] seconds)
congratulations :) (70.59 seconds)
Final Words
What makes this setup robust?
- No shared state between environments. Each Tox environment is its own virtualenv with its own Django installation.
- The package is built, not symlinked.
isolated_build = truecatches packaging mistakes before they reach users. - The database never persists between runs. SQLite in-memory means no stale data, no cleanup scripts, no CI-specific teardown.
- The test settings are minimal by design. Fewer installed apps means faster startup, fewer implicit dependencies, and tests that fail for clear, local reasons rather than configuration noise from elsewhere in the project.
This setup is not the only way to test a Django app with Tox, but it is a solid starting point that balances comprehensiveness with maintainability. With a little effort upfront, you can ensure your app works across a wide range of Python and Django versions - and catch packaging bugs before they hit real users.
27 Feb 2026 6:00pm GMT
Django News - Google Summer of Code 2026 with Django - Feb 27th 2026
News
Google Summer of Code 2026 with Django
All the information you need to apply for Django's 21st consecutive year in the program.
Django Software Foundation
DSF member of the month - Baptiste Mispelon
Baptiste is a long-time Django and Python contributor who co-created the Django Under the Hood conference series and serves on the Ops team maintaining its infrastructure. He has been a DSF member since November 2014. You can learn more about Baptiste by visiting Baptiste's website and his GitHub Profile.
Wagtail CMS News
The *1000 most popular* Django packages
Based on GitHub stars and PyPI download numbers.
Updates to Django
Today, "Updates to Django" is presented by Johanan from Djangonaut Space! 🚀
Last week we had 11 pull requests merged into Django by 10 different contributors - including 4 first-time contributors! Congratulations to Saish Mungase, Marco Aurélio da Rosa Haubrich, 조형준 and Muhammad Usman for having their first commits merged into Django - welcome on board!
This week's Django highlights:
-
BuiltinLookup.as_sql()now correctly handles parameters returned as tuples, ensuring consistency with release note guidance for custom lookups. This avoids the need for developers to audit both process_lhs() and as_sql() for tuple/list resilience when subclassing BuiltinLookup. (#36934) (#35972) -
SessionBase.__bool__() has been implemented, allowing session objects to be evaluated directly in boolean contexts instead of relying on truthiness checks. (#36899)
Django Newsletter
Django Fellow Reports
Django Fellow Report - Jacob
A short week with a US holiday and some travel to visit family, but still 4 tickets triaged, 12 reviewed, 3 authored, security report, and more.
Django Fellow Report - Natalia
Roughly 70% of my time this week went into security work, which continues being quite demanding. The remaining time was primarily dedicated to Mike's excellent write-up on the dictionary-based EMAIL_PROVIDERS implementation and migration, along with a smaller amount of ticket triage and PR review.
Also 2 tickets triaged, 9 reviewed, and other misc.
Sponsored Link 1
PyTV - Free Online Python Conference (March 4th)
1 Day, 15 Speakers, 6 hours of live talks including from Sarah Boyce, Sheena O'Connell, Carlton Gibson, and Will Vincent. Sign up and save the date!
Articles
⭐ Django ORM Standalone⁽¹⁾: Querying an existing database
A practical step-by-step guide to using Django ORM in standalone mode to connect to and query an existing database using inspectdb.
Using tox to Test a Django App Across Multiple Django Versions
A practical, production-ready guide to using tox to test your reusable Django app across multiple Python and Django versions, complete with packaging, minimal test settings, and a full version matrix.
How I Use django-simple-nav for Dashboards, Command Palettes, and More
Jeff shares how he uses django-simple-nav to define navigation once in Python and reuse it across dashboards and even a lightweight HTMX-powered command palette.
Serving Private Files with Django and S3
Django's FileField and ImageField are good at storing files, but on their own they don't let us control access. When …
CLI subcommands with lazy imports
In case you didn't hear, PEP 810 got accepted which means Python 3.15 is going to support lazy imports! One of the selling points of lazy imports is with code that has a CLI so that you only import code as necessary, making the app a bit more snappy
Events
DjangoCon US Updated Dates
The conference is now August 24-28, 2026 in Chicago, Illinois. The Call for Proposals (CFP) is open until March 16. And Early Bird Tickets are now available!
Sponsored Link 2
Sponsor Django News
Reach 4,300+ highly-engaged and experienced Django developers.
Podcasts
Django Chat #196: Freelancing & Community - Andrew Miller
Andrew is a prolific software developer based out of Cambridge, UK. He runs the solo agency Software Crafts, writes regularly, is a former Djangonaut, and co-founder of the AI banking startup Hamilton Rock.
PyPodcats Episode 11 with Sheena O'Connell
Sheena O'Connell tells us about her journey, the importance of community and good practices for teachers and educators in Python, and organizational psychology. We talk about how to enable a 10x team and how to enable the community through guild of educators.
Django Job Board
This week there is a very rare Infrastructure Engineer position for the PSF.
Infrastructure Engineer at Python Software Foundation 🆕
Lead Backend Engineer at TurnTable
Backend Software Developer at Chartwell Resource Group Ltd.
Django Newsletter
Projects
yassi/dj-control-room
The control room for your Django app.
adamchainz/icu4py
Python bindings to the ICU (International Components for Unicode) library (ICU4C).
matagus/awesome-django-articles
📚 Articles explaining topics about Django like admin, ORM, views, forms, scaling, performance, testing, deployments, APIs, and more!
Sponsorship
🚀 Reach 4,300+ Django Developers Every Week
Want to reach developers who actually read what they subscribe to?
Django News is opened by thousands of engaged Django and Python developers every week. A 52% open rate and 15% click rate means your message lands in front of people who pay attention.
Support the newsletter and promote your product, service, event, or job to builders who use Django daily.
👉 Explore sponsorship options: https://django-news.com/sponsorship
This RSS feed is published on https://django-news.com/. You can also subscribe via email.
27 Feb 2026 5:00pm GMT
25 Feb 2026
Django community aggregator: Community blog posts
Freelancing & Community - Andrew Miller
🔗 Links
- Personal website
- GitHub, Mastodon, and LinkedIn
- In Progress podcast
- Hamilton Rock
- Comprehension Debt
- Builder Methods
📦 Projects
📚 Books
- How to Build a LLM From Scratch by Sebastian Raschka
- World of Astrom
- Rob Walling books
- Jonathan Stark books
- David Kadavy books
- Manifesto of winning without pitching
🎥 YouTube
Sponsor
This episode is brought to you by Six Feet Up, the Python, Django, and AI experts who solve hard software problems. Whether it's scaling an application, deriving insights from data, or getting results from AI, Six Feet Up helps you move forward faster.
See what's possible at sixfeetup.com.
25 Feb 2026 6:00pm GMT

