mirror of
https://github.com/django/django.git
synced 2024-12-22 00:55:44 +00:00
Refs #34043 -- Added --screenshots option to runtests.py and selenium tests.
This commit is contained in:
parent
4a5048b036
commit
be56c982c0
1
.gitignore
vendored
1
.gitignore
vendored
@ -16,3 +16,4 @@ tests/coverage_html/
|
|||||||
tests/.coverage*
|
tests/.coverage*
|
||||||
build/
|
build/
|
||||||
tests/report/
|
tests/report/
|
||||||
|
tests/screenshots/
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
~~~~
|
~~~~
|
||||||
|
|
||||||
|
@ -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."
|
||||||
|
@ -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(
|
||||||
|
Loading…
Reference in New Issue
Block a user