Repository: wagtail/wagtail

Issue:
Test fails with Wagtail 2beta 
### Issue Summary

A "self.assertCanCreate(self.root, ArticlePage, {" line which has this below to test streamfield content is failing on the google_map line.

     'page_content': json.dumps([
                    {"type": "heading", "value": "Heading Test"},
                    {"type": "embedded_video", "value": "https://xxxxxxxxxxxxxxxxxxx"},
                    {"type": "fox_stories", "value": {"display_mode": "standard", "tagged_by_keyword": "business", "stories_limit": "4"}},
                    {"type": "paragraph", "value": "<p>This is text</p>"},
                    {"type": "google_map", "value": {"map_pins": [], "map_lat": "12.3434", "map_zoom_level": "14", "map_long": "-52.34334"}}
                ]),

Error from running tests: 

    Traceback (most recent call last):
      File "/code/researchsite/tests/test_create_pages.py", line 64, in test_can_create_article_page
        {"type": "google_map", "value": {"map_pins": [], "map_lat": "12.3434", "map_zoom_level": "14", "map_long": "-52.34334"}}
      File "/usr/local/lib/python3.5/dist-packages/wagtail/tests/utils.py", line 275, in assertCanCreate
        response = self.client.post(add_url, data, follow=True)
      File "/usr/local/lib/python3.5/dist-packages/django/test/client.py", line 548, in post
        secure=secure, **extra)
      File "/usr/local/lib/python3.5/dist-packages/django/test/client.py", line 350, in post
        secure=secure, **extra)
      File "/usr/local/lib/python3.5/dist-packages/django/test/client.py", line 416, in generic
        return self.request(**r)
      File "/usr/local/lib/python3.5/dist-packages/django/test/client.py", line 501, in request
        six.reraise(*exc_info)
      File "/usr/local/lib/python3.5/dist-packages/django/utils/six.py", line 686, in reraise
        raise value
      File "/usr/local/lib/python3.5/dist-packages/django/core/handlers/exception.py", line 41, in inner
        response = get_response(request)
      File "/usr/local/lib/python3.5/dist-packages/django/core/handlers/base.py", line 249, in _legacy_get_response
        response = self._get_response(request)
      File "/usr/local/lib/python3.5/dist-packages/django/core/handlers/base.py", line 187, in _get_response
        response = self.process_exception_by_middleware(e, request)
      File "/usr/local/lib/python3.5/dist-packages/django/core/handlers/base.py", line 185, in _get_response
        response = wrapped_callback(request, *callback_args, **callback_kwargs)
      File "/usr/local/lib/python3.5/dist-packages/django/views/decorators/cache.py", line 43, in _cache_controlled
        response = viewfunc(request, *args, **kw)
      File "/usr/local/lib/python3.5/dist-packages/wagtail/admin/urls/__init__.py", line 95, in wrapper
        return view_func(request, *args, **kwargs)
      File "/usr/local/lib/python3.5/dist-packages/wagtail/admin/decorators.py", line 28, in decorated_view
        return view_func(request, *args, **kwargs)
      File "/usr/local/lib/python3.5/dist-packages/wagtail/admin/views/pages.py", line 194, in create
        if form.is_valid():
      File "/usr/local/lib/python3.5/dist-packages/modelcluster/forms.py", line 308, in is_valid
        form_is_valid = super(ClusterForm, self).is_valid()
      File "/usr/local/lib/python3.5/dist-packages/django/forms/forms.py", line 183, in is_valid
        return self.is_bound and not self.errors
      File "/usr/local/lib/python3.5/dist-packages/django/forms/forms.py", line 175, in errors
        self.full_clean()
      File "/usr/local/lib/python3.5/dist-packages/django/forms/forms.py", line 384, in full_clean
        self._clean_fields()
      File "/usr/local/lib/python3.5/dist-packages/django/forms/forms.py", line 396, in _clean_fields
        value = field.widget.value_from_datadict(self.data, self.files, self.add_prefix(name))
      File "/usr/local/lib/python3.5/dist-packages/wagtail/core/blocks/base.py", line 512, in value_from_datadict
        return self.block_def.value_from_datadict(data, files, name)
      File "/usr/local/lib/python3.5/dist-packages/wagtail/core/blocks/stream_block.py", line 162, in value_from_datadict
        child_block.value_from_datadict(data, files, '%s-%d-value' % (prefix, i)),
      File "/usr/local/lib/python3.5/dist-packages/wagtail/core/blocks/field_block.py", line 69, in value_from_datadict
        return self.value_from_form(self.field.widget.value_from_datadict(data, files, prefix))
      File "/usr/local/lib/python3.5/dist-packages/wagtail/admin/rich_text/editors/draftail/__init__.py", line 56, in value_from_datadict
        return self.converter.to_database_format(original_value)
      File "/usr/local/lib/python3.5/dist-packages/wagtail/admin/rich_text/converters/contentstate.py", line 93, in to_database_format
        return self.exporter.render(json.loads(contentstate_json))
      File "/usr/lib/python3.5/json/__init__.py", line 319, in loads
        return _default_decoder.decode(s)
      File "/usr/lib/python3.5/json/decoder.py", line 339, in decode
        obj, end = self.raw_decode(s, idx=_w(s, 0).end())
      File "/usr/lib/python3.5/json/decoder.py", line 357, in raw_decode
        raise JSONDecodeError("Expecting value", s, err.value) from None
    json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)


Not sure what this is, possibly not a bug but something I've done wrong, think I've renamed all the module names though as in the release notes. 

### Technical details

* Python version: Python3.5
* Django version:1.11.9
* Wagtail version: 2 beta



Discussion:
Thanks for the report @jossingram. It looks like this is failing on the rich text field - presumably because the new Draftail editor works with a JSON representation of the text, and so the code to support it isn't expecting to receive the HTML string (`<p>This is text</p>`).

Unfortunately the JSON representation isn't particularly human-readable/writable, which goes against the principle of assertCanCreate (which expects you to pass the page content in the same format that the browser would submit it). I'll have to ponder the best way to resolve this...
@jossingram Would you be willing to share the complete `self.assertCanCreate` statement from your test (with data anonymised if necessary), please? That would give me a better idea of how `assertCanCreate` is being used "in the wild", and the best way to update its behaviour to fix this.

I suspect the fix will probably involve adding 'translation' wrapper functions around rich text content, to convert the human-readable HTML-ish representation into the contentState JSON used by the editor internally, something like:

    'page_content': json.dumps([
        # ...
        {"type": "paragraph", "value": html_to_contentstate("<p>This is text</p>")},
    ]),

As this is a breaking change, we'll provide details of how to update existing tests as part of the 2.0 release notes.

@timheap - just wondering if you have any thoughts on other approaches we can take here (as the original developer of `assertCanCreate` et al)? Stated more generally, the problem here is that `assertCanCreate` ultimately relies on the developer hand-crafting a form POST body, which is an interface that wasn't built with user-friendliness or API stability in mind. In this case, changing the representation of rich text fields is what's broken it, but I dare say there are other places where the POST representation is rather intractable without knowledge of Wagtail / Django internals (inline panels / formset management forms is another that springs to mind). One possibility is that we bypass the admin view entirely, and have `assertCanCreate` test creation at the model level instead - but I guess that undermines most or all of the reason for `assertCanCreate` to exist in the first place...
@gasman Your suggestion sounds sensible. In the past, I've created similar helpers to make the creation of inline panel and stream field data easier. I've put the code for the helpers [in this gist](https://gist.github.com/timheap/02cb81557526fbbf49e9c90583bbabac). If you're interested in putting something like this in Wagtail, feel free to reuse the code. Adding another helper to turn HTML in to Draft.js JSON would be a sensible addition to these helpers.

The helpers from the gist can be used as follows, a test case copied from an actual project:

```python
from project.models import ContentPage
from project.tests.base import BasicSiteTestCase
from project.tests.utils import nested_form_data, streamfield


class TestContentPageAdmin(BasicSiteTestCase):
    def test_create_page(self):
        self.assertCanCreate(self.home_page, ContentPage, nested_form_data({
            'title': 'Content Page',
            'banner_image': self.banner_image.pk,
            'body': streamfield([
                ('text', '<p>hello, world!</p>'),
                ('heading1', 'Heading'),
                ('block_quote', {
                    'quote': 'Statement',
                    'attribution': 'Person',
                }),
                ('heading2', 'Heading'),
                ('image_with_caption', {
                    'image': self.banner_image.pk,
                    'caption': 'Caption',
                }),
                ('embed', 'https://www.youtube.com/watch?v=aNW4vDgQW7M'),
            ]),
            'related_things': inline_formset([
                {'thing': 'foo'},
                {'thing': 'bar'},
            ]),
        }))

        # Make sure it renders
        page = ContentPage.objects.get()
        response = self.client.get(page.url)
        self.assertEqual(response.status_code, 200)
```

Resolving patch:
diff --git a/wagtail/admin/rich_text/editors/draftail/__init__.py b/wagtail/admin/rich_text/editors/draftail/__init__.py
--- a/wagtail/admin/rich_text/editors/draftail/__init__.py
+++ b/wagtail/admin/rich_text/editors/draftail/__init__.py
@@ -33,16 +33,22 @@ def __init__(self, *args, **kwargs):
 
         super().__init__(*args, **kwargs)
 
+    def translate_value(self, value):
+        # Convert database rich text representation to the format required by
+        # the input field
+
+        if value is None:
+            value = ''
+
+        return self.converter.from_database_format(value)
+
     def render(self, name, value, attrs=None):
         if attrs is None:
             attrs = {}
 
         attrs['data-draftail-input'] = True
 
-        if value is None:
-            value = ''
-
-        translated_value = self.converter.from_database_format(value)
+        translated_value = self.translate_value(value)
         return super().render(name, translated_value, attrs)
 
     def render_js_init(self, id_, name, value):
diff --git a/wagtail/admin/rich_text/editors/hallo.py b/wagtail/admin/rich_text/editors/hallo.py
--- a/wagtail/admin/rich_text/editors/hallo.py
+++ b/wagtail/admin/rich_text/editors/hallo.py
@@ -102,11 +102,16 @@ def __init__(self, *args, **kwargs):
 
         super().__init__(*args, **kwargs)
 
-    def render(self, name, value, attrs=None):
+    def translate_value(self, value):
+        # Convert database rich text representation to the format required by
+        # the input field
         if value is None:
-            translated_value = None
-        else:
-            translated_value = self.converter.from_database_format(value)
+            return None
+
+        return self.converter.from_database_format(value)
+
+    def render(self, name, value, attrs=None):
+        translated_value = self.translate_value(value)
         return super().render(name, translated_value, attrs)
 
     def render_js_init(self, id_, name, value):


Test patch:
diff --git a/docs/advanced_topics/testing.rst b/docs/advanced_topics/testing.rst
--- a/docs/advanced_topics/testing.rst
+++ b/docs/advanced_topics/testing.rst
@@ -44,14 +44,21 @@ WagtailPageTests
 
         .. code-block:: python
 
+            from wagtail.tests.utils.form_data import nested_form_data, streamfield
+
             def test_can_create_content_page(self):
                 # Get the HomePage
                 root_page = HomePage.objects.get(pk=2)
 
                 # Assert that a ContentPage can be made here, with this POST data
-                self.assertCanCreate(root_page, ContentPage, {
+                self.assertCanCreate(root_page, ContentPage, nested_form_data({
                     'title': 'About us',
-                    'body': 'Lorem ipsum dolor sit amet')
+                    'body': streamfield([
+                        ('text', 'Lorem ipsum dolor sit amet'),
+                    ])
+                }))
+
+        See :ref:`form_data_test_helpers` for a set of functions useful for constructing POST data.
 
     .. automethod:: assertAllowedParentPageTypes
 
@@ -79,3 +86,19 @@ WagtailPageTests
                 # A HomePage can have ContentPage and EventIndex children
                 self.assertAllowedParentPageTypes(
                     HomePage, {ContentPage, EventIndex})
+
+
+.. _form_data_test_helpers:
+
+Form data helpers
+=================
+
+.. automodule:: wagtail.tests.utils.form_data
+
+   .. autofunction:: nested_form_data
+
+   .. autofunction:: rich_text
+
+   .. autofunction:: streamfield
+
+   .. autofunction:: inline_formset
diff --git a/wagtail/core/tests/test_tests.py b/wagtail/core/tests/test_tests.py
--- a/wagtail/core/tests/test_tests.py
+++ b/wagtail/core/tests/test_tests.py
@@ -1,10 +1,14 @@
+import json
+
 from django.test import TestCase
 
+from wagtail.admin.tests.test_contentstate import content_state_equal
 from wagtail.core.models import PAGE_MODEL_CLASSES, Page, Site
 from wagtail.tests.testapp.models import (
     BusinessChild, BusinessIndex, BusinessNowherePage, BusinessSubIndex, EventIndex, EventPage,
-    SimplePage, StreamPage)
+    SectionedRichTextPage, SimplePage, StreamPage)
 from wagtail.tests.utils import WagtailPageTests, WagtailTestUtils
+from wagtail.tests.utils.form_data import inline_formset, nested_form_data, rich_text, streamfield
 
 
 class TestAssertTagInHTML(WagtailTestUtils, TestCase):
@@ -100,9 +104,59 @@ def test_assert_can_create(self):
             ]}''',
             'body-1-order': '1',
             'body-1-deleted': '',
-            'body-count': '2'
+            'body-2-type': 'product',
+            'body-2-value-name': 'pegs',
+            'body-2-value-price': 'a pound',
+            'body-2-order': '2',
+            'body-2-deleted': '',
+            'body-count': '3'
+        })
+
+        self.assertCanCreate(self.root, SectionedRichTextPage, {
+            'title': 'Fight Club',
+            'sections-TOTAL_FORMS': '2',
+            'sections-INITIAL_FORMS': '0',
+            'sections-MIN_NUM_FORMS': '0',
+            'sections-MAX_NUM_FORMS': '1000',
+            'sections-0-body': '''{"entityMap": {},"blocks": [
+                {"inlineStyleRanges": [], "text": "Rule 1: You do not talk about Fight Club", "depth": 0, "type": "unstyled", "key": "00000", "entityRanges": []}
+            ]}''',
+            'sections-0-ORDER': '0',
+            'sections-0-DELETE': '',
+            'sections-1-body': '''{"entityMap": {},"blocks": [
+                {"inlineStyleRanges": [], "text": "Rule 2: You DO NOT talk about Fight Club", "depth": 0, "type": "unstyled", "key": "00000", "entityRanges": []}
+            ]}''',
+            'sections-1-ORDER': '0',
+            'sections-1-DELETE': '',
         })
 
+    def test_assert_can_create_with_form_helpers(self):
+        # same as test_assert_can_create, but using the helpers from wagtail.tests.utils.form_data
+        # as an end-to-end test
+        self.assertFalse(EventIndex.objects.exists())
+        self.assertCanCreate(self.root, EventIndex, nested_form_data({
+            'title': 'Event Index',
+            'intro': rich_text('<p>Event intro</p>')
+        }))
+        self.assertTrue(EventIndex.objects.exists())
+
+        self.assertCanCreate(self.root, StreamPage, nested_form_data({
+            'title': 'Flierp',
+            'body': streamfield([
+                ('text', 'Dit is onze mooie text'),
+                ('rich_text', rich_text('<p>Dit is onze mooie text in een ferrari</p>')),
+                ('product', {'name': 'pegs', 'price': 'a pound'}),
+            ]),
+        }))
+
+        self.assertCanCreate(self.root, SectionedRichTextPage, nested_form_data({
+            'title': 'Fight Club',
+            'sections': inline_formset([
+                {'body': rich_text('<p>Rule 1: You do not talk about Fight Club</p>')},
+                {'body': rich_text('<p>Rule 2: You DO NOT talk about Fight Club</p>')},
+            ]),
+        }))
+
     def test_assert_can_create_subpage_rules(self):
         simple_page = SimplePage(title='Simple Page', slug='simple', content="hello")
         self.root.add_child(instance=simple_page)
@@ -137,3 +191,97 @@ def test_assert_allowed_parent_page_types(self):
         self.assertAllowedParentPageTypes(BusinessIndex, all_but_business)
         with self.assertRaises(AssertionError):
             self.assertAllowedParentPageTypes(BusinessSubIndex, {BusinessSubIndex, BusinessIndex})
+
+
+class TestFormDataHelpers(TestCase):
+    def test_nested_form_data(self):
+        result = nested_form_data({
+            'foo': 'bar',
+            'parent': {
+                'child': 'field',
+            },
+        })
+        self.assertEqual(
+            result,
+            {'foo': 'bar', 'parent-child': 'field'}
+        )
+
+    def test_streamfield(self):
+        result = nested_form_data({'content': streamfield([
+            ('text', 'Hello, world'),
+            ('text', 'Goodbye, world'),
+            ('coffee', {'type': 'latte', 'milk': 'soya'}),
+        ])})
+
+        self.assertEqual(
+            result,
+            {
+                'content-count': '3',
+                'content-0-type': 'text',
+                'content-0-value': 'Hello, world',
+                'content-0-order': '0',
+                'content-0-deleted': '',
+                'content-1-type': 'text',
+                'content-1-value': 'Goodbye, world',
+                'content-1-order': '1',
+                'content-1-deleted': '',
+                'content-2-type': 'coffee',
+                'content-2-value-type': 'latte',
+                'content-2-value-milk': 'soya',
+                'content-2-order': '2',
+                'content-2-deleted': '',
+            }
+        )
+
+    def test_inline_formset(self):
+        result = nested_form_data({'lines': inline_formset([
+            {'text': 'Hello'},
+            {'text': 'World'},
+        ])})
+
+        self.assertEqual(
+            result,
+            {
+                'lines-TOTAL_FORMS': '2',
+                'lines-INITIAL_FORMS': '0',
+                'lines-MIN_NUM_FORMS': '0',
+                'lines-MAX_NUM_FORMS': '1000',
+                'lines-0-text': 'Hello',
+                'lines-0-ORDER': '0',
+                'lines-0-DELETE': '',
+                'lines-1-text': 'World',
+                'lines-1-ORDER': '1',
+                'lines-1-DELETE': '',
+            }
+        )
+
+    def test_default_rich_text(self):
+        result = rich_text('<h2>title</h2><p>para</p>')
+        self.assertTrue(content_state_equal(
+            json.loads(result),
+            {
+                'entityMap': {},
+                'blocks': [
+                    {'inlineStyleRanges': [], 'text': 'title', 'depth': 0, 'type': 'header-two', 'key': '00000', 'entityRanges': []},
+                    {'inlineStyleRanges': [], 'text': 'para', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
+                ]
+            }
+        ))
+
+    def test_rich_text_with_custom_features(self):
+        # feature list doesn't allow <h2>, so it should become an unstyled paragraph block
+        result = rich_text('<h2>title</h2><p>para</p>', features=['p'])
+        self.assertTrue(content_state_equal(
+            json.loads(result),
+            {
+                'entityMap': {},
+                'blocks': [
+                    {'inlineStyleRanges': [], 'text': 'title', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
+                    {'inlineStyleRanges': [], 'text': 'para', 'depth': 0, 'type': 'unstyled', 'key': '00000', 'entityRanges': []},
+                ]
+            }
+        ))
+
+    def test_rich_text_with_alternative_editor(self):
+        result = rich_text('<h2>title</h2><p>para</p>', editor='hallo')
+        self.assertEqual(result, '<h2>title</h2><p>para</p>')
diff --git a/wagtail/tests/testapp/migrations/0029_auto_20180215_1950.py b/wagtail/tests/testapp/migrations/0029_auto_20180215_1950.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/testapp/migrations/0029_auto_20180215_1950.py
@@ -0,0 +1,21 @@
+# Generated by Django 2.0 on 2018-02-15 10:50
+
+from django.db import migrations
+import wagtail.core.blocks
+import wagtail.core.fields
+import wagtail.tests.testapp.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tests', '0028_headcountrelatedmodelusingpk'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='streampage',
+            name='body',
+            field=wagtail.core.fields.StreamField((('text', wagtail.core.blocks.CharBlock()), ('rich_text', wagtail.core.blocks.RichTextBlock()), ('image', wagtail.tests.testapp.models.ExtendedImageChooserBlock()), ('product', wagtail.core.blocks.StructBlock((('name', wagtail.core.blocks.CharBlock()), ('price', wagtail.core.blocks.CharBlock())))))),
+        ),
+    ]
diff --git a/wagtail/tests/testapp/models.py b/wagtail/tests/testapp/models.py
--- a/wagtail/tests/testapp/models.py
+++ b/wagtail/tests/testapp/models.py
@@ -27,7 +27,7 @@
     FORM_FIELD_CHOICES, AbstractEmailForm, AbstractFormField, AbstractFormSubmission)
 from wagtail.contrib.settings.models import BaseSetting, register_setting
 from wagtail.contrib.table_block.blocks import TableBlock
-from wagtail.core.blocks import CharBlock, RichTextBlock
+from wagtail.core.blocks import CharBlock, RichTextBlock, StructBlock
 from wagtail.core.fields import RichTextField, StreamField
 from wagtail.core.models import Orderable, Page, PageManager, PageQuerySet
 from wagtail.documents.edit_handlers import DocumentChooserPanel
@@ -901,6 +901,10 @@ class StreamPage(Page):
         ('text', CharBlock()),
         ('rich_text', RichTextBlock()),
         ('image', ExtendedImageChooserBlock()),
+        ('product', StructBlock([
+            ('name', CharBlock()),
+            ('price', CharBlock()),
+        ])),
     ])
 
     api_fields = ('body',)
diff --git a/wagtail/tests/utils/__init__.py b/wagtail/tests/utils/__init__.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/utils/__init__.py
@@ -0,0 +1,2 @@
+from .page_tests import *  # NOQA
+from .wagtail_tests import *  # NOQA
diff --git a/wagtail/tests/assert_logs.py b/wagtail/tests/utils/assert_logs.py
similarity index 100%
rename from wagtail/tests/assert_logs.py
rename to wagtail/tests/utils/assert_logs.py
diff --git a/wagtail/tests/utils/form_data.py b/wagtail/tests/utils/form_data.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/utils/form_data.py
@@ -0,0 +1,136 @@
+"""
+The ``assertCanCreate`` method requires page data to be passed in
+the same format that the page edit form would submit. For complex
+page types, it can be difficult to construct this data structure by hand;
+the ``wagtail.tests.utils.form_data`` module provides a set of helper
+functions to assist with this.
+"""
+
+from wagtail.admin.rich_text import get_rich_text_editor_widget
+
+
+def _nested_form_data(data):
+    if isinstance(data, dict):
+        items = data.items()
+    elif isinstance(data, list):
+        items = enumerate(data)
+
+    for key, value in items:
+        key = str(key)
+        if isinstance(value, (dict, list)):
+            for child_keys, child_value in _nested_form_data(value):
+                yield [key] + child_keys, child_value
+        else:
+            yield [key], value
+
+
+def nested_form_data(data):
+    """
+    Translates a nested dict structure into a flat form data dict
+    with hyphen-separated keys.
+
+    .. code-block:: python
+
+        nested_form_data({
+            'foo': 'bar',
+            'parent': {
+                'child': 'field',
+            },
+        })
+        # Returns: {'foo': 'bar', 'parent-child': 'field'}
+    """
+    return {'-'.join(key): value for key, value in _nested_form_data(data)}
+
+
+def streamfield(items):
+    """
+    Takes a list of (block_type, value) tuples and turns it in to
+    StreamField form data. Use this within a :func:`nested_form_data`
+    call, with the field name as the key.
+
+    .. code-block:: python
+
+        nested_form_data({'content': streamfield([
+            ('text', 'Hello, world'),
+        ])})
+        # Returns:
+        # {
+        #     'content-count': '1',
+        #     'content-0-type': 'text',
+        #     'content-0-value': 'Hello, world',
+        #     'content-0-order': '0',
+        #     'content-0-deleted': '',
+        # }
+    """
+    def to_block(index, item):
+        block, value = item
+        return {'type': block, 'value': value, 'deleted': '', 'order': str(index)}
+    data_dict = {str(index): to_block(index, item)
+                 for index, item in enumerate(items)}
+    data_dict['count'] = str(len(data_dict))
+    return data_dict
+
+
+def inline_formset(items, initial=0, min=0, max=1000):
+    """
+    Takes a list of form data for an InlineFormset and translates
+    it in to valid POST data. Use this within a :func:`nested_form_data`
+    call, with the formset relation name as the key.
+
+    .. code-block:: python
+
+        nested_form_data({'lines': inline_formset([
+            {'text': 'Hello'},
+            {'text': 'World'},
+        ])})
+        # Returns:
+        # {
+        #     'lines-TOTAL_FORMS': '2',
+        #     'lines-INITIAL_FORMS': '0',
+        #     'lines-MIN_NUM_FORMS': '0',
+        #     'lines-MAX_NUM_FORMS': '1000',
+        #     'lines-0-text': 'Hello',
+        #     'lines-0-ORDER': '0',
+        #     'lines-0-DELETE': '',
+        #     'lines-1-text': 'World',
+        #     'lines-1-ORDER': '1',
+        #     'lines-1-DELETE': '',
+        # }
+    """
+    def to_form(index, item):
+        defaults = {
+            'ORDER': str(index),
+            'DELETE': '',
+        }
+        defaults.update(item)
+        return defaults
+
+    data_dict = {str(index): to_form(index, item)
+                 for index, item in enumerate(items)}
+
+    data_dict.update({
+        'TOTAL_FORMS': str(len(data_dict)),
+        'INITIAL_FORMS': str(initial),
+        'MIN_NUM_FORMS': str(min),
+        'MAX_NUM_FORMS': str(max),
+    })
+    return data_dict
+
+
+def rich_text(value, editor='default', features=None):
+    """
+    Converts an HTML-like rich text string to the data format required by
+    the currently active rich text editor.
+
+    :param editor: An alternative editor name as defined in ``WAGTAILADMIN_RICH_TEXT_EDITORS``
+    :param features: A list of features allowed in the rich text content (see :ref:`rich_text_features`)
+
+    .. code-block:: python
+
+        self.assertCanCreate(root_page, ContentPage, nested_form_data({
+            'title': 'About us',
+            'body': rich_text('<p>Lorem ipsum dolor sit amet</p>'),
+        }))
+    """
+    widget = get_rich_text_editor_widget(editor, features)
+    return widget.translate_value(value)
diff --git a/wagtail/tests/utils/page_tests.py b/wagtail/tests/utils/page_tests.py
new file mode 100644
--- /dev/null
+++ b/wagtail/tests/utils/page_tests.py
@@ -0,0 +1,115 @@
+from django.test import TestCase
+from django.urls import reverse
+from django.utils.text import slugify
+
+from .wagtail_tests import WagtailTestUtils
+
+
+class WagtailPageTests(WagtailTestUtils, TestCase):
+    """
+    A set of asserts to help write tests for your own Wagtail site.
+    """
+    def setUp(self):
+        super().setUp()
+        self.login()
+
+    def _testCanCreateAt(self, parent_model, child_model):
+        return child_model in parent_model.allowed_subpage_models()
+
+    def assertCanCreateAt(self, parent_model, child_model, msg=None):
+        """
+        Assert a particular child Page type can be created under a parent
+        Page type. ``parent_model`` and ``child_model`` should be the Page
+        classes being tested.
+        """
+        if not self._testCanCreateAt(parent_model, child_model):
+            msg = self._formatMessage(msg, "Can not create a %s.%s under a %s.%s" % (
+                child_model._meta.app_label, child_model._meta.model_name,
+                parent_model._meta.app_label, parent_model._meta.model_name))
+            raise self.failureException(msg)
+
+    def assertCanNotCreateAt(self, parent_model, child_model, msg=None):
+        """
+        Assert a particular child Page type can not be created under a parent
+        Page type. ``parent_model`` and ``child_model`` should be the Page
+        classes being tested.
+        """
+        if self._testCanCreateAt(parent_model, child_model):
+            msg = self._formatMessage(msg, "Can create a %s.%s under a %s.%s" % (
+                child_model._meta.app_label, child_model._meta.model_name,
+                parent_model._meta.app_label, parent_model._meta.model_name))
+            raise self.failureException(msg)
+
+    def assertCanCreate(self, parent, child_model, data, msg=None):
+        """
+        Assert that a child of the given Page type can be created under the
+        parent, using the supplied POST data.
+
+        ``parent`` should be a Page instance, and ``child_model`` should be a
+        Page subclass. ``data`` should be a dict that will be POSTed at the
+        Wagtail admin Page creation method.
+        """
+        self.assertCanCreateAt(parent.specific_class, child_model)
+
+        if 'slug' not in data and 'title' in data:
+            data['slug'] = slugify(data['title'])
+        data['action-publish'] = 'action-publish'
+
+        add_url = reverse('wagtailadmin_pages:add', args=[
+            child_model._meta.app_label, child_model._meta.model_name, parent.pk])
+        response = self.client.post(add_url, data, follow=True)
+
+        if response.status_code != 200:
+            msg = self._formatMessage(msg, 'Creating a %s.%s returned a %d' % (
+                child_model._meta.app_label, child_model._meta.model_name, response.status_code))
+            raise self.failureException(msg)
+
+        if response.redirect_chain == []:
+            if 'form' not in response.context:
+                msg = self._formatMessage(msg, 'Creating a page failed unusually')
+                raise self.failureException(msg)
+            form = response.context['form']
+            if not form.errors:
+                msg = self._formatMessage(msg, 'Creating a page failed for an unknown reason')
+                raise self.failureException(msg)
+
+            errors = '\n'.join('  %s:\n    %s' % (field, '\n    '.join(errors))
+                               for field, errors in sorted(form.errors.items()))
+            msg = self._formatMessage(msg, 'Validation errors found when creating a %s.%s:\n%s' % (
+                child_model._meta.app_label, child_model._meta.model_name, errors))
+            raise self.failureException(msg)
+
+        explore_url = reverse('wagtailadmin_explore', args=[parent.pk])
+        if response.redirect_chain != [(explore_url, 302)]:
+            msg = self._formatMessage(msg, 'Creating a page %s.%s didnt redirect the user to the explorer, but to %s' % (
+                child_model._meta.app_label, child_model._meta.model_name,
+                response.redirect_chain))
+            raise self.failureException(msg)
+
+    def assertAllowedSubpageTypes(self, parent_model, child_models, msg=None):
+        """
+        Test that the only page types that can be created under
+        ``parent_model`` are ``child_models``.
+
+        The list of allowed child models may differ from those set in
+        ``Page.subpage_types``, if the child models have set
+        ``Page.parent_page_types``.
+        """
+        self.assertEqual(
+            set(parent_model.allowed_subpage_models()),
+            set(child_models),
+            msg=msg)
+
+    def assertAllowedParentPageTypes(self, child_model, parent_models, msg=None):
+        """
+        Test that the only page types that ``child_model`` can be created under
+        are ``parent_models``.
+
+        The list of allowed parent models may differ from those set in
+        ``Page.parent_page_types``, if the parent models have set
+        ``Page.subpage_types``.
+        """
+        self.assertEqual(
+            set(child_model.allowed_parent_page_models()),
+            set(parent_models),
+            msg=msg)
diff --git a/wagtail/tests/utils.py b/wagtail/tests/utils/wagtail_tests.py
similarity index 62%
rename from wagtail/tests/utils.py
rename to wagtail/tests/utils/wagtail_tests.py
--- a/wagtail/tests/utils.py
+++ b/wagtail/tests/utils/wagtail_tests.py
@@ -3,12 +3,9 @@
 from contextlib import contextmanager
 
 from django.contrib.auth import get_user_model
-from django.test import TestCase
 from django.test.testcases import assert_and_parse_html
-from django.urls import reverse
-from django.utils.text import slugify
 
-from wagtail.tests.assert_logs import _AssertLogsContext
+from .assert_logs import _AssertLogsContext
 
 
 class WagtailTestUtils:
@@ -218,113 +215,3 @@ def assertTagInTemplateScript(self, needle, haystack, count=None, msg_prefix='')
             )
         else:
             self.assertTrue(real_count != 0, msg_prefix + "Couldn't find '%s' in template script" % needle)
-
-
-class WagtailPageTests(WagtailTestUtils, TestCase):
-    """
-    A set of asserts to help write tests for your own Wagtail site.
-    """
-    def setUp(self):
-        super().setUp()
-        self.login()
-
-    def _testCanCreateAt(self, parent_model, child_model):
-        return child_model in parent_model.allowed_subpage_models()
-
-    def assertCanCreateAt(self, parent_model, child_model, msg=None):
-        """
-        Assert a particular child Page type can be created under a parent
-        Page type. ``parent_model`` and ``child_model`` should be the Page
-        classes being tested.
-        """
-        if not self._testCanCreateAt(parent_model, child_model):
-            msg = self._formatMessage(msg, "Can not create a %s.%s under a %s.%s" % (
-                child_model._meta.app_label, child_model._meta.model_name,
-                parent_model._meta.app_label, parent_model._meta.model_name))
-            raise self.failureException(msg)
-
-    def assertCanNotCreateAt(self, parent_model, child_model, msg=None):
-        """
-        Assert a particular child Page type can not be created under a parent
-        Page type. ``parent_model`` and ``child_model`` should be the Page
-        classes being tested.
-        """
-        if self._testCanCreateAt(parent_model, child_model):
-            msg = self._formatMessage(msg, "Can create a %s.%s under a %s.%s" % (
-                child_model._meta.app_label, child_model._meta.model_name,
-                parent_model._meta.app_label, parent_model._meta.model_name))
-            raise self.failureException(msg)
-
-    def assertCanCreate(self, parent, child_model, data, msg=None):
-        """
-        Assert that a child of the given Page type can be created under the
-        parent, using the supplied POST data.
-
-        ``parent`` should be a Page instance, and ``child_model`` should be a
-        Page subclass. ``data`` should be a dict that will be POSTed at the
-        Wagtail admin Page creation method.
-        """
-        self.assertCanCreateAt(parent.specific_class, child_model)
-
-        if 'slug' not in data and 'title' in data:
-            data['slug'] = slugify(data['title'])
-        data['action-publish'] = 'action-publish'
-
-        add_url = reverse('wagtailadmin_pages:add', args=[
-            child_model._meta.app_label, child_model._meta.model_name, parent.pk])
-        response = self.client.post(add_url, data, follow=True)
-
-        if response.status_code != 200:
-            msg = self._formatMessage(msg, 'Creating a %s.%s returned a %d' % (
-                child_model._meta.app_label, child_model._meta.model_name, response.status_code))
-            raise self.failureException(msg)
-
-        if response.redirect_chain == []:
-            if 'form' not in response.context:
-                msg = self._formatMessage(msg, 'Creating a page failed unusually')
-                raise self.failureException(msg)
-            form = response.context['form']
-            if not form.errors:
-                msg = self._formatMessage(msg, 'Creating a page failed for an unknown reason')
-                raise self.failureException(msg)
-
-            errors = '\n'.join('  %s:\n    %s' % (field, '\n    '.join(errors))
-                               for field, errors in sorted(form.errors.items()))
-            msg = self._formatMessage(msg, 'Validation errors found when creating a %s.%s:\n%s' % (
-                child_model._meta.app_label, child_model._meta.model_name, errors))
-            raise self.failureException(msg)
-
-        explore_url = reverse('wagtailadmin_explore', args=[parent.pk])
-        if response.redirect_chain != [(explore_url, 302)]:
-            msg = self._formatMessage(msg, 'Creating a page %s.%s didnt redirect the user to the explorer, but to %s' % (
-                child_model._meta.app_label, child_model._meta.model_name,
-                response.redirect_chain))
-            raise self.failureException(msg)
-
-    def assertAllowedSubpageTypes(self, parent_model, child_models, msg=None):
-        """
-        Test that the only page types that can be created under
-        ``parent_model`` are ``child_models``.
-
-        The list of allowed child models may differ from those set in
-        ``Page.subpage_types``, if the child models have set
-        ``Page.parent_page_types``.
-        """
-        self.assertEqual(
-            set(parent_model.allowed_subpage_models()),
-            set(child_models),
-            msg=msg)
-
-    def assertAllowedParentPageTypes(self, child_model, parent_models, msg=None):
-        """
-        Test that the only page types that ``child_model`` can be created under
-        are ``parent_models``.
-
-        The list of allowed parent models may differ from those set in
-        ``Page.parent_page_types``, if the parent models have set
-        ``Page.subpage_types``.
-        """
-        self.assertEqual(
-            set(child_model.allowed_parent_page_models()),
-            set(parent_models),
-            msg=msg)