A recent deploy broke your Wagtail-powered news platform's image gallery, and no one noticed for three hours. This lesson teaches you to prevent regressions with automated tests for page models, API endpoints, StreamField validation, and image renditions, then build a GitHub Actions CI/CD pipeline that enforces quality gates and deploys only on green builds.
1. Learning Objectives
By the end of this lesson, you will be able to:
- Set up Django's test framework with Wagtail-specific configurations, including test factories and fixtures
- Write comprehensive tests for Wagtail page models, covering custom methods, parent/child relationships, and route resolution
- Test Wagtail REST API endpoints, including custom serializers, filtering, pagination, and authentication
- Validate StreamField content programmatically, testing block structure, field constraints, and custom block validation
- Write tests for image renditions and rich text processing with correct content type handling
- Configure a GitHub Actions CI/CD pipeline that runs tests on every push and deploys to a staging environment
- Implement automated quality gates including linting, coverage thresholds, and migration checks
2. Why This Matters (Real-World Scenario)
Your team has just shipped a Wagtail-powered news platform with 50+ page types, custom StreamField blocks, and a public REST API serving a React frontend. The client reports that a recent deploy broke the image gallery on article pages — a regression that went unnoticed because there were no automated tests. The editorial team discovers it three hours after go-live, and the fix requires a hotfix deploy during peak traffic.
This scenario is avoidable. A proper test suite would have caught the regression before deploy. A CI/CD pipeline would have blocked the broken commit from reaching production. In this lesson, you will build both: a comprehensive test suite covering Wagtail's unique features (page models, StreamFields, image renditions, APIs) and a GitHub Actions pipeline that runs these tests automatically, enforcing quality gates and deploying only when green.
3. Core Concepts
Django Test Framework — Django extends Python's unittest with Wagtail-aware helpers. Use TestCase for database-backed tests, RequestFactory for simulating HTTP requests, and Client for full request/response cycles. Wagtail adds WagtailPageTestCase with page-specific assertions like assertCanCreate and assertRouteMatches.
Page Model Tests — Wagtail pages inherit from Page and have custom methods, parent page restrictions, and subpage type rules. Tests verify that pages can be created under the correct parent, that custom methods return expected values, and that routes resolve to the right view.
StreamField Validation — StreamField blocks have custom validation logic: required fields, min/max counts, block type restrictions, and cross-field validation. Tests should verify that valid data passes and invalid data (missing required fields, wrong block types) raises appropriate errors.
API Endpoint Tests — Wagtail's REST API exposes pages, images, documents, and custom endpoints. Tests should verify that public endpoints return correct data, authenticated endpoints require credentials, custom serializers transform data properly, and pagination/filtering works as expected.
Image Rendition Tests — Wagtail generates image renditions on-the-fly. Tests verify that renditions are created with correct dimensions, that filter strings are parsed correctly, and that custom image operations produce expected outputs.
CI/CD with GitHub Actions — A CI/CD pipeline runs tests, linting, and quality checks on every push. A successful pipeline deploys to staging. This prevents regressions, enforces code standards, and makes deployment repeatable and auditable.
4. Hands-On Practice: Step-by-Step Testing and CI/CD Setup
4.1 Setting Up the Test Environment
Start by installing test dependencies and configuring your settings.py for testing. Wagtail tests need a database and the Django test client.
pip install pytest pytest-django pytest-cov factory-boy responses
# Add to requirements-dev.txt:
# pytest==8.x
# pytest-django==4.x
# pytest-cov==5.x
# factory-boy==3.x
# responses==0.25.x
Create a pytest.ini or pyproject.toml section:
[pytest]
DJANGO_SETTINGS_MODULE = myproject.settings.test
python_files = tests.py test_*.py *_tests.py
# Use an in-memory SQLite database for speed
testpaths = myproject myapp
addopts = --reuse-db --nomigrations -p no:cacheprovider
Create a separate test settings file that uses SQLite in-memory for fast test runs:
# myproject/settings/test.py
from .base import *
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
}
}
PASSWORD_HASHERS = [
'django.contrib.auth.hashers.MD5PasswordHasher',
]
# Disable migrations for speed (optional)
MIGRATION_MODULES = {}
# Use local file storage (not cloud) in tests
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
# Disable Wagtail's frontend cache invalidator in tests
WAGTAILFRONTENDCACHE = {}
4.2 Writing Page Model Tests
Create a tests/test_pages.py file. Use WagtailPageTestCase for page-specific assertions. Wagtail's PageTestCase mixin gives you self.assertCanCreate and self.assertRouteMatches.
from django.test import TestCase
from wagtail.test.utils import WagtailPageTestCase
from wagtail.models import Page, Site
from myapp.models import BlogIndexPage, BlogPage, HomePage
class BlogPageTests(WagtailPageTestCase):
def setUp(self):
# Create the site root
self.root = Page.get_first_root_node()
self.home = HomePage(
title='Home',
slug='home',
)
self.root.add_child(instance=self.home)
self.home.save_revision().publish()
# Create a blog index
self.blog_index = BlogIndexPage(
title='Blog',
slug='blog',
)
self.home.add_child(instance=self.blog_index)
self.blog_index.save_revision().publish()
def test_blog_page_can_be_created_under_index(self):
"""Verify BlogPage can only be created under BlogIndexPage."""
self.assertCanCreate(self.blog_index, BlogPage, {
'title': 'Test Article',
'slug': 'test-article',
'body': '[{"type": "paragraph", "value": "<p>Test content</p>"}]',
})
def test_blog_page_not_allowed_under_home(self):
"""Verify BlogPage cannot be created directly under HomePage."""
self.assertCanNotCreate(self.home, BlogPage, {
'title': 'Invalid Article',
})
def test_page_route_resolves(self):
"""Verify the page URL resolves correctly."""
page = BlogPage(
title='My Article',
slug='my-article',
)
self.blog_index.add_child(instance=page)
page.save_revision().publish()
self.assertRouteMatches(page.url)
def test_custom_page_method(self):
"""Test a custom method on the page model."""
page = BlogPage(
title='SEO Test',
slug='seo-test',
seo_description='A test description for search engines',
)
self.blog_index.add_child(instance=page)
page.save_revision().publish()
self.assertEqual(
page.get_meta_description(),
'A test description for search engines'
)
def test_page_query_returns_published_only(self):
"""Verify .live() only returns published pages."""
draft = BlogPage(title='Draft', slug='draft')
self.blog_index.add_child(instance=draft)
# Not published - should NOT appear in .live()
published = BlogPage(title='Live', slug='live')
self.blog_index.add_child(instance=published)
published.save_revision().publish()
live_pages = BlogPage.objects.live()
self.assertIn(published, live_pages)
self.assertNotIn(draft, live_pages)
4.3 Testing API Endpoints
Test the Wagtail REST API by sending HTTP requests via Django's test client and inspecting responses. This covers custom serializers, pagination, filtering, and authentication.
from django.test import TestCase
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from wagtail.models import Page
from myapp.models import BlogPage, BlogIndexPage, HomePage
class BlogAPITests(TestCase):
def setUp(self):
self.client = APIClient()
# Set up pages
root = Page.get_first_root_node()
home = HomePage(title='Home', slug='home')
root.add_child(instance=home)
home.save_revision().publish()
blog_index = BlogIndexPage(title='Blog', slug='blog')
home.add_child(instance=blog_index)
blog_index.save_revision().publish()
# Create a test page
self.page = BlogPage(title='Test Article', slug='test-article')
blog_index.add_child(instance=self.page)
self.page.save_revision().publish()
# Create a non-published page
self.draft = BlogPage(title='Draft Article', slug='draft-article')
blog_index.add_child(instance=self.draft)
# Create API user
self.user = User.objects.create_user(
username='testuser', password='testpass123'
)
def test_list_endpoint_returns_pages(self):
"""GET /api/v2/pages/ returns paginated pages."""
response = self.client.get('/api/v2/pages/')
self.assertEqual(response.status_code, 200)
self.assertIn('results', response.json())
def test_list_only_returns_live_pages(self):
"""Unpublished pages should not appear in the public API."""
response = self.client.get('/api/v2/pages/')
titles = [p['title'] for p in response.json()['results']]
self.assertIn('Test Article', titles)
self.assertNotIn('Draft Article', titles)
def test_detail_endpoint(self):
"""GET /api/v2/pages/{id}/ returns full page data."""
response = self.client.get(f'/api/v2/pages/{self.page.pk}/')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data['title'], 'Test Article')
self.assertIn('body', data)
def test_filtering_by_type(self):
"""Filter pages by content type."""
response = self.client.get(
'/api/v2/pages/?type=myapp.BlogPage'
)
self.assertEqual(response.status_code, 200)
results = response.json()['results']
for r in results:
self.assertIn('BlogPage', r['meta']['type'])
def test_auth_required_for_private_endpoints(self):
"""Private endpoints return 401 without auth."""
response = self.client.get('/api/v2/pages/private/')
self.assertEqual(response.status_code, 401)
self.client.force_authenticate(user=self.user)
response = self.client.get('/api/v2/pages/private/')
self.assertEqual(response.status_code, 200)
def test_pagination(self):
"""API returns paginated results with meta links."""
response = self.client.get('/api/v2/pages/?page=1')
data = response.json()
self.assertIn('meta', data)
self.assertIn('total_count', data['meta'])
4.4 StreamField Validation Tests
StreamField blocks must validate correctly. Test that valid block data passes, invalid data raises the right errors, and custom block validation methods fire at the correct time.
from django.test import TestCase
from wagtail.blocks import StreamBlockValidationError
from myapp.blocks import CustomRichTextBlock, ImageGalleryBlock, CodeBlock
class StreamFieldValidationTests(TestCase):
def test_richtext_block_valid(self):
"""Valid rich text content should pass validation."""
block = CustomRichTextBlock()
result = block.clean('<p>Valid <strong>HTML</strong> content</p>')
self.assertEqual(result, '<p>Valid <strong>HTML</strong> content</p>')
def test_richtext_block_rejects_scripts(self):
"""Script tags should be stripped or raise validation errors."""
block = CustomRichTextBlock()
with self.assertRaises(Exception):
block.clean('<script>alert("xss")</script>')
def test_image_gallery_min_images(self):
"""Gallery block requires at least 1 image."""
block = ImageGalleryBlock()
with self.assertRaises(StreamBlockValidationError):
block.clean([]) # Empty gallery
def test_image_gallery_max_images(self):
"""Gallery block rejects more than 10 images."""
block = ImageGalleryBlock()
too_many = [{'image': i} for i in range(15)]
with self.assertRaises(StreamBlockValidationError):
block.clean(too_many)
def test_code_block_language_enum(self):
"""Code block only accepts supported languages."""
block = CodeBlock()
for lang in ['python', 'bash', 'javascript', 'html', 'css']:
result = block.clean({'language': lang, 'code': 'print("hi")'})
self.assertEqual(result['language'], lang)
with self.assertRaises(Exception):
block.clean({'language': 'invalid', 'code': 'test'})
def test_max_block_count(self):
"""StreamField should enforce a maximum number of blocks."""
too_many_blocks = [
{'type': 'paragraph', 'value': f'<p>Block {i}</p>', 'id': f'abc{i}'}
for i in range(60)
]
with self.assertRaises(Exception):
from wagtail.blocks import StreamValue
4.5 Image Rendition Tests
Wagtail generates image renditions (thumbnails, resized versions) on demand. Test that renditions are generated with correct dimensions and that filter strings parse properly.
from django.test import TestCase
from wagtail.images.models import Image, Rendition
from django.core.files.uploadedfile import SimpleUploadedFile
from io import BytesIO
from PIL import Image as PILImage
class ImageRenditionTests(TestCase):
def setUp(self):
# Create a test image
img_data = BytesIO()
pil_img = PILImage.new('RGB', (800, 600), color='red')
pil_img.save(img_data, format='JPEG')
img_data.seek(0)
self.image = Image.objects.create(
title='Test Image',
file=SimpleUploadedFile(
'test.jpg', img_data.read(),
content_type='image/jpeg'
),
width=800,
height=600,
)
def test_fill_rendition(self):
"""fill-400x300 creates a 400x300 cropped rendition."""
rendition = self.image.get_rendition('fill-400x300')
self.assertEqual(rendition.width, 400)
self.assertEqual(rendition.height, 300)
self.assertIsNotNone(rendition.file)
def test_max_rendition(self):
"""max-500x500 downsizes to fit within bounds."""
rendition = self.image.get_rendition('max-500x500')
self.assertLessEqual(rendition.width, 500)
self.assertLessEqual(rendition.height, 500)
self.assertAlmostEqual(
rendition.width / rendition.height,
800 / 600,
delta=0.1
)
def test_width_rendition(self):
"""width-300 sets width to 300, preserves aspect ratio."""
rendition = self.image.get_rendition('width-300')
self.assertEqual(rendition.width, 300)
expected_height = int(300 * 600 / 800)
self.assertEqual(rendition.height, expected_height)
def test_invalid_filter_raises_error(self):
"""An invalid filter string should raise an error."""
with self.assertRaises(Exception):
self.image.get_rendition('invalid-filter-999')
def test_multiple_renditions_reuse_same_file(self):
"""Same filter should reuse the same rendition."""
r1 = self.image.get_rendition('fill-100x100')
r2 = self.image.get_rendition('fill-100x100')
self.assertEqual(r1.id, r2.id)
4.6 Setting Up GitHub Actions CI/CD
Create a .github/workflows/ci.yml file in your project root. This workflow runs tests on every push and pull request to the main branch, and deploys to staging on a successful merge.
name: Wagtail CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_USER: wagtail
POSTGRES_PASSWORD: wagtail
POSTGRES_DB: test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements-dev.txt
- name: Lint with flake8
run: |
source venv/bin/activate
flake8 . --count --max-complexity=10 --statistics
- name: Check for missing migrations
run: |
source venv/bin/activate
python manage.py makemigrations --check --dry-run
- name: Run tests with coverage
env:
DATABASE_URL: postgres://wagtail:wagtail@localhost:5432/test_db
run: |
source venv/bin/activate
pytest --cov=myproject --cov=myapp
--cov-report=term-missing
--cov-fail-under=80
- name: Archive coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: htmlcov/
deploy-staging:
needs: [test]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install PythonAnywhere CLI
run: pip install pythonanywhere
- name: Deploy to staging
env:
PA_API_TOKEN: ${{ secrets.PA_API_TOKEN }}
PA_USERNAME: ${{ secrets.PA_USERNAME }}
run: |
pythonanywhere --host www.pythonanywhere.com
bash --command "cd ~/staging-site &&
git pull origin main &&
source venv/bin/activate &&
pip install -r requirements.txt &&
python manage.py migrate --noinput &&
python manage.py collectstatic --noinput &&
touch /var/www/staging-site/wsgi.py"
4.7 Test Quality Gates and Best Practices
Enforce quality gates to prevent low-quality or breaking changes from reaching production:
# Quality gate enforcement in CI
MIN_COVERAGE = 80
def check_quality(test_exit_code, coverage_pct):
if test_exit_code != 0:
return 'FAILED: Test failures detected'
if coverage_pct < MIN_COVERAGE:
return f'FAILED: Coverage {coverage_pct}% below threshold {MIN_COVERAGE}%'
return f'PASSED: All tests pass, coverage {coverage_pct}% >= {MIN_COVERAGE}%'
5. Common Errors and Solutions
Error: 'WagtailPageTestCase' object has no attribute 'assertCanCreate'
This error occurs when the test class doesn't inherit from WagtailPageTestCase (instead using plain TestCase). The assertCanCreate and assertCanNotCreate methods are only available on WagtailPageTestCase. Fix: change your class to class MyTests(WagtailPageTestCase): and ensure you import it from wagtail.test.utils.
Error: 'add_child()' called on unsaved model instance
Page models must be saved before calling add_child. Wagtail requires the parent page to have a database ID before children can be attached. Fix: call root.add_child(instance=page) instead of page.add_child(...) — the parent must already exist in the database.
Error: StreamField block data not appearing in API response
The API returns StreamField data only when the page is live (published). Draft pages return null for StreamField content. Fix: always call page.save_revision().publish() in your test setup.
Error: Image rendition returns 404 for invalid filter
An invalid filter string (e.g., typo in fill-400-300 instead of fill-400x300) causes an error. Fix: validate filter strings against Wagtail's known filter specification. Common mistakes: using dash instead of x for dimension separators.
Error: GitHub Actions pipeline fails with 'ModuleNotFoundError'
The CI runner's Python environment doesn't have your project's dependencies. Fix: verify your requirements.txt includes ALL dependencies and install both requirements-prod.txt and requirements-dev.txt in CI.
6. Summary Checklist
- Created a test settings file (settings/test.py) with in-memory SQLite database
- Installed pytest, pytest-django, pytest-cov, and factory-boy
- Configured pytest with pytest.ini or pyproject.toml
- Written page model tests for creation, routing, custom methods, and live queries
- Written API tests for list, detail, filtering, pagination, and authentication
- Written StreamField validation tests for block types, constraints, and custom validation
- Written image rendition tests for different filter strings and dimension accuracy
- Set up GitHub Actions CI pipeline with linting, migration checks, and test coverage
- Configured automatic staging deployment on successful main branch builds
- Enforced coverage thresholds (greater than or equal to 80%) and zero-failure quality gates
- Verified tests run locally with pytest before pushing
- Ensured CI pipeline blocks PRs with failing tests
7. Practice Exercise
Challenge: Build a Test Suite for a Wagtail Event Calendar
Your task is to write a complete test suite for a Wagtail-powered event calendar application. The app has:
- An EventIndexPage that lists upcoming events with past/future filtering
- An EventPage model with fields: title, date, location (CharField), body (StreamField with RichTextBlock and ImageGalleryBlock), registration_url (URLField, optional)
- A REST API endpoint at /api/v2/events/ that returns future events only
- A custom generate_ics_file() method on EventPage that creates a calendar file
Your test suite must cover:
- Creating an EventPage under EventIndexPage (not under HomePage)
- Route resolution for individual event pages
- REST API returns only future events (filtering by date)
- StreamField body with valid RichTextBlock and ImageGalleryBlock content
- The generate_ics_file() method produces valid iCal output
- An event without a registration URL still passes validation
- Submitting registration with an invalid URL is rejected
Write the tests in a file called tests/test_events.py and run them with pytest -v. Aim for at least 85% test coverage on the events app.
8. Next Steps
You have learned to write comprehensive tests for Wagtail page models, REST API endpoints, StreamField validation, and image renditions. Your CI/CD pipeline now enforces code quality and deploys automatically to staging. This is the foundation of a production-grade Wagtail deployment that your team can iterate on with confidence.
Up next: Wagtail Performance Optimization and Caching — Learn to optimize Wagtail page rendering with template fragment caching, Django's cache framework, the Wagtail cache invalidator, frontend cache backends (Varnish, Cloudflare), database query optimization with select_related and prefetch_related, image rendition caching strategies, and performance profiling with Django Debug Toolbar and Wagtail's built-in metrics.
Further resources:
- Wagtail Testing Guide — Official Wagtail testing documentation
- pytest Documentation — pytest features and plugins
- GitHub Actions Documentation — Workflow syntax and examples
- Factory Boy Documentation — Test data factories for Django models
- Coverage.py Documentation — Code coverage measurement and reporting
Common pitfalls to avoid:
- Don't use production database settings in tests — always use an in-memory SQLite or a dedicated test database
- Don't forget to publish pages in test setup — unpublished pages return null StreamField content in API responses
- Avoid hardcoding page IDs in tests — use dynamic lookups with Page.objects.get(slug=...)
- Don't skip the migration check in CI — missing migrations cause hard-to-debug production errors
- Avoid testing Wagtail's own functionality — test YOUR custom code, not Wagtail's internals
Comments (0)
This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!
ReplyLeave a Comment