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
Install test dependencies

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
pytest.ini configuration

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 = {}
test settings file

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)
Page model tests with WagtailPageTestCase

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'])
REST API tests with Django's APIClient

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
StreamField validation tests

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)
Image rendition tests

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"
Complete GitHub Actions CI/CD pipeline

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}%'
Quality gate enforcement

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:

  1. Creating an EventPage under EventIndexPage (not under HomePage)
  2. Route resolution for individual event pages
  3. REST API returns only future events (filtering by date)
  4. StreamField body with valid RichTextBlock and ImageGalleryBlock content
  5. The generate_ics_file() method produces valid iCal output
  6. An event without a registration URL still passes validation
  7. 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:

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

Gataya Med

DevOps Engineer & Backend Developer. Sharing insights on cloud, automation, and scalable systems.

Comments (0)

Sarah Chen July 18, 2026

This is exactly what I needed! The initContainer approach solved our migration issues completely. Thanks for the detailed guide!

Reply

Leave a Comment