瀏覽代碼

Add new test assertions to WagtailPageTestCase

- Add assertions, and move them to a new TestCase that allows use without forcing authentication for every test
- Add routes and preview modes to RoutablePageTest to facilitate testing
- Move assertion tests out of admin app
- Add custom test assertions for pages
- Use default value for exclude_csrf in assertPageIsEditable
- Use publish action when posting in assertPageIsEditable for better coverage
- Update assertPageIsEditable to always make both a GET and POST request
Andy Babic 2 年之前
父節點
當前提交
f6a92bf7d2

+ 20 - 0
wagtail/test/routablepage/models.py

@@ -1,6 +1,8 @@
 from django.http import HttpResponse
+from django.shortcuts import redirect
 
 from wagtail.contrib.routable_page.models import RoutablePage, path, re_path, route
+from wagtail.models import PreviewableMixin
 
 
 def routable_page_external_view(request, arg="ARG NOT SET"):
@@ -29,6 +31,14 @@ class RoutablePageTest(RoutablePage):
     def archive_by_category(self, request, category_slug):
         return HttpResponse("ARCHIVE BY CATEGORY: " + category_slug)
 
+    @route(r"^permanant-homepage-redirect/$")
+    def permanent_homepage_redirect(self, request):
+        return redirect("/", permanent=True)
+
+    @route(r"^temporary-homepage-redirect/$")
+    def temporary_homepage_redirect(self, request):
+        return redirect("/", permanent=False)
+
     @route(r"^external/(.+)/$")
     @route(r"^external-no-arg/$")
     def external_view(self, *args, **kwargs):
@@ -59,6 +69,16 @@ class RoutablePageTest(RoutablePage):
             "not-a-valid-route",
         ]
 
+    preview_modes = PreviewableMixin.DEFAULT_PREVIEW_MODES + [
+        ("extra", "Extra"),
+        ("broken", "Broken"),
+    ]
+
+    def serve_preview(self, request, mode_name):
+        if mode_name == "broken":
+            raise AttributeError("Something is broken!")
+        return super().serve_preview(request, mode_name)
+
 
 class RoutablePageWithOverriddenIndexRouteTest(RoutablePage):
     @route(r"^$")

+ 61 - 0
wagtail/test/utils/form_data.py

@@ -5,6 +5,8 @@ page types, it can be difficult to construct this data structure by hand;
 the ``wagtail.test.utils.form_data`` module provides a set of helper
 functions to assist with this.
 """
+import bs4
+from django.http import QueryDict
 
 from wagtail.admin.rich_text import get_rich_text_editor_widget
 
@@ -137,3 +139,62 @@ def rich_text(value, editor="default", features=None):
     """
     widget = get_rich_text_editor_widget(editor, features)
     return widget.format_value(value)
+
+
+def _querydict_from_form(form: bs4.Tag, exclude_csrf: bool = True) -> QueryDict:
+    data = QueryDict(mutable=True)
+    for input in form.find_all("input"):
+        name = input.attrs.get("name")
+        if (
+            name
+            and input.attrs.get("type", "") not in ("checkbox", "radio")
+            and (not exclude_csrf or name != "csrfmiddlewaretoken")
+        ):
+            data[name] = input.attrs.get("value", "")
+
+    for input in form.find_all("input", type="radio", checked=True):
+        name = input.attrs.get("name")
+        if name:
+            data[name] = input.attrs.get("value")
+
+    for input in form.find_all("input", type="checkbox", checked=True):
+        name = input.attrs.get("name")
+        if name:
+            data.appendlist(name, input.attrs.get("value", ""))
+
+    for textarea in form.find_all("textarea"):
+        name = textarea.attrs.get("name")
+        if name:
+            data[name] = textarea.get_text()
+
+    for select in form.find_all("select"):
+        name = select.attrs.get("name")
+        if name:
+            selected_value = False
+            for option in select.find_all("option", selected=True):
+                selected_value = True
+                data.appendlist(name, option.attrs.get("value", option.get_text()))
+            if not selected_value:
+                first_option = select.find("option")
+                if first_option:
+                    data[name] = first_option.attrs.get(
+                        "value", first_option.get_text()
+                    )
+    return data
+
+
+def querydict_from_html(
+    html: str, form_id: str = None, form_index: int = 0, exclude_csrf: bool = True
+) -> QueryDict:
+    soup = bs4.BeautifulSoup(html, "html5lib")
+    if form_id is not None:
+        form = soup.find("form", attrs={"id": form_id})
+        if form is None:
+            raise ValueError(f'No form was found with id "{form_id}".')
+        return _querydict_from_form(form, exclude_csrf)
+    else:
+        index = int(form_index)
+        for i, form in enumerate(soup.find_all("form", limit=index + 1)):
+            if i == index:
+                return _querydict_from_form(form, exclude_csrf)
+    raise ValueError(f"No form was found with index: {form_index}.")

+ 304 - 5
wagtail/test/utils/page_tests.py

@@ -1,18 +1,32 @@
+from typing import Any, Dict, Optional
+from unittest import mock
+
+from django.conf import settings
+from django.contrib.auth.base_user import AbstractBaseUser
+from django.http import Http404
 from django.test import TestCase
 from django.urls import reverse
+from django.utils.http import urlencode
 from django.utils.text import slugify
 
+from wagtail.coreutils import get_dummy_request
+from wagtail.models import Page
+
+from .form_data import querydict_from_html
 from .wagtail_tests import WagtailTestUtils
 
+AUTH_BACKEND = settings.AUTHENTICATION_BACKENDS[0]
+
 
-class WagtailPageTests(WagtailTestUtils, TestCase):
+class WagtailPageTestCase(WagtailTestUtils, TestCase):
     """
-    A set of asserts to help write tests for your own Wagtail site.
+    A set of assertions to help write tests for custom Wagtail page types
     """
 
-    def setUp(self):
-        super().setUp()
-        self.login()
+    @classmethod
+    def setUpClass(cls):
+        super().setUpClass()
+        cls.dummy_request = get_dummy_request()
 
     def _testCanCreateAt(self, parent_model, child_model):
         return child_model in parent_model.allowed_subpage_models()
@@ -148,3 +162,288 @@ class WagtailPageTests(WagtailTestUtils, TestCase):
         self.assertEqual(
             set(child_model.allowed_parent_page_models()), set(parent_models), msg=msg
         )
+
+    def assertPageIsRoutable(
+        self,
+        page: Page,
+        route_path: Optional[str] = "/",
+        msg: Optional[str] = None,
+    ):
+        """
+        Asserts that ``page`` can be routed to without raising a ``Http404`` error.
+
+        For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.
+        """
+        path = page.get_url(self.dummy_request)
+        if route_path != "/":
+            path = path.rstrip("/") + "/" + route_path.lstrip("/")
+
+        site = page.get_site()
+        if site is None:
+            msg = self._formatMessage(
+                msg,
+                'Failed to route to "%s" for %s "%s". The page does not belong to any sites.'
+                % (type(page).__name__, route_path, page),
+            )
+            raise self.failureException(msg)
+
+        path_components = [component for component in path.split("/") if component]
+        try:
+            page, args, kwargs = site.root_page.localized.specific.route(
+                self.dummy_request, path_components
+            )
+        except Http404:
+            msg = self._formatMessage(
+                msg,
+                'Failed to route to "%(route_path)s" for %(page_type)s "%(page)s". A Http404 was raised for path: "%(full_path)s".'
+                % {
+                    "route_path": route_path,
+                    "page_type": type(page).__name__,
+                    "page": page,
+                    "full_path": path,
+                },
+            )
+            raise self.failureException(msg)
+
+    def assertPageIsRenderable(
+        self,
+        page: Page,
+        route_path: Optional[str] = "/",
+        query_data: Optional[Dict[str, Any]] = None,
+        post_data: Optional[Dict[str, Any]] = None,
+        user: Optional[AbstractBaseUser] = None,
+        accept_404: Optional[bool] = False,
+        accept_redirect: Optional[bool] = False,
+        msg: Optional[str] = None,
+    ):
+        """
+        Asserts that ``page`` can be rendered without raising a fatal error.
+
+        For page types with multiple routes, you can use ``route_path`` to specify an alternate route to test.
+
+        When ``post_data`` is provided, the test makes a ``POST`` request with ``post_data`` in the request body. Otherwise, a ``GET`` request is made.
+
+        When supplied, ``query_data`` is converted to a querystring and added to the request URL (regardless of whether ``post_data`` is provided).
+
+        When ``user`` is provided, the test is conducted with them as the active user.
+
+        By default, the assertion will fail if the request to the page URL results in a 301, 302 or 404 HTTP response. If you are testing a page/route
+        where a 404 response is expected, you can use ``accept_404=True`` to indicate this, and the assertion will pass when encountering a 404. Likewise,
+        if you are testing a page/route where a redirect response is expected, you can use `accept_redirect=True` to indicate this, and the assertion will
+        pass when encountering 301 or 302.
+        """
+        if user:
+            self.client.force_login(user, AUTH_BACKEND)
+
+        path = page.get_url(self.dummy_request)
+        if route_path != "/":
+            path = path.rstrip("/") + "/" + route_path.lstrip("/")
+
+        post_kwargs = {}
+        if post_data is not None:
+            post_kwargs = {"data": post_data}
+            if query_data:
+                post_kwargs["QUERYSTRING"] = urlencode(query_data, doseq=True)
+        try:
+            if post_data is None:
+                resp = self.client.get(path, data=query_data)
+            else:
+                resp = self.client.post(path, **post_kwargs)
+        except Exception as e:
+            msg = self._formatMessage(
+                msg,
+                'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\n%(exc)s'
+                % {
+                    "route_path": route_path,
+                    "page_type": type(page).__name__,
+                    "page": page,
+                    "exc": e,
+                },
+            )
+            raise self.failureException(msg)
+        finally:
+            if user:
+                self.client.logout()
+
+        if (
+            resp.status_code == 200
+            or (accept_404 and resp.status_code == 404)
+            or (accept_redirect and resp.status_code in (301, 302))
+            or isinstance(resp, mock.MagicMock)
+        ):
+            return
+
+        msg = self._formatMessage(
+            msg,
+            'Failed to render route "%(route_path)s" for %(page_type)s "%(page)s":\nA HTTP %(code)s response was received for path: "%(full_path)s".'
+            % {
+                "route_path": route_path,
+                "page_type": type(page).__name__,
+                "page": page,
+                "code": resp.status_code,
+                "full_path": path,
+            },
+        )
+        raise self.failureException(msg)
+
+    def assertPageIsEditable(
+        self,
+        page: Page,
+        post_data: Optional[Dict[str, Any]] = None,
+        user: Optional[AbstractBaseUser] = None,
+        msg: Optional[str] = None,
+    ):
+        """
+        Asserts that the page edit view works for ``page`` without raising a fatal error.
+
+        When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.
+
+        After a successful ``GET`` request, a ``POST`` request is made with field data in the request body. If ``post_data`` is provided, that will be used for this purpose. If not, this data will be extracted from the ``GET`` response HTML.
+        """
+        if user:
+            # rule out permission issues early on
+            if not page.permissions_for_user(user).can_edit():
+                self._formatMessage(
+                    msg,
+                    'Failed to load edit view for %(page_type)s "%(page)s":\nUser "%(user)s" have insufficient permissions.'
+                    % {
+                        "page_type": type(page).__name__,
+                        "page": page,
+                        "user": user,
+                    },
+                )
+                raise self.failureException(msg)
+        else:
+            if not hasattr(self, "_pageiseditable_superuser"):
+                self._pageiseditable_superuser = self.create_superuser(
+                    "assertpageiseditable"
+                )
+            user = self._pageiseditable_superuser
+
+        self.client.force_login(user, AUTH_BACKEND)
+
+        path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
+        try:
+            response = self.client.get(path)
+        except Exception as e:
+            self.client.logout()
+            msg = self._formatMessage(
+                msg,
+                'Failed to load edit view via GET for %(page_type)s "%(page)s":\n%(exc)s'
+                % {"page_type": type(page).__name__, "page": page, "exc": e},
+            )
+            raise self.failureException(msg)
+        if response.status_code != 200:
+            self.client.logout()
+            msg = self._formatMessage(
+                msg,
+                'Failed to load edit view via GET for %(page_type)s "%(page)s":\nReceived response with HTTP status code: %(code)s.'
+                % {
+                    "page_type": type(page).__name__,
+                    "page": page,
+                    "code": response.status_code,
+                },
+            )
+            raise self.failureException(msg)
+
+        if post_data is not None:
+            data_to_post = post_data
+        else:
+            data_to_post = querydict_from_html(
+                response.content.decode(), form_id="page-edit-form"
+            )
+            data_to_post["action-publish"] = ""
+
+        try:
+            response = self.client.post(path, data_to_post)
+        except Exception as e:
+            msg = self._formatMessage(
+                msg,
+                'Failed to load edit view via POST for %(page_type)s "%(page)s":\n%(exc)s'
+                % {"page_type": type(page).__name__, "page": page, "exc": e},
+            )
+            raise self.failureException(msg)
+        finally:
+            page.save()  # undo any changes to page
+            self.client.logout()
+
+    def assertPageIsPreviewable(
+        self,
+        page: Page,
+        mode: Optional[str] = "",
+        post_data: Optional[Dict[str, Any]] = None,
+        user: Optional[AbstractBaseUser] = None,
+        msg: Optional[str] = None,
+    ):
+        """
+        Asserts that the page preview view can be loaded for ``page`` without raising a fatal error.
+
+        For page types that support multiple preview modes, ``mode`` can be used to specify the preview mode to be tested.
+
+        When ``user`` is provided, the test is conducted with them as the active user. Otherwise, a superuser is created and used for the test.
+
+        To load the preview, the test client needs to make a ``POST`` request including all required field data in the request body.
+        If ``post_data`` is provided, that will be used for this purpose. If not, the method will attempt to extract this data from the page edit view.
+        """
+        if not user:
+            if not hasattr(self, "_pageispreviewable_superuser"):
+                self._pageispreviewable_superuser = self.create_superuser(
+                    "assertpageispreviewable"
+                )
+            user = self._pageispreviewable_superuser
+
+        self.client.force_login(user, AUTH_BACKEND)
+
+        if post_data is None:
+            edit_path = reverse("wagtailadmin_pages:edit", kwargs={"page_id": page.id})
+            html = self.client.get(edit_path).content.decode()
+            post_data = querydict_from_html(html, form_id="page-edit-form")
+
+        preview_path = reverse(
+            "wagtailadmin_pages:preview_on_edit", kwargs={"page_id": page.id}
+        )
+        try:
+            response = self.client.post(
+                preview_path, data=post_data, QUERYSTRING=f"mode={mode}"
+            )
+            self.assertEqual(response.status_code, 200)
+            self.assertJSONEqual(
+                response.content.decode(),
+                {"is_valid": True, "is_available": True},
+            )
+        except Exception as e:
+            self.client.logout()
+            msg = self._formatMessage(
+                msg,
+                'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
+                % {
+                    "page_type": type(page).__name__,
+                    "page": page,
+                    "mode": mode,
+                    "exc": e,
+                },
+            )
+            raise self.failureException(msg)
+
+        try:
+            self.client.get(preview_path, data={"mode": mode})
+        except Exception as e:
+            msg = self._formatMessage(
+                msg,
+                'Failed to load preview for %(page_type)s "%(page)s" with mode="%(mode)s":\n%(exc)s'
+                % {
+                    "page_type": type(page).__name__,
+                    "page": page,
+                    "mode": mode,
+                    "exc": e,
+                },
+            )
+            raise self.failureException(msg)
+        finally:
+            self.client.logout()
+
+
+class WagtailPageTests(WagtailPageTestCase):
+    def setUp(self):
+        super().setUp()
+        self.login()

+ 189 - 0
wagtail/tests/test_form_data_utils.py

@@ -0,0 +1,189 @@
+from django.test import SimpleTestCase
+
+from wagtail.test.utils.form_data import querydict_from_html
+
+
+class TestQueryDictFromHTML(SimpleTestCase):
+    html = """
+    <form id="personal-details">
+        <input type="hidden" name="csrfmiddlewaretoken" value="Z783HTL5Bc2J54WhAtEeR3eefM1FBkq0EbTfNnYnepFGuJSOfvosFvwjeKYtMwFr">
+        <input type="hidden" name="no_value_input">
+        <input type="hidden" value="no name input">
+        <div class="mt-8 max-w-md">
+            <div class="grid grid-cols-1 gap-6">
+                <label class="block">
+                    <span class="text-gray-700">Full name</span>
+                    <input type="text" name="name" value="Jane Doe" class="mt-1 block w-full" placeholder="">
+                </label>
+                <label class="block">
+                    <span class="text-gray-700">Email address</span>
+                    <input type="email" name="email" class="mt-1 block w-full" value="jane@example.com" placeholder="name@example.com">
+                </label>
+            </div>
+        </div>
+    </form>
+    <form id="event-details">
+        <div class="mt-8 max-w-md">
+            <div class="grid grid-cols-1 gap-6">
+                <label class="block">
+                    <span class="text-gray-700">When is your event?</span>
+                    <input type="date" name="date" class="mt-1 block w-full" value="2023-01-01">
+                </label>
+                <label class="block">
+                    <span class="text-gray-700">What type of event is it?</span>
+                    <select name="event_type" class="block w-full mt-1">
+                        <option value="corporate">Corporate event</option>
+                        <option value="wedding">Wedding</option>
+                        <option value="birthday">Birthday</option>
+                        <option value="other" selected>Other</option>
+                    </select>
+                </label>
+                <label class="block">
+                    <span class="text-gray-700">What age groups is it suitable for?</span>
+                    <select name="ages" class="block w-full mt-1" multiple>
+                        <option>Infants</option>
+                        <option>Children</option>
+                        <option>Teenagers</option>
+                        <option selected>18-30</option>
+                        <option selected>30-50</option>
+                        <option>50-70</option>
+                        <option>70+</option>
+                    </select>
+                </label>
+            </div>
+        </div>
+    </form>
+    <form id="market-research">
+        <div class="mt-8 max-w-md">
+            <div class="grid grid-cols-1 gap-6">
+                <fieldset class="block">
+                    <legend>How many pets do you have?</legend>
+                    <div class="radio-list">
+                        <div class="radio">
+                            <label>
+                                <input type="radio" name="pets" value="0" />
+                                None
+                            </label>
+                        </div>
+                        <div class="radio">
+                            <label>
+                                <input type="radio" name="pets" value="1" />
+                                One
+                            </label>
+                        </div>
+                        <div class="radio">
+                            <label>
+                                <input type="radio" name="pets" value="2" checked />
+                                Two
+                            </label>
+                        </div>
+                        <div class="radio">
+                            <label>
+                                <input type="radio" name="pets" value="3+" />
+                                Three or more
+                            </label>
+                        </div>
+                    </div>
+                </fieldset>
+                <fieldset class="block">
+                    <legend>Which two colours do you like best?</legend>
+                    <div class="checkbox-list">
+                        <div class="checkbox">
+                            <label>
+                                <input type="checkbox" name="colours" value="cyan">
+                                Cyan
+                            </label>
+                        </div>
+                        <div class="checkbox">
+                            <label>
+                                <input type="checkbox" name="colours" value="magenta" checked />
+                                Magenta
+                            </label>
+                        </div>
+                        <div class="checkbox">
+                            <label>
+                                <input type="checkbox" name="colours" value="yellow" />
+                                Yellow
+                            </label>
+                        </div>
+                        <div class="checkbox">
+                            <label>
+                                <input type="checkbox" name="colours" value="black" checked />
+                                Black
+                            </label>
+                        </div>
+                        <div class="checkbox">
+                            <label>
+                                <input type="checkbox" name="colours" value="white" />
+                                White
+                            </label>
+                        </div>
+                    </div>
+                </fieldset>
+                <label class="block">
+                    <span class="text-gray-700">Tell us what you love</span>
+                    <textarea name="love" class="mt-1 block w-full" rows="3">Comic books</textarea>
+                </label>
+            </div>
+        </div>
+    </form>
+    """
+
+    personal_details = [
+        ("no_value_input", [""]),
+        ("name", ["Jane Doe"]),
+        ("email", ["jane@example.com"]),
+    ]
+
+    event_details = [
+        ("date", ["2023-01-01"]),
+        ("event_type", ["other"]),
+        ("ages", ["18-30", "30-50"]),
+    ]
+
+    market_research = [
+        ("pets", ["2"]),
+        ("colours", ["magenta", "black"]),
+        ("love", ["Comic books"]),
+    ]
+
+    def test_html_only(self):
+        # data should be extracted from the 'first' form by default
+        result = querydict_from_html(self.html)
+        self.assertEqual(list(result.lists()), self.personal_details)
+
+    def test_include_csrf(self):
+        result = querydict_from_html(self.html, exclude_csrf=False)
+        expected_result = [
+            (
+                "csrfmiddlewaretoken",
+                ["Z783HTL5Bc2J54WhAtEeR3eefM1FBkq0EbTfNnYnepFGuJSOfvosFvwjeKYtMwFr"],
+            )
+        ] + self.personal_details
+        self.assertEqual(list(result.lists()), expected_result)
+
+    def test_form_index(self):
+        for index, expected_data in (
+            (0, self.personal_details),
+            ("2", self.market_research),
+            (1, self.event_details),
+        ):
+            result = querydict_from_html(self.html, form_index=index)
+            self.assertEqual(list(result.lists()), expected_data)
+
+    def test_form_id(self):
+        for id, expected_data in (
+            ("event-details", self.event_details),
+            ("personal-details", self.personal_details),
+            ("market-research", self.market_research),
+        ):
+            result = querydict_from_html(self.html, form_id=id)
+            self.assertEqual(list(result.lists()), expected_data)
+
+    def test_invalid_form_id(self):
+        with self.assertRaises(ValueError):
+            querydict_from_html(self.html, form_id="invalid-id")
+
+    def test_invalid_index(self):
+        with self.assertRaises(ValueError):
+            querydict_from_html(self.html, form_index=5)

+ 173 - 0
wagtail/tests/test_page_assertions.py

@@ -0,0 +1,173 @@
+from unittest import mock
+
+from django.conf import settings
+
+from wagtail.models import Page
+from wagtail.test.routablepage.models import RoutablePageTest
+from wagtail.test.utils import WagtailPageTestCase
+
+
+class TestCustomPageAssertions(WagtailPageTestCase):
+    @classmethod
+    def setUpTestData(cls):
+        cls.superuser = cls.create_superuser("super")
+
+    def setUp(self):
+        self.parent = Page.objects.get(id=2)
+        self.page = RoutablePageTest(
+            title="Hello world!",
+            slug="hello-world",
+        )
+        self.parent.add_child(instance=self.page)
+
+    def test_is_routable(self):
+        self.assertPageIsRoutable(self.page)
+
+    def test_is_routable_with_alternative_route(self):
+        self.assertPageIsRoutable(self.page, "archive/year/1984/")
+
+    def test_is_routable_fails_for_draft_page(self):
+        self.page.live = False
+        self.page.save()
+        with self.assertRaises(self.failureException):
+            self.assertPageIsRoutable(self.page)
+
+    def test_is_routable_fails_for_invalid_route_path(self):
+        with self.assertRaises(self.failureException):
+            self.assertPageIsRoutable(self.page, "invalid-route-path/")
+
+    @mock.patch("django.test.testcases.Client.get")
+    @mock.patch("django.test.testcases.Client.force_login")
+    def test_is_renderable(self, mocked_force_login, mocked_get):
+        self.assertPageIsRenderable(self.page)
+        mocked_force_login.assert_not_called()
+        mocked_get.assert_called_once_with("/hello-world/", data=None)
+
+    @mock.patch("django.test.testcases.Client.get")
+    @mock.patch("django.test.testcases.Client.force_login")
+    def test_is_renderable_for_alternative_route(self, mocked_force_login, mocked_get):
+        self.assertPageIsRenderable(self.page, "archive/year/1984/")
+        mocked_force_login.assert_not_called()
+        mocked_get.assert_called_once_with("/hello-world/archive/year/1984/", data=None)
+
+    @mock.patch("django.test.testcases.Client.get")
+    @mock.patch("django.test.testcases.Client.force_login")
+    def test_is_renderable_for_user(self, mocked_force_login, mocked_get):
+        self.assertPageIsRenderable(self.page, user=self.superuser)
+        mocked_force_login.assert_called_once_with(
+            self.superuser, settings.AUTHENTICATION_BACKENDS[0]
+        )
+        mocked_get.assert_called_once_with("/hello-world/", data=None)
+
+    @mock.patch("django.test.testcases.Client.get")
+    def test_is_renderable_with_query_data(self, mocked_get):
+        query_data = {"p": 1, "q": "test"}
+        self.assertPageIsRenderable(self.page, query_data=query_data)
+        mocked_get.assert_called_once_with("/hello-world/", data=query_data)
+
+    @mock.patch("django.test.testcases.Client.post")
+    def test_is_renderable_with_query_and_post_data(self, mocked_post):
+        query_data = {"p": 1, "q": "test"}
+        post_data = {"subscribe": True}
+        self.assertPageIsRenderable(
+            self.page, query_data=query_data, post_data=post_data
+        )
+        mocked_post.assert_called_once_with(
+            "/hello-world/", data=post_data, QUERYSTRING="p=1&q=test"
+        )
+
+    def test_is_renderable_for_draft_page(self):
+        self.page.live = False
+        self.page.save()
+
+        # When accept_404 is False (the default) the test should fail
+        with self.assertRaises(self.failureException):
+            self.assertPageIsRenderable(self.page)
+
+        # When accept_404 is True, the test should pass
+        self.assertPageIsRenderable(self.page, accept_404=True)
+
+    def test_is_renderable_for_invalid_route_path(self):
+        # When accept_404 is False (the default) the test should fail
+        with self.assertRaises(self.failureException):
+            self.assertPageIsRenderable(self.page, "invalid-route-path/")
+
+        # When accept_404 is True, the test should pass
+        self.assertPageIsRenderable(self.page, "invalid-route-path/", accept_404=True)
+
+    def test_is_rendereable_accept_redirect(self):
+        redirect_route_paths = [
+            "permanant-homepage-redirect/",
+            "temporary-homepage-redirect/",
+        ]
+
+        # When accept_redirect is False (the default) the tests should fail
+        for route_path in redirect_route_paths:
+            with self.assertRaises(self.failureException):
+                self.assertPageIsRenderable(self.page, route_path)
+
+        # When accept_redirect is True, the tests should pass
+        for route_path in redirect_route_paths:
+            self.assertPageIsRenderable(self.page, route_path, accept_redirect=True)
+
+    def test_is_editable(self):
+        self.assertPageIsEditable(self.page)
+
+    @mock.patch("django.test.testcases.Client.force_login")
+    def test_is_editable_always_authenticates(self, mocked_force_login):
+        try:
+            self.assertPageIsEditable(self.page)
+        except self.failureException:
+            pass
+
+        mocked_force_login.assert_called_with(
+            self._pageiseditable_superuser, settings.AUTHENTICATION_BACKENDS[0]
+        )
+
+        try:
+            self.assertPageIsEditable(self.page, user=self.superuser)
+        except self.failureException:
+            pass
+
+        mocked_force_login.assert_called_with(
+            self.superuser, settings.AUTHENTICATION_BACKENDS[0]
+        )
+
+    @mock.patch("django.test.testcases.Client.get")
+    @mock.patch("django.test.testcases.Client.force_login")
+    def test_is_editable_with_permission_lacking_user(
+        self, mocked_force_login, mocked_get
+    ):
+        user = self.create_user("bob")
+        with self.assertRaises(self.failureException):
+            self.assertPageIsEditable(self.page, user=user)
+        mocked_force_login.assert_not_called()
+        mocked_get.assert_not_called()
+
+    def test_is_editable_with_post_data(self):
+        self.assertPageIsEditable(
+            self.page,
+            post_data={
+                "title": "Goodbye world?",
+                "slug": "goodbye-world",
+                "content": "goodbye",
+            },
+        )
+
+    def test_is_previewable(self):
+        self.assertPageIsPreviewable(self.page)
+
+    def test_is_previewable_with_post_data(self):
+        self.assertPageIsPreviewable(
+            self.page, post_data={"title": "test", "slug": "test"}
+        )
+
+    def test_is_previewable_with_custom_user(self):
+        self.assertPageIsPreviewable(self.page, user=self.superuser)
+
+    def test_is_previewable_for_alternative_mode(self):
+        self.assertPageIsPreviewable(self.page, mode="extra")
+
+    def test_is_previewable_for_broken_mode(self):
+        with self.assertRaises(self.failureException):
+            self.assertPageIsPreviewable(self.page, mode="broken")