Merge pull request #7242 from nicoddemus/fix-flaky-test

Introduce _pytest.timing as a way to control timing during internal tests
This commit is contained in:
Bruno Oliveira 2020-06-02 20:25:02 -03:00 committed by GitHub
commit 61a6ac9705
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 94 additions and 68 deletions

View File

@ -13,7 +13,6 @@ import os
import platform import platform
import re import re
import sys import sys
import time
from datetime import datetime from datetime import datetime
import py import py
@ -21,6 +20,7 @@ import py
import pytest import pytest
from _pytest import deprecated from _pytest import deprecated
from _pytest import nodes from _pytest import nodes
from _pytest import timing
from _pytest.config import filename_arg from _pytest.config import filename_arg
from _pytest.store import StoreKey from _pytest.store import StoreKey
from _pytest.warnings import _issue_warning_captured from _pytest.warnings import _issue_warning_captured
@ -625,14 +625,14 @@ class LogXML:
reporter._add_simple(Junit.error, "internal error", excrepr) reporter._add_simple(Junit.error, "internal error", excrepr)
def pytest_sessionstart(self): def pytest_sessionstart(self):
self.suite_start_time = time.time() self.suite_start_time = timing.time()
def pytest_sessionfinish(self): def pytest_sessionfinish(self):
dirname = os.path.dirname(os.path.abspath(self.logfile)) dirname = os.path.dirname(os.path.abspath(self.logfile))
if not os.path.isdir(dirname): if not os.path.isdir(dirname):
os.makedirs(dirname) os.makedirs(dirname)
logfile = open(self.logfile, "w", encoding="utf-8") logfile = open(self.logfile, "w", encoding="utf-8")
suite_stop_time = time.time() suite_stop_time = timing.time()
suite_time_delta = suite_stop_time - self.suite_start_time suite_time_delta = suite_stop_time - self.suite_start_time
numtests = ( numtests = (
@ -660,7 +660,7 @@ class LogXML:
logfile.close() logfile.close()
def pytest_terminal_summary(self, terminalreporter): def pytest_terminal_summary(self, terminalreporter):
terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) terminalreporter.write_sep("-", "generated xml file: {}".format(self.logfile))
def add_global_property(self, name, value): def add_global_property(self, name, value):
__tracebackhide__ = True __tracebackhide__ = True

View File

@ -7,7 +7,6 @@ import platform
import re import re
import subprocess import subprocess
import sys import sys
import time
import traceback import traceback
from fnmatch import fnmatch from fnmatch import fnmatch
from io import StringIO from io import StringIO
@ -24,6 +23,7 @@ from weakref import WeakKeyDictionary
import py import py
import pytest import pytest
from _pytest import timing
from _pytest._code import Source from _pytest._code import Source
from _pytest.capture import _get_multicapture from _pytest.capture import _get_multicapture
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
@ -970,7 +970,7 @@ class Testdir:
if syspathinsert: if syspathinsert:
self.syspathinsert() self.syspathinsert()
now = time.time() now = timing.time()
capture = _get_multicapture("sys") capture = _get_multicapture("sys")
capture.start_capturing() capture.start_capturing()
try: try:
@ -999,7 +999,7 @@ class Testdir:
sys.stderr.write(err) sys.stderr.write(err)
res = RunResult( res = RunResult(
reprec.ret, out.splitlines(), err.splitlines(), time.time() - now reprec.ret, out.splitlines(), err.splitlines(), timing.time() - now
) )
res.reprec = reprec # type: ignore res.reprec = reprec # type: ignore
return res return res
@ -1200,7 +1200,7 @@ class Testdir:
f1 = open(str(p1), "w", encoding="utf8") f1 = open(str(p1), "w", encoding="utf8")
f2 = open(str(p2), "w", encoding="utf8") f2 = open(str(p2), "w", encoding="utf8")
try: try:
now = time.time() now = timing.time()
popen = self.popen( popen = self.popen(
cmdargs, cmdargs,
stdin=stdin, stdin=stdin,
@ -1247,7 +1247,7 @@ class Testdir:
ret = ExitCode(ret) ret = ExitCode(ret)
except ValueError: except ValueError:
pass pass
return RunResult(ret, out, err, time.time() - now) return RunResult(ret, out, err, timing.time() - now)
def _dump_lines(self, lines, fp): def _dump_lines(self, lines, fp):
try: try:

View File

@ -2,8 +2,6 @@
import bdb import bdb
import os import os
import sys import sys
from time import perf_counter # Intentionally not `import time` to avoid being
from time import time # affected by tests which monkeypatch `time` (issue #185).
from typing import Callable from typing import Callable
from typing import Dict from typing import Dict
from typing import List from typing import List
@ -15,6 +13,7 @@ import attr
from .reports import CollectErrorRepr from .reports import CollectErrorRepr
from .reports import CollectReport from .reports import CollectReport
from .reports import TestReport from .reports import TestReport
from _pytest import timing
from _pytest._code.code import ExceptionChainRepr from _pytest._code.code import ExceptionChainRepr
from _pytest._code.code import ExceptionInfo from _pytest._code.code import ExceptionInfo
from _pytest.compat import TYPE_CHECKING from _pytest.compat import TYPE_CHECKING
@ -254,8 +253,8 @@ class CallInfo:
#: context of invocation: one of "setup", "call", #: context of invocation: one of "setup", "call",
#: "teardown", "memocollect" #: "teardown", "memocollect"
excinfo = None excinfo = None
start = time() start = timing.time()
precise_start = perf_counter() precise_start = timing.perf_counter()
try: try:
result = func() result = func()
except BaseException: except BaseException:
@ -264,9 +263,9 @@ class CallInfo:
raise raise
result = None result = None
# use the perf counter # use the perf counter
precise_stop = perf_counter() precise_stop = timing.perf_counter()
duration = precise_stop - precise_start duration = precise_stop - precise_start
stop = time() stop = timing.time()
return cls( return cls(
start=start, start=start,
stop=stop, stop=stop,

View File

@ -7,7 +7,6 @@ import datetime
import inspect import inspect
import platform import platform
import sys import sys
import time
import warnings import warnings
from functools import partial from functools import partial
from typing import Any from typing import Any
@ -26,6 +25,7 @@ from more_itertools import collapse
import pytest import pytest
from _pytest import nodes from _pytest import nodes
from _pytest import timing
from _pytest._io import TerminalWriter from _pytest._io import TerminalWriter
from _pytest._io.wcwidth import wcswidth from _pytest._io.wcwidth import wcswidth
from _pytest.compat import order_preserving_dict from _pytest.compat import order_preserving_dict
@ -552,7 +552,7 @@ class TerminalReporter:
if self.isatty: if self.isatty:
if self.config.option.verbose >= 0: if self.config.option.verbose >= 0:
self.write("collecting ... ", flush=True, bold=True) self.write("collecting ... ", flush=True, bold=True)
self._collect_report_last_write = time.time() self._collect_report_last_write = timing.time()
elif self.config.option.verbose >= 1: elif self.config.option.verbose >= 1:
self.write("collecting ... ", flush=True, bold=True) self.write("collecting ... ", flush=True, bold=True)
@ -572,7 +572,7 @@ class TerminalReporter:
if not final: if not final:
# Only write "collecting" report every 0.5s. # Only write "collecting" report every 0.5s.
t = time.time() t = timing.time()
if ( if (
self._collect_report_last_write is not None self._collect_report_last_write is not None
and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION and self._collect_report_last_write > t - REPORT_COLLECTING_RESOLUTION
@ -609,7 +609,7 @@ class TerminalReporter:
@pytest.hookimpl(trylast=True) @pytest.hookimpl(trylast=True)
def pytest_sessionstart(self, session: Session) -> None: def pytest_sessionstart(self, session: Session) -> None:
self._session = session self._session = session
self._sessionstarttime = time.time() self._sessionstarttime = timing.time()
if not self.showheader: if not self.showheader:
return return
self.write_sep("=", "test session starts", bold=True) self.write_sep("=", "test session starts", bold=True)
@ -964,7 +964,7 @@ class TerminalReporter:
if self.verbosity < -1: if self.verbosity < -1:
return return
session_duration = time.time() - self._sessionstarttime session_duration = timing.time() - self._sessionstarttime
(parts, main_color) = self.build_summary_stats_line() (parts, main_color) = self.build_summary_stats_line()
line_parts = [] line_parts = []

13
src/_pytest/timing.py Normal file
View File

@ -0,0 +1,13 @@
"""
Indirection for time functions.
We intentionally grab some "time" functions internally to avoid tests mocking "time" to affect
pytest runtime information (issue #185).
Fixture "mock_timinig" also interacts with this module for pytest's own tests.
"""
from time import perf_counter
from time import sleep
from time import time
__all__ = ["perf_counter", "sleep", "time"]

View File

@ -6,11 +6,9 @@ import types
import attr import attr
import py import py
import _pytest.runner
import pytest import pytest
from _pytest.compat import importlib_metadata from _pytest.compat import importlib_metadata
from _pytest.config import ExitCode from _pytest.config import ExitCode
from _pytest.monkeypatch import MonkeyPatch
from _pytest.pytester import Testdir from _pytest.pytester import Testdir
@ -896,37 +894,21 @@ class TestInvocationVariants:
assert request.config.pluginmanager.hasplugin("python") assert request.config.pluginmanager.hasplugin("python")
def fake_time(monkeypatch: MonkeyPatch) -> None:
"""Monkeypatch time functions to make TestDurations not rely on actual time."""
import time
current_time = 1586202699.9859412
def sleep(seconds: float) -> None:
nonlocal current_time
current_time += seconds
monkeypatch.setattr(time, "sleep", sleep)
monkeypatch.setattr(_pytest.runner, "time", lambda: current_time)
monkeypatch.setattr(_pytest.runner, "perf_counter", lambda: current_time)
class TestDurations: class TestDurations:
source = """ source = """
import time from _pytest import timing
def test_something(): def test_something():
pass pass
def test_2(): def test_2():
time.sleep(0.010) timing.sleep(0.010)
def test_1(): def test_1():
time.sleep(0.002) timing.sleep(0.002)
def test_3(): def test_3():
time.sleep(0.020) timing.sleep(0.020)
""" """
def test_calls(self, testdir): def test_calls(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=10") result = testdir.runpytest_inprocess("--durations=10")
assert result.ret == 0 assert result.ret == 0
@ -938,18 +920,17 @@ class TestDurations:
["(8 durations < 0.005s hidden. Use -vv to show these durations.)"] ["(8 durations < 0.005s hidden. Use -vv to show these durations.)"]
) )
def test_calls_show_2(self, testdir): def test_calls_show_2(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=2") result = testdir.runpytest_inprocess("--durations=2")
assert result.ret == 0 assert result.ret == 0
lines = result.stdout.get_lines_after("*slowest*durations*") lines = result.stdout.get_lines_after("*slowest*durations*")
assert "4 passed" in lines[2] assert "4 passed" in lines[2]
def test_calls_showall(self, testdir): def test_calls_showall(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=0") result = testdir.runpytest_inprocess("--durations=0")
assert result.ret == 0 assert result.ret == 0
@ -962,9 +943,8 @@ class TestDurations:
else: else:
raise AssertionError("not found {} {}".format(x, y)) raise AssertionError("not found {} {}".format(x, y))
def test_calls_showall_verbose(self, testdir): def test_calls_showall_verbose(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=0", "-vv") result = testdir.runpytest_inprocess("--durations=0", "-vv")
assert result.ret == 0 assert result.ret == 0
@ -976,17 +956,15 @@ class TestDurations:
else: else:
raise AssertionError("not found {} {}".format(x, y)) raise AssertionError("not found {} {}".format(x, y))
def test_with_deselected(self, testdir): def test_with_deselected(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=2", "-k test_3") result = testdir.runpytest_inprocess("--durations=2", "-k test_3")
assert result.ret == 0 assert result.ret == 0
result.stdout.fnmatch_lines(["*durations*", "*call*test_3*"]) result.stdout.fnmatch_lines(["*durations*", "*call*test_3*"])
def test_with_failing_collection(self, testdir): def test_with_failing_collection(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
testdir.makepyfile(test_collecterror="""xyz""") testdir.makepyfile(test_collecterror="""xyz""")
result = testdir.runpytest_inprocess("--durations=2", "-k test_1") result = testdir.runpytest_inprocess("--durations=2", "-k test_1")
assert result.ret == 2 assert result.ret == 2
@ -996,9 +974,8 @@ class TestDurations:
# output # output
result.stdout.no_fnmatch_line("*duration*") result.stdout.no_fnmatch_line("*duration*")
def test_with_not(self, testdir): def test_with_not(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("-k not 1") result = testdir.runpytest_inprocess("-k not 1")
assert result.ret == 0 assert result.ret == 0
@ -1006,27 +983,26 @@ class TestDurations:
class TestDurationsWithFixture: class TestDurationsWithFixture:
source = """ source = """
import pytest import pytest
import time from _pytest import timing
@pytest.fixture @pytest.fixture
def setup_fixt(): def setup_fixt():
time.sleep(0.02) timing.sleep(2)
def test_1(setup_fixt): def test_1(setup_fixt):
time.sleep(0.02) timing.sleep(5)
""" """
def test_setup_function(self, testdir): def test_setup_function(self, testdir, mock_timing):
testdir.makepyfile(self.source) testdir.makepyfile(self.source)
fake_time(testdir.monkeypatch)
result = testdir.runpytest_inprocess("--durations=10") result = testdir.runpytest_inprocess("--durations=10")
assert result.ret == 0 assert result.ret == 0
result.stdout.fnmatch_lines_random( result.stdout.fnmatch_lines_random(
""" """
*durations* *durations*
* setup *test_1* 5.00s call *test_1*
* call *test_1* 2.00s setup *test_1*
""" """
) )

View File

@ -197,3 +197,41 @@ def color_mapping():
) )
return ColorMapping return ColorMapping
@pytest.fixture
def mock_timing(monkeypatch):
"""Mocks _pytest.timing with a known object that can be used to control timing in tests
deterministically.
pytest itself should always use functions from `_pytest.timing` instead of `time` directly.
This then allows us more control over time during testing, if testing code also
uses `_pytest.timing` functions.
Time is static, and only advances through `sleep` calls, thus tests might sleep over large
numbers and obtain accurate time() calls at the end, making tests reliable and instant.
"""
import attr
@attr.s
class MockTiming:
_current_time = attr.ib(default=1590150050.0)
def sleep(self, seconds):
self._current_time += seconds
def time(self):
return self._current_time
def patch(self):
from _pytest import timing
monkeypatch.setattr(timing, "sleep", self.sleep)
monkeypatch.setattr(timing, "time", self.time)
monkeypatch.setattr(timing, "perf_counter", self.time)
result = MockTiming()
result.patch()
return result

View File

@ -201,23 +201,23 @@ class TestPython:
timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f") timestamp = datetime.strptime(node["timestamp"], "%Y-%m-%dT%H:%M:%S.%f")
assert start_time <= timestamp < datetime.now() assert start_time <= timestamp < datetime.now()
def test_timing_function(self, testdir, run_and_parse): def test_timing_function(self, testdir, run_and_parse, mock_timing):
testdir.makepyfile( testdir.makepyfile(
""" """
import time, pytest from _pytest import timing
def setup_module(): def setup_module():
time.sleep(0.01) timing.sleep(1)
def teardown_module(): def teardown_module():
time.sleep(0.01) timing.sleep(2)
def test_sleep(): def test_sleep():
time.sleep(0.01) timing.sleep(4)
""" """
) )
result, dom = run_and_parse() result, dom = run_and_parse()
node = dom.find_first_by_tag("testsuite") node = dom.find_first_by_tag("testsuite")
tnode = node.find_first_by_tag("testcase") tnode = node.find_first_by_tag("testcase")
val = tnode["time"] val = tnode["time"]
assert round(float(val), 2) >= 0.03 assert float(val) == 7.0
@pytest.mark.parametrize("duration_report", ["call", "total"]) @pytest.mark.parametrize("duration_report", ["call", "total"])
def test_junit_duration_report( def test_junit_duration_report(