Refs #34043 -- Added --screenshots option to runtests.py and selenium tests.

This commit is contained in:
Sarah Boyce 2023-10-16 12:01:58 +02:00 committed by Mariusz Felisiak
parent 4a5048b036
commit be56c982c0
6 changed files with 126 additions and 3 deletions

1
.gitignore vendored
View File

@ -16,3 +16,4 @@ tests/coverage_html/
tests/.coverage* tests/.coverage*
build/ build/
tests/report/ tests/report/
tests/screenshots/

View File

@ -1,8 +1,11 @@
import sys import sys
import unittest import unittest
from contextlib import contextmanager 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.functional import classproperty
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from django.utils.text import capfirst from django.utils.text import capfirst
@ -116,6 +119,30 @@ class ChangeWindowSize:
class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase): class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
implicit_wait = 10 implicit_wait = 10
external_host = None 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 @classproperty
def live_server_url(cls): def live_server_url(cls):
@ -147,6 +174,30 @@ class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
with ChangeWindowSize(360, 800, self.selenium): with ChangeWindowSize(360, 800, self.selenium):
yield 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 @classmethod
def _quit_selenium(cls): def _quit_selenium(cls):
# quit() the WebDriver before attempting to terminate and join the # quit() the WebDriver before attempting to terminate and join the
@ -163,3 +214,15 @@ class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
yield yield
finally: finally:
self.selenium.implicitly_wait(self.implicit_wait) 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

View File

@ -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 .. _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-unit-tests-dependencies:
Running all the tests Running all the tests

View File

@ -206,6 +206,9 @@ Tests
:meth:`~django.test.SimpleTestCase.assertInHTML` assertions now add haystacks :meth:`~django.test.SimpleTestCase.assertInHTML` assertions now add haystacks
to assertion error messages. to assertion error messages.
* Django test runner now supports ``--screenshots`` option to save screenshots
for Selenium tests.
URLs URLs
~~~~ ~~~~

View File

@ -35,6 +35,7 @@ from django.test import (
override_settings, override_settings,
skipUnlessDBFeature, skipUnlessDBFeature,
) )
from django.test.selenium import screenshot_cases
from django.test.utils import override_script_prefix from django.test.utils import override_script_prefix
from django.urls import NoReverseMatch, resolve, reverse from django.urls import NoReverseMatch, resolve, reverse
from django.utils import formats, translation from django.utils import formats, translation
@ -5732,6 +5733,7 @@ class SeleniumTests(AdminSeleniumTestCase):
title="A Long Title", published=True, slug="a-long-title" title="A Long Title", published=True, slug="a-long-title"
) )
@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
def test_login_button_centered(self): def test_login_button_centered(self):
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@ -5743,6 +5745,7 @@ class SeleniumTests(AdminSeleniumTestCase):
) - (offset_left + button.get_property("offsetWidth")) ) - (offset_left + button.get_property("offsetWidth"))
# Use assertAlmostEqual to avoid pixel rounding errors. # Use assertAlmostEqual to avoid pixel rounding errors.
self.assertAlmostEqual(offset_left, offset_right, delta=3) self.assertAlmostEqual(offset_left, offset_right, delta=3)
self.take_screenshot("login")
def test_prepopulated_fields(self): def test_prepopulated_fields(self):
""" """
@ -6017,6 +6020,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.assertEqual(slug1, "this-is-the-main-name-the-best-2012-02-18") 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") self.assertEqual(slug2, "option-two-this-is-the-main-name-the-best")
@screenshot_cases(["desktop_size", "mobile_size", "dark"])
def test_collapsible_fieldset(self): def test_collapsible_fieldset(self):
""" """
The 'collapse' class in fieldsets definition allows to The 'collapse' class in fieldsets definition allows to
@ -6031,12 +6035,15 @@ class SeleniumTests(AdminSeleniumTestCase):
self.live_server_url + reverse("admin:admin_views_article_add") self.live_server_url + reverse("admin:admin_views_article_add")
) )
self.assertFalse(self.selenium.find_element(By.ID, "id_title").is_displayed()) 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.selenium.find_elements(By.LINK_TEXT, "Show")[0].click()
self.assertTrue(self.selenium.find_element(By.ID, "id_title").is_displayed()) self.assertTrue(self.selenium.find_element(By.ID, "id_title").is_displayed())
self.assertEqual( self.assertEqual(
self.selenium.find_element(By.ID, "fieldsetcollapser0").text, "Hide" 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): def test_selectbox_height_collapsible_fieldset(self):
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@ -6047,7 +6054,7 @@ class SeleniumTests(AdminSeleniumTestCase):
) )
url = self.live_server_url + reverse("admin7:admin_views_pizza_add") url = self.live_server_url + reverse("admin7:admin_views_pizza_add")
self.selenium.get(url) 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_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter")
from_box = self.selenium.find_element(By.ID, "id_toppings_from") from_box = self.selenium.find_element(By.ID, "id_toppings_from")
to_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter_selected") to_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter_selected")
@ -6062,7 +6069,9 @@ class SeleniumTests(AdminSeleniumTestCase):
+ from_box.get_property("offsetHeight") + 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): def test_selectbox_height_not_collapsible_fieldset(self):
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@ -6091,7 +6100,9 @@ class SeleniumTests(AdminSeleniumTestCase):
+ from_box.get_property("offsetHeight") + 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): def test_first_field_focus(self):
"""JavaScript-assisted auto-focus on first usable form field.""" """JavaScript-assisted auto-focus on first usable form field."""
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
@ -6108,6 +6119,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.switch_to.active_element, self.selenium.switch_to.active_element,
self.selenium.find_element(By.ID, "id_name"), self.selenium.find_element(By.ID, "id_name"),
) )
self.take_screenshot("focus-single-widget")
# First form field has a MultiWidget # First form field has a MultiWidget
with self.wait_page_loaded(): with self.wait_page_loaded():
@ -6118,6 +6130,7 @@ class SeleniumTests(AdminSeleniumTestCase):
self.selenium.switch_to.active_element, self.selenium.switch_to.active_element,
self.selenium.find_element(By.ID, "id_start_date_0"), self.selenium.find_element(By.ID, "id_start_date_0"),
) )
self.take_screenshot("focus-multi-widget")
def test_cancel_delete_confirmation(self): def test_cancel_delete_confirmation(self):
"Cancelling the deletion of an object takes the user back one page." "Cancelling the deletion of an object takes the user back one page."

View File

@ -26,7 +26,7 @@ else:
from django.db import connection, connections from django.db import connection, connections
from django.test import TestCase, TransactionTestCase from django.test import TestCase, TransactionTestCase
from django.test.runner import get_max_test_processes, parallel_type 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.test.utils import NullTimeKeeper, TimeKeeper, get_runner
from django.utils.deprecation import RemovedInDjango60Warning from django.utils.deprecation import RemovedInDjango60Warning
from django.utils.log import DEFAULT_LOGGING from django.utils.log import DEFAULT_LOGGING
@ -598,6 +598,11 @@ if __name__ == "__main__":
metavar="BROWSERS", metavar="BROWSERS",
help="A comma-separated list of browsers to run the Selenium tests against.", 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( parser.add_argument(
"--headless", "--headless",
action="store_true", action="store_true",
@ -699,6 +704,10 @@ if __name__ == "__main__":
) )
if using_selenium_hub and not options.external_host: if using_selenium_hub and not options.external_host:
parser.error("--selenium-hub and --external-host must be used together.") 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 # Allow including a trailing slash on app_labels for tab completion convenience
options.modules = [os.path.normpath(labels) for labels in options.modules] options.modules = [os.path.normpath(labels) for labels in options.modules]
@ -748,6 +757,9 @@ if __name__ == "__main__":
SeleniumTestCaseBase.external_host = options.external_host SeleniumTestCaseBase.external_host = options.external_host
SeleniumTestCaseBase.headless = options.headless SeleniumTestCaseBase.headless = options.headless
SeleniumTestCaseBase.browsers = options.selenium SeleniumTestCaseBase.browsers = options.selenium
if options.screenshots:
options.tags = ["screenshot"]
SeleniumTestCase.screenshots = options.screenshots
if options.bisect: if options.bisect:
bisect_tests( bisect_tests(