Skip to content

Commit be56c98

Browse files
sarahboycefelixxm
authored andcommitted
Refs #34043 -- Added --screenshots option to runtests.py and selenium tests.
1 parent 4a5048b commit be56c98

File tree

6 files changed

+126
-3
lines changed

6 files changed

+126
-3
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ tests/coverage_html/
1616
tests/.coverage*
1717
build/
1818
tests/report/
19+
tests/screenshots/

django/test/selenium.py

+64-1
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import sys
22
import unittest
33
from contextlib import contextmanager
4+
from functools import wraps
5+
from pathlib import Path
46

5-
from django.test import LiveServerTestCase, tag
7+
from django.conf import settings
8+
from django.test import LiveServerTestCase, override_settings, tag
69
from django.utils.functional import classproperty
710
from django.utils.module_loading import import_string
811
from django.utils.text import capfirst
@@ -116,6 +119,30 @@ def __exit__(self, exc_type, exc_value, traceback):
116119
class SeleniumTestCase(LiveServerTestCase, metaclass=SeleniumTestCaseBase):
117120
implicit_wait = 10
118121
external_host = None
122+
screenshots = False
123+
124+
@classmethod
125+
def __init_subclass__(cls, **kwargs):
126+
super().__init_subclass__(**kwargs)
127+
if not cls.screenshots:
128+
return
129+
130+
for name, func in list(cls.__dict__.items()):
131+
if not hasattr(func, "_screenshot_cases"):
132+
continue
133+
# Remove the main test.
134+
delattr(cls, name)
135+
# Add separate tests for each screenshot type.
136+
for screenshot_case in getattr(func, "_screenshot_cases"):
137+
138+
@wraps(func)
139+
def test(self, *args, _func=func, _case=screenshot_case, **kwargs):
140+
with getattr(self, _case)():
141+
return _func(self, *args, **kwargs)
142+
143+
test.__name__ = f"{name}_{screenshot_case}"
144+
test.__qualname__ = f"{test.__qualname__}_{screenshot_case}"
145+
setattr(cls, test.__name__, test)
119146

120147
@classproperty
121148
def live_server_url(cls):
@@ -147,6 +174,30 @@ def mobile_size(self):
147174
with ChangeWindowSize(360, 800, self.selenium):
148175
yield
149176

177+
@contextmanager
178+
def rtl(self):
179+
with self.desktop_size():
180+
with override_settings(LANGUAGE_CODE=settings.LANGUAGES_BIDI[-1]):
181+
yield
182+
183+
@contextmanager
184+
def dark(self):
185+
# Navigate to a page before executing a script.
186+
self.selenium.get(self.live_server_url)
187+
self.selenium.execute_script("localStorage.setItem('theme', 'dark');")
188+
with self.desktop_size():
189+
try:
190+
yield
191+
finally:
192+
self.selenium.execute_script("localStorage.removeItem('theme');")
193+
194+
def take_screenshot(self, name):
195+
if not self.screenshots:
196+
return
197+
path = Path.cwd() / "screenshots" / f"{self._testMethodName}-{name}.png"
198+
path.parent.mkdir(exist_ok=True, parents=True)
199+
self.selenium.save_screenshot(path)
200+
150201
@classmethod
151202
def _quit_selenium(cls):
152203
# quit() the WebDriver before attempting to terminate and join the
@@ -163,3 +214,15 @@ def disable_implicit_wait(self):
163214
yield
164215
finally:
165216
self.selenium.implicitly_wait(self.implicit_wait)
217+
218+
219+
def screenshot_cases(method_names):
220+
if isinstance(method_names, str):
221+
method_names = method_names.split(",")
222+
223+
def wrapper(func):
224+
func._screenshot_cases = method_names
225+
setattr(func, "tags", {"screenshot"}.union(getattr(func, "tags", set())))
226+
return func
227+
228+
return wrapper

docs/internals/contributing/writing-code/unit-tests.txt

+31
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,37 @@ faster and more stable. Add the ``--headless`` option to enable this mode.
271271

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

274+
For testing changes to the admin UI, the selenium tests can be run with the
275+
``--screenshots`` option enabled. Screenshots will be saved to the
276+
``tests/screenshots/`` directory.
277+
278+
To define when screenshots should be taken during a selenium test, the test
279+
class must use the ``@django.test.selenium.screenshot_cases`` decorator with a
280+
list of supported screenshot types (``"desktop_size"``, ``"mobile_size"``,
281+
``"small_screen_size"``, ``"rtl"``, and ``"dark"``). It can then call
282+
``self.take_screenshot("unique-screenshot-name")`` at the desired point to
283+
generate the screenshots. For example::
284+
285+
from django.test.selenium import SeleniumTestCase, screenshot_cases
286+
from django.urls import reverse
287+
288+
289+
class SeleniumTests(SeleniumTestCase):
290+
@screenshot_cases(["desktop_size", "mobile_size", "rtl", "dark"])
291+
def test_login_button_centered(self):
292+
self.selenium.get(self.live_server_url + reverse("admin:login"))
293+
self.take_screenshot("login")
294+
...
295+
296+
This generates multiple screenshots of the login page - one for a desktop
297+
screen, one for a mobile screen, one for right-to-left languages on desktop,
298+
and one for the dark mode on desktop.
299+
300+
.. versionchanged:: 5.1
301+
302+
The ``--screenshots`` option and ``@screenshot_cases`` decorator were
303+
added.
304+
274305
.. _running-unit-tests-dependencies:
275306

276307
Running all the tests

docs/releases/5.1.txt

+3
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ Tests
206206
:meth:`~django.test.SimpleTestCase.assertInHTML` assertions now add haystacks
207207
to assertion error messages.
208208

209+
* Django test runner now supports ``--screenshots`` option to save screenshots
210+
for Selenium tests.
211+
209212
URLs
210213
~~~~
211214

tests/admin_views/tests.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
override_settings,
3636
skipUnlessDBFeature,
3737
)
38+
from django.test.selenium import screenshot_cases
3839
from django.test.utils import override_script_prefix
3940
from django.urls import NoReverseMatch, resolve, reverse
4041
from django.utils import formats, translation
@@ -5732,6 +5733,7 @@ def setUp(self):
57325733
title="A Long Title", published=True, slug="a-long-title"
57335734
)
57345735

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

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

57475750
def test_prepopulated_fields(self):
57485751
"""
@@ -6017,6 +6020,7 @@ def test_populate_existing_object(self):
60176020
self.assertEqual(slug1, "this-is-the-main-name-the-best-2012-02-18")
60186021
self.assertEqual(slug2, "option-two-this-is-the-main-name-the-best")
60196022

6023+
@screenshot_cases(["desktop_size", "mobile_size", "dark"])
60206024
def test_collapsible_fieldset(self):
60216025
"""
60226026
The 'collapse' class in fieldsets definition allows to
@@ -6031,12 +6035,15 @@ def test_collapsible_fieldset(self):
60316035
self.live_server_url + reverse("admin:admin_views_article_add")
60326036
)
60336037
self.assertFalse(self.selenium.find_element(By.ID, "id_title").is_displayed())
6038+
self.take_screenshot("collapsed")
60346039
self.selenium.find_elements(By.LINK_TEXT, "Show")[0].click()
60356040
self.assertTrue(self.selenium.find_element(By.ID, "id_title").is_displayed())
60366041
self.assertEqual(
60376042
self.selenium.find_element(By.ID, "fieldsetcollapser0").text, "Hide"
60386043
)
6044+
self.take_screenshot("expanded")
60396045

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

@@ -6047,7 +6054,7 @@ def test_selectbox_height_collapsible_fieldset(self):
60476054
)
60486055
url = self.live_server_url + reverse("admin7:admin_views_pizza_add")
60496056
self.selenium.get(url)
6050-
self.selenium.find_elements(By.LINK_TEXT, "Show")[0].click()
6057+
self.selenium.find_elements(By.ID, "fieldsetcollapser0")[0].click()
60516058
from_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter")
60526059
from_box = self.selenium.find_element(By.ID, "id_toppings_from")
60536060
to_filter_box = self.selenium.find_element(By.ID, "id_toppings_filter_selected")
@@ -6062,7 +6069,9 @@ def test_selectbox_height_collapsible_fieldset(self):
60626069
+ from_box.get_property("offsetHeight")
60636070
),
60646071
)
6072+
self.take_screenshot("selectbox-collapsible")
60656073

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

@@ -6091,7 +6100,9 @@ def test_selectbox_height_not_collapsible_fieldset(self):
60916100
+ from_box.get_property("offsetHeight")
60926101
),
60936102
)
6103+
self.take_screenshot("selectbox-non-collapsible")
60946104

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

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

61226135
def test_cancel_delete_confirmation(self):
61236136
"Cancelling the deletion of an object takes the user back one page."

tests/runtests.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from django.db import connection, connections
2727
from django.test import TestCase, TransactionTestCase
2828
from django.test.runner import get_max_test_processes, parallel_type
29-
from django.test.selenium import SeleniumTestCaseBase
29+
from django.test.selenium import SeleniumTestCase, SeleniumTestCaseBase
3030
from django.test.utils import NullTimeKeeper, TimeKeeper, get_runner
3131
from django.utils.deprecation import RemovedInDjango60Warning
3232
from django.utils.log import DEFAULT_LOGGING
@@ -598,6 +598,11 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
598598
metavar="BROWSERS",
599599
help="A comma-separated list of browsers to run the Selenium tests against.",
600600
)
601+
parser.add_argument(
602+
"--screenshots",
603+
action="store_true",
604+
help="Take screenshots during selenium tests to capture the user interface.",
605+
)
601606
parser.add_argument(
602607
"--headless",
603608
action="store_true",
@@ -699,6 +704,10 @@ def paired_tests(paired_test, options, test_labels, start_at, start_after):
699704
)
700705
if using_selenium_hub and not options.external_host:
701706
parser.error("--selenium-hub and --external-host must be used together.")
707+
if options.screenshots and not options.selenium:
708+
parser.error("--screenshots require --selenium to be used.")
709+
if options.screenshots and options.tags:
710+
parser.error("--screenshots and --tag are mutually exclusive.")
702711

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

752764
if options.bisect:
753765
bisect_tests(

0 commit comments

Comments
 (0)