Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace nose with pytest #34778

Merged
merged 38 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
77ca2d2
Remove pytest compatibility module
millerdev Sep 9, 2024
aba41f6
Initial pytest implementation
millerdev Sep 7, 2024
ec95345
Remove nose test settings
millerdev Apr 19, 2024
18b98e9
Show helpful error on ./manage.py test
millerdev Jun 11, 2024
4d44b95
Adapt patches nose plugin to pytest
millerdev Sep 4, 2024
40b26c2
Adapt redislocks nose plugin to pytest
millerdev Sep 4, 2024
3d22e5c
Add pytest-unmagic plugin for fixtures
millerdev Oct 23, 2024
e56d177
Adapt classCleanup tests to pytest
millerdev May 28, 2024
2efd892
Uninstall ddtrace ModuleWatchdog when testing
millerdev Sep 5, 2024
66663ce
Adapt reusedb feature to pytest - some tests passing
millerdev Oct 17, 2024
8372914
Use ExitStack for cleaner test database tear down
millerdev May 8, 2024
c3b3c75
Rename methods and function
millerdev May 8, 2024
1ae6ea1
Move DeferredDatabaseContext methods to functions
millerdev May 8, 2024
a834219
Implement db blocker for Couch
millerdev Sep 4, 2024
aa24be1
Setup databases on first db unblock
millerdev Jul 29, 2024
9613cdf
Do not unblock db for SimpleTestCase tests
millerdev Jul 26, 2024
3d5c1a1
Observe pytest-django's database options
millerdev Jun 11, 2024
a513405
Adapt dividedwerun nose plugin to pytest
millerdev Sep 4, 2024
0ca69fd
Remove djangomigrations plugin
millerdev May 28, 2024
069391e
Add test markers: es_test, sharded, slow
millerdev Sep 7, 2024
9b8137f
Remove obsolete and disabled nose plugins
millerdev May 29, 2024
912e55c
Move timelimit decorator to its own module
millerdev Sep 17, 2024
421f770
Adapt (part of) timing nose plugin to pytest
millerdev May 30, 2024
9b523cd
More nose.tools: assert_raises, assert_equal, ...
millerdev Jun 11, 2024
e305b74
Add 'skip_setup_users' marker
millerdev Sep 7, 2024
68219e5
Convert "yield" tests to parametrized tests
millerdev Jun 13, 2024
5ae9b5e
Remove nose test utilities module
millerdev Jun 14, 2024
c58537c
Align test discovery with nose
millerdev Sep 5, 2024
214546f
Fix parametrized test that hit Couch
millerdev Jun 14, 2024
2253349
make requirements
millerdev Jun 14, 2024
e7650e0
Switch github actions from nose to pytest
millerdev Jun 14, 2024
509dddc
Ignore unclosed file warnings in tests
millerdev Jul 24, 2024
05d1a10
Work around pytest capture plugin
millerdev Jul 30, 2024
e33e25f
Adapt champ tests to pytest
millerdev Aug 6, 2024
cd7c0a8
Adapt inddex tests to pytest
millerdev Sep 2, 2024
946173a
Adapt up_nrhm tests to pytest
millerdev Sep 2, 2024
ca33bd1
Update testing documentation: nose -> pytest
millerdev Oct 21, 2024
7ebdc62
Merge branch 'master' into dm/pytest
millerdev Oct 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@
/corehq/sql_db/ @snopoke @calellowitz
/corehq/sql_proxy_accessors/ @snopoke
/corehq/sql_proxy_standby_accessors/ @snopoke
/corehq/tests/nose.py @millerdev
/corehq/tests/noseplugins/ @millerdev
/corehq/tests/ @millerdev
/corehq/util/couch.py @esoergel
/corehq/util/couch_helpers.py @millerdev
/corehq/util/datadog/lockmeter.py @millerdev
Expand Down
22 changes: 22 additions & 0 deletions .pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[pytest]
minversion = 8.1

addopts =
--strict-markers
-pcorehq.tests.pytest_hooks
# HQ has its own (incompatible) warnings system
-pno:warnings

empty_parameter_set_mark = xfail
xfail_strict = true

norecursedirs =
docker
node_modules
staticfiles

pythonpath =
.

required_plugins = pytest-django
DJANGO_SETTINGS_MODULE = testsettings
1 change: 0 additions & 1 deletion corehq/apps/es/transient_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ def populate_doc_adapter_map():
from pillowtop.tests.utils import TEST_ES_TYPE, TEST_ES_MAPPING, TEST_ES_INDEX
add_dynamic_adapter("PillowTop", TEST_ES_INDEX, TEST_ES_TYPE, TEST_ES_MAPPING)

import corehq.tests.pytest_compat # noqa: F401 - to be removed after switch to pytest
from corehq.apps.es.tests.utils import TEST_ES_INFO, TEST_ES_MAPPING
add_dynamic_adapter("UtilES", TEST_ES_INFO.alias, TEST_ES_INFO.type,
TEST_ES_MAPPING)
Expand Down
13 changes: 13 additions & 0 deletions corehq/tests/nosecompat.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import sys

from .tools import nottest as nottest_tool


def create_nose_virtual_package():
sys.modules['nose.tools'] = VirtualNose.tools


class VirtualNose:
"""Legacy namespace for tests written before pytest"""
class tools:
nottest = nottest_tool
1 change: 0 additions & 1 deletion corehq/tests/noseplugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +0,0 @@
import corehq.tests.pytest_compat # noqa: F401
43 changes: 0 additions & 43 deletions corehq/tests/pytest_compat.py

This file was deleted.

41 changes: 41 additions & 0 deletions corehq/tests/pytest_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os

import pytest


@pytest.hookimpl(tryfirst=True)
def pytest_load_initial_conftests():
assert not hasattr(pytest_load_initial_conftests, 'loaded'), "Already loaded"
pytest_load_initial_conftests.loaded = True
os.environ.setdefault('CCHQ_TESTING', '1')

from manage import init_hq_python_path, run_patches
init_hq_python_path()
run_patches()

from corehq.warnings import configure_warnings
configure_warnings(is_testing=True)

from .nosecompat import create_nose_virtual_package
create_nose_virtual_package()


def pytest_pycollect_makeitem(collector, name, obj):
"""Fail on common mistake that results in masked tests"""
if (
"Test" in name
and not isinstance(obj, type)
and isinstance(wrapped := _get_wrapped(obj), type)
and any(n.startswith("test_") for n in dir(wrapped))
):
return pytest.fail(
f"{obj.__module__}.{name} appears to be a test class that has "
"been wrapped with a decorator that masks its tests."
)
return None


def _get_wrapped(obj):
while hasattr(obj, "__wrapped__"):
obj = obj.__wrapped__
return obj
10 changes: 2 additions & 8 deletions corehq/warnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,9 @@
# warnings that may be resolved with a library upgrade
("bs4.builder", "option of HTMLParser() has never done anything"),
("couchdbkit.schema.properties", "'collections.abc'"),
("ddtrace.internal.module", "the imp module is deprecated"),
("ddtrace.internal.module", "pkg_resources is deprecated as an API"),
("eulxml", "pkg_resources is deprecated as an API"),
("nose.importer", "the imp module is deprecated"),
("nose.util", "inspect.getargspec() is deprecated"),
("pkg_resources", "pkg_resources.declare_namespace"),
("nose.suite", "'collections.abc'"),
("nose.plugins.collect", "'collections.abc'"),
("", "", RemovedInDjango50Warning),
("", "", RemovedInDjango51Warning),

Expand All @@ -54,8 +50,6 @@
"elasticsearch6.connection.http_urllib3",
"HTTPResponse.getheaders() is deprecated and will be removed in urllib3 v2.1.0."
),
# Should be removed when Nose is updated
("nose.plugins.manager", "pkg_resources is deprecated as an API."),

# other, resolution not obvious
("IPython.core.interactiveshell", "install IPython inside the virtualenv.", UserWarning),
Expand All @@ -72,7 +66,7 @@
]


def configure_warnings(is_testing):
def configure_warnings(is_testing=False):
strict = is_testing or os.environ.get("CCHQ_STRICT_WARNINGS")
if strict:
augment_warning_messages()
Expand Down
47 changes: 5 additions & 42 deletions manage.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@


def main():
if len(sys.argv) > 1 and sys.argv[1] == 'test':
sys.exit("pytest is used to run HQ tests. See 'pytest --help' for options")

# important to apply gevent monkey patches before running any other code
# applying this later can lead to inconsistencies and threading issues
# but compressor doesn't like it
Expand Down Expand Up @@ -40,10 +43,9 @@ def main():
run_patches()

from corehq.warnings import configure_warnings
configure_warnings(is_testing(sys.argv))
configure_warnings()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should configure_warnings() take the CCHQ_TESTING env var into account?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair question. Should a management command (that is not running tests) fail because of warnings if CCHQ_TESTING is set? For example, if you are running CCHQ_TESTING=1 ./manage.py migrate to migrate your test database, should it crash if warnings are emitted? I think that might be more annoying than helpful, but I'm happy to hear arguments in favor.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We reckoned that CCHQ_TESTING=1 ./manage.py dbshell would useful for inspecting the test database.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you saying you agree that that command should not crash on a warning? That's my position.


set_default_settings_path(sys.argv)
set_nosetests_verbosity(sys.argv)
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)

Expand Down Expand Up @@ -159,51 +161,12 @@ def unpatch_sys_modules():


def set_default_settings_path(argv):
if is_testing(argv):
os.environ.setdefault('CCHQ_TESTING', '1')
if os.environ.get('CCHQ_TESTING') == '1':
module = 'testsettings'
else:
module = 'settings'
os.environ.setdefault("DJANGO_SETTINGS_MODULE", module)


def is_testing(argv):
return len(argv) > 1 and argv[1] == 'test' or os.environ.get('CCHQ_TESTING') == '1'


def set_nosetests_verbosity(argv):
"""Increase nose output verbosity with -v... argument

-v: print test names
-vv: do not capture stdout
-vvv: do not capture logging
-vvvv: enable nose internal logging
"""
import logging

def set_verbosity(arg, i):
args = []
verbosity = sum(1 for c in arg if c == "v") + 1
if len(arg) > verbosity:
# preserve other single-letter arguments (ex: -xv)
args.append("".join(c for c in arg if c != "v"))
if verbosity > 2:
args.append("--nocapture")
if verbosity > 3:
verbosity -= 1
args.append("--nologcapture")
logging.basicConfig(level=logging.NOTSET)
logging.getLogger().info(
"Adjust logging with testsettings._set_logging_levels")
args.append("--nose-verbosity=%s" % verbosity)
argv[i:i + 1] = args

if len(argv) > 1 and argv[1] == 'test':
for i, arg in reversed(list(enumerate(argv))):
if arg[:1] == "-" and arg[1] != "-" and any(c == 'v' for c in arg):
set_verbosity(arg, i)
break


if __name__ == "__main__":
main()
7 changes: 4 additions & 3 deletions requirements/test-requirements.in
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
-r requirements.in

django-nose @ https://github.com/dimagi/django-nose/raw/fast-first-1.4.6.1/releases/django_nose-1.4.6.1-py2.py3-none-any.whl
fakecouch
nose
nose-exclude
pip-tools>=6.8.0
testil
requests-mock
Expand All @@ -12,5 +9,9 @@ Faker
flaky
freezegun
radon>=5.0 # v5 no longer depends on flake8-polyfill
# pytest is pinned to a git commit until 8.4 is released.
# `minversion = 8.4` should also be set in .pytest.ini at that time.
pytest @ git+https://github.com/pytest-dev/pytest.git@85760bff2776989b365167c7aeb35c86308ab76b
Comment on lines +12 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What feature are we using that will be in 8.4?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pytest-django
coverage
uWSGI
37 changes: 2 additions & 35 deletions testsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
from warnings import filterwarnings

import settingshelper as helper
from settings import * # noqa: F403
assert helper.is_testing(), 'test mode is required before importing settings'
from settings import * # noqa: E402, F403

# Commenting out temporarily for tests
# if os.environ.get('ELASTICSEARCH_MAJOR_VERSION'):
Expand Down Expand Up @@ -53,45 +54,12 @@
# it can be reverted whenever that's figured out.
# https://github.com/dimagi/commcare-hq/pull/10034#issuecomment-174868270
INSTALLED_APPS = (
'django_nose',
'testapps.test_elasticsearch',
'testapps.test_pillowtop',
) + tuple(INSTALLED_APPS) # noqa: F405

TEST_RUNNER = 'django_nose.BasicNoseRunner'
NOSE_ARGS = [
#'--no-migrations' # trim ~120s from test run with db tests
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should look to see if test run time increases by ~120s after switching to pytest.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the answer is possibly "yes." The average build time does appear to have increased from 14 minutes to 16 minutes soon after Nov 11 when this PR was merged.

#'--with-fixture-bundling',
]
NOSE_PLUGINS = [
'corehq.tests.nose.HqTestFinderPlugin',
'corehq.tests.noseplugins.classcleanup.ClassCleanupPlugin',
'corehq.tests.noseplugins.dbtransaction.DatabaseTransactionPlugin',
'corehq.tests.noseplugins.dividedwerun.DividedWeRunPlugin',
'corehq.tests.noseplugins.djangomigrations.DjangoMigrationsPlugin',
'corehq.tests.noseplugins.cmdline_params.CmdLineParametersPlugin',
'corehq.tests.noseplugins.patches.PatchesPlugin',
'corehq.tests.noseplugins.redislocks.RedisLockTimeoutPlugin',
'corehq.tests.noseplugins.uniformresult.UniformTestResultPlugin',

# The following are not enabled by default
'corehq.tests.noseplugins.logfile.LogFilePlugin',
'corehq.tests.noseplugins.timing.TimingPlugin',
'corehq.tests.noseplugins.output.OutputPlugin',
'corehq.tests.noseplugins.elasticsnitch.ElasticSnitchPlugin',

# Uncomment to debug tests. Plugins have nice hooks for inspecting state
# before/after each test or context setup/teardown, etc.
#'corehq.tests.noseplugins.debug.DebugPlugin',
]

# these settings can be overridden with environment variables
for key, value in {
'NOSE_DB_TEST_CONTEXT': 'corehq.tests.nose.HqdbContext',
'NOSE_NON_DB_TEST_CONTEXT': 'corehq.tests.nose.ErrorOnDbAccessContext',
'NOSE_IGNORE_FILES': '^localsettings',
'NOSE_EXCLUDE_DIRS': 'scripts',

'DD_DOGSTATSD_DISABLE': 'true',
'DD_TRACE_ENABLED': 'false',
}.items():
Expand Down Expand Up @@ -146,7 +114,6 @@ def _set_logging_levels(levels):
'urllib3': 'WARNING',
})

# use empty LOGGING dict with --debug=nose,nose.plugins to debug test discovery
# TODO empty logging config (and fix revealed deprecation warnings)
LOGGING = {
'disable_existing_loggers': False,
Expand Down