Skip to content

Commit

Permalink
Refs #34043 -- Added --screenshots option to runtests.py and selenium…
Browse files Browse the repository at this point in the history
… tests.
  • Loading branch information
sarahboyce authored and felixxm committed Oct 18, 2023
1 parent 4a5048b commit be56c98
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ tests/coverage_html/
tests/.coverage*
build/
tests/report/
tests/screenshots/
65 changes: 64 additions & 1 deletion django/test/selenium.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import sys
import unittest
from contextlib import contextmanager
from functools import wraps
from pathlib import Path

from django.test import LiveServerTestCase, tag
from django.conf import settings
from django.test import LiveServerTestCase, override_settings, tag
from django.utils.functional import classproperty
from django.utils.module_loading import import_string
from django.utils.text import capfirst
Expand Down Expand Up @@ -116,6 +119,30 @@ def __exit__(self, exc_type, exc_value, traceback):
class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
implicit_wait = 10
external_host = None
screenshots = False

@classmethod
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
if not cls.screenshots:
return

for name, func in list(cls.__dict__.items()):
if not hasattr(func, "_screenshot_cases"):
continue
# Remove the main test.
delattr(cls, name)
# Add separate tests for each screenshot type.
for screenshot_case in getattr(func, "_screenshot_cases"):

@wraps(func)
def test(self, *args, _func=func, _case=screenshot_case, **kwargs):
with getattr(self, _case)():
return _func(self, *args, **kwargs)

test.__name__ = f"{name}_{screenshot_case}"
test.__qualname__ = f"{test.__qualname__}_{screenshot_case}"
setattr(cls, test.__name__, test)

@classproperty
def live_server_url(cls):
Expand Down Expand Up @@ -147,6 +174,30 @@ def mobile_size(self):
with ChangeWindowSize(360, 800, self.selenium):
yield

@contextmanager
def rtl(self):
with self.desktop_size():
with override_settings(LANGUAGE_CODE=settings.LANGUAGES_BIDI[-1]):
yield

@contextmanager
def dark(self):
# Navigate to a page before executing a script.
self.selenium.get(self.live_server_url)
self.selenium.execute_script("localStorage.setItem('theme', 'dark');")
with self.desktop_size():
try:
yield
finally:
self.selenium.execute_script("localStorage.removeItem('theme');")

def take_screenshot(self, name):
if not self.screenshots:
return
path = Path.cwd() / "screenshots" / f"{self._testMethodName}-{name}.png"
path.parent.mkdir(exist_ok=True, parents=True)
self.selenium.save_screenshot(path)

@classmethod
def _quit_selenium(cls):
# quit() the WebDriver before attempting to terminate and join the
Expand All @@ -163,3 +214,15 @@ def disable_implicit_wait(self):
yield
finally:
self.selenium.implicitly_wait(self.implicit_wait)


def screenshot_cases(method_names):
if isinstance(method_names, str):
method_names = method_names.split(",")

def wrapper(func):
func._screenshot_cases = method_names
setattr(func, "tags", {"screenshot"}.union(getattr(func, "tags", set())))
return func

return wrapper
31 changes: 31 additions & 0 deletions docs/internals/contributing/writing-code/unit-tests.txt
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,37 @@ faster and more stable. Add the ``--headless`` option to enable this mode.

.. _selenium.webdriver: https://github.com/SeleniumHQ/selenium/tree/trunk/py/selenium/webdriver

For testing changes to the admin UI, the selenium tests can be run with the
``--screenshots`` option enabled. Screenshots will be saved to the
``tests/screenshots/`` directory.

To define when screenshots should be taken during a selenium test, the test
class must use the ``@django.test.selenium.screenshot_cases`` decorator with a
list of supported screenshot types (``"desktop_size"``, ``"mobile_size"``,
``"small_screen_size"``, ``"rtl"``, and ``"dark"``). It can then call
``self.take_screenshot("unique-screenshot-name")`` at the desired point to
generate the screenshots. For example::

from django.test.selenium import SeleniumTestCase, screenshot_cases
from django.urls import reverse


class SeleniumTests(SeleniumTestCase):
@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_login_button_centered(self):
self.selenium.get(self.live_server_url + reverse("admin:login"))
self.take_screenshot("login")
...

This generates multiple screenshots of the login page - one for a desktop
screen, one for a mobile screen, one for right-to-left languages on desktop,
and one for the dark mode on desktop.

.. versionchanged:: 5.1

The ``--screenshots`` option and ``@screenshot_cases`` decorator were
added.

.. _running-unit-tests-dependencies:

Running all the tests
Expand Down
3 changes: 3 additions & 0 deletions docs/releases/5.1.txt
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,9 @@ Tests
:meth:`~django.test.SimpleTestCase.assertInHTML` assertions now add haystacks
to assertion error messages.

* Django test runner now supports ``--screenshots`` option to save screenshots
for Selenium tests.

URLs
~~~~

Expand Down
15 changes: 14 additions & 1 deletion tests/admin_views/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
override_settings,
skipUnlessDBFeature,
)
from django.test.selenium import screenshot_cases
from django.test.utils import override_script_prefix
from django.urls import NoReverseMatch, resolve, reverse
from django.utils import formats, translation
Expand Down Expand Up @@ -5732,6 +5733,7 @@ def setUp(self):
title="A Long Title", published=True, slug="a-long-title"
)

@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_login_button_centered(self):
from selenium.webdriver.common.by import By

Expand All @@ -5743,6 +5745,7 @@ def test_login_button_centered(self):
) - (offset_left + button.get_property("offsetWidth"))
# Use assertAlmostEqual to avoid pixel rounding errors.
self.assertAlmostEqual(offset_left, offset_right, delta=3)
self.take_screenshot("login")

def test_prepopulated_fields(self):
"""
Expand Down Expand Up @@ -6017,6 +6020,7 @@ def test_populate_existing_object(self):
self.assertEqual(slug1, "this-is-the-main-name-the-best-2012-02-18")
self.assertEqual(slug2, "option-two-this-is-the-main-name-the-best")

@screenshot_cases(["desktop_size", "mobile_size", "dark"])
def test_collapsible_fieldset(self):
"""
The 'collapse' class in fieldsets definition allows to
Expand All @@ -6031,12 +6035,15 @@ def test_collapsible_fieldset(self):
self.live_server_url + reverse("admin:admin_views_article_add")
)
self.assertFalse(self.selenium.find_element(By.ID, "id_title").is_displayed())
self.take_screenshot("collapsed")
self.selenium.find_elements(By.LINK_TEXT, "Show")[0].click()
self.assertTrue(self.selenium.find_element(By.ID, "id_title").is_displayed())
self.assertEqual(
self.selenium.find_element(By.ID, "fieldsetcollapser0").text, "Hide"
)
self.take_screenshot("expanded")

@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_selectbox_height_collapsible_fieldset(self):
from selenium.webdriver.common.by import By

Expand All @@ -6047,7 +6054,7 @@ def test_selectbox_height_collapsible_fieldset(self):
)
url = self.live_server_url + reverse("admin7:admin_views_pizza_add")
self.selenium.get(url)
self.selenium.find_elements(By.LINK_TEXT, "Show")[0].click()
self.selenium.find_elements(By.ID, "fieldsetcollapser0")[0].click()
from_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter")
from_box = self.selenium.find_element(By.ID, "id_toppings_from")
to_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter_selected")
Expand All @@ -6062,7 +6069,9 @@ def test_selectbox_height_collapsible_fieldset(self):
+ from_box.get_property("offsetHeight")
),
)
self.take_screenshot("selectbox-collapsible")

@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_selectbox_height_not_collapsible_fieldset(self):
from selenium.webdriver.common.by import By

Expand Down Expand Up @@ -6091,7 +6100,9 @@ def test_selectbox_height_not_collapsible_fieldset(self):
+ from_box.get_property("offsetHeight")
),
)
self.take_screenshot("selectbox-non-collapsible")

@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_first_field_focus(self):
"""JavaScript-assisted auto-focus on first usable form field."""
from selenium.webdriver.common.by import By
Expand All @@ -6108,6 +6119,7 @@ def test_first_field_focus(self):
self.selenium.switch_to.active_element,
self.selenium.find_element(By.ID, "id_name"),
)
self.take_screenshot("focus-single-widget")

# First form field has a MultiWidget
with self.wait_page_loaded():
Expand All @@ -6118,6 +6130,7 @@ def test_first_field_focus(self):
self.selenium.switch_to.active_element,
self.selenium.find_element(By.ID, "id_start_date_0"),
)
self.take_screenshot("focus-multi-widget")

def test_cancel_delete_confirmation(self):
"Cancelling the deletion of an object takes the user back one page."
Expand Down
14 changes: 13 additions & 1 deletion tests/runtests.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
from django.db import connection, connections
from django.test import TestCase, TransactionTestCase
from django.test.runner import get_max_test_processes, parallel_type
from django.test.selenium import SeleniumTestCaseBase
from django.test.selenium import SeleniumTestCase, SeleniumTestCaseBase
from django.test.utils import NullTimeKeeper, TimeKeeper, get_runner
from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.log import DEFAULT_LOGGING
Expand Down Expand Up @@ -598,6 +598,11 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
metavar="BROWSERS",
help="A comma-separated list of browsers to run the Selenium tests against.",
)
parser.add_argument(
"--screenshots",
action="store_true",
help="Take screenshots during selenium tests to capture the user interface.",
)
parser.add_argument(
"--headless",
action="store_true",
Expand Down Expand Up @@ -699,6 +704,10 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
)
if using_selenium_hub and not options.external_host:
parser.error("--selenium-hub and --external-host must be used together.")
if options.screenshots and not options.selenium:
parser.error("--screenshots require --selenium to be used.")
if options.screenshots and options.tags:
parser.error("--screenshots and --tag are mutually exclusive.")

# Allow including a trailing slash on app_labels for tab completion convenience
options.modules = [os.path.normpath(labels) for labels in options.modules]
Expand Down Expand Up @@ -748,6 +757,9 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
SeleniumTestCaseBase.external_host = options.external_host
SeleniumTestCaseBase.headless = options.headless
SeleniumTestCaseBase.browsers = options.selenium
if options.screenshots:
options.tags = ["screenshot"]
SeleniumTestCase.screenshots = options.screenshots

if options.bisect:
bisect_tests(
Expand Down

0 comments on commit be56c98

Please sign in to comment.