Skip to content

Commit ad85b27

Browse files
pandafynemesifier
authored andcommitted
[feature] Added retry mechanism to SeleniumTestMixin #464
Retry selenium tests if the tests fails on the first attempt. This prevents failng the CI build from flaky tests. Closes #464
1 parent 60e8e44 commit ad85b27

File tree

3 files changed

+125
-0
lines changed

3 files changed

+125
-0
lines changed

docs/developer/test-utilities.rst

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,35 @@ This mixin provides the core Selenium setup logic and reusable test
179179
methods that must be used across all OpenWISP modules based on Django to
180180
enforce best practices and avoid flaky tests.
181181

182+
It includes a built-in retry mechanism that can automatically repeat
183+
failing tests to identify transient (flaky) failures. You can customize
184+
this behavior using the following class attributes:
185+
186+
- ``retry_max``: The maximum number of times to retry a failing test.
187+
Defaults to ``5``.
188+
- ``retry_delay``: The number of seconds to wait between retries. Defaults
189+
to ``0``.
190+
- ``retry_threshold``: The minimum ratio of successful retries required
191+
for the test to be considered as passed. If the success ratio falls
192+
below this threshold, the test is marked as failed. Defaults to ``0.8``.
193+
194+
**Example usage:**
195+
196+
.. code-block:: python
197+
198+
from openwisp_utils.tests import SeleniumTestMixin
199+
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
200+
201+
202+
class MySeleniumTest(SeleniumTestMixin, StaticLiveServerTestCase):
203+
retry_max = 10
204+
retry_delay = 0
205+
retry_threshold = 0.9
206+
207+
def test_something(self):
208+
self.open("/some-url/")
209+
# Your test logic here
210+
182211
.. _selenium_dependencies:
183212

184213
Selenium Dependencies

openwisp_utils/tests/selenium.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import time
23

34
from selenium import webdriver
45
from selenium.common.exceptions import TimeoutException
@@ -19,6 +20,62 @@ class SeleniumTestMixin:
1920
admin_password = 'password'
2021
browser = 'firefox'
2122

23+
retry_max = 5
24+
retry_delay = 0
25+
retry_threshold = 0.8
26+
27+
def _print_retry_message(self, test_name, attempt):
28+
if attempt == 0:
29+
return
30+
print('-' * 80)
31+
print(f'[Retry] Retrying "{test_name}", attempt {attempt}/{self.retry_max}. ')
32+
print('-' * 80)
33+
34+
def _setup_and_call(self, result, debug=False):
35+
"""Override unittest.TestCase.run to retry flaky tests.
36+
37+
This method is responsible for calling setUp and tearDown methods.
38+
Thus, we override this method to implement the retry mechanism
39+
instead of TestCase.run().
40+
"""
41+
original_result = result
42+
test_name = self.id()
43+
success_count = 0
44+
failed_result = None
45+
# Manually call startTest to ensure TimeLoggingTestResult can
46+
# measure the execution time for the test.
47+
original_result.startTest(self)
48+
49+
for attempt in range(self.retry_max + 1):
50+
# Use a new result object to prevent writing all attempts
51+
# to stdout.
52+
result = self.defaultTestResult()
53+
super()._setup_and_call(result, debug)
54+
if result.wasSuccessful():
55+
if attempt == 0:
56+
original_result.addSuccess(self)
57+
return
58+
else:
59+
success_count += 1
60+
else:
61+
failed_result = result
62+
self._print_retry_message(test_name, attempt)
63+
if self.retry_delay:
64+
time.sleep(self.retry_delay)
65+
66+
if success_count / self.retry_max < self.retry_threshold:
67+
# If the success rate of retries is below the threshold then,
68+
# copy errors and failures from the last failed result to the
69+
# original result.
70+
original_result.failures = failed_result.failures
71+
original_result.errors = failed_result.errors
72+
if hasattr(original_result, 'events'):
73+
# Parallel tests uses RemoteTestResult which relies on events.
74+
original_result.events = failed_result.events
75+
else:
76+
# Mark the test as passed in the original result
77+
original_result.addSuccess(self)
78+
2279
@classmethod
2380
def setUpClass(cls):
2481
super().setUpClass()

tests/test_project/tests/test_selenium.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from unittest import expectedFailure
2+
13
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
24
from django.urls import reverse
35
from selenium.common.exceptions import JavascriptException, NoSuchElementException
@@ -753,3 +755,40 @@ def test_get_browser_logs(self):
753755
self.assertEqual(self.get_browser_logs(), [])
754756
self.web_driver.execute_script('console.log("test")')
755757
self.assertEqual(len(self.get_browser_logs()), 1)
758+
759+
760+
class TestSeleniumMixinRetryMechanism(SeleniumTestMixin, StaticLiveServerTestCase):
761+
retry_delay = 0
762+
763+
@classmethod
764+
def setUpClass(cls):
765+
# We don't need browser instances for these tests.
766+
pass
767+
768+
@classmethod
769+
def tearDownClass(cls):
770+
pass
771+
772+
def test_retry_mechanism_pass(self):
773+
if not hasattr(self, '_test_retry_mechanism_pass_called'):
774+
self._test_retry_mechanism_pass_called = 1
775+
self.fail('Failing on first call')
776+
else:
777+
self._test_retry_mechanism_pass_called += 1
778+
779+
def test_retry_mechanism_not_called(self):
780+
if not hasattr(self, '_test_retry_mechanism_not_called'):
781+
self._test_retry_mechanism_not_called = 1
782+
else:
783+
# This code should not be executed because the test
784+
# is called only once.
785+
self._test_retry_mechanism_not_called += 1
786+
self.assertEqual(self._test_retry_mechanism_not_called, 1)
787+
788+
@expectedFailure
789+
def test_retry_mechanism_fails(self):
790+
if not hasattr(self, '_test_retry_mechanism_fails_called'):
791+
self._test_retry_mechanism_fails_called = 0
792+
self._test_retry_mechanism_fails_called += 1
793+
if self._test_retry_mechanism_fails_called < 5:
794+
self.fail('Report failed test')

0 commit comments

Comments
 (0)