diff --git a/Makefile b/Makefile index 9eeacdc9..a08b2fd7 100644 --- a/Makefile +++ b/Makefile @@ -15,7 +15,7 @@ else UNITTEST_ARGS := --verbose endif TESTS_SRCS = tests -SRCS = testslide util +SRCS = testslide util pytest-testslide ALL_SRCS = $(TESTS_SRCS) $(SRCS) TERM_BRIGHT := $(shell tput bold) TERM_NONE := $(shell tput sgr0) @@ -68,6 +68,16 @@ docs_clean: .PHONY: unittest_tests unittest_tests: $(TESTS_SRCS)/*_unittest.py +.PHONY: pytest_tests +pytest_tests: export PYTHONPATH=${CURDIR}/pytest-testslide:${CURDIR} +pytest_tests: FORCE coverage_erase + @printf "${TERM_BRIGHT}INSTALL pytest_testslide DEPS ${TERM_NONE}\n" + ${Q} pip install -r pytest-testslide/requirements.txt + @printf "${TERM_BRIGHT}PYTEST pytest_testslide${TERM_NONE}" + ${Q} coverage run \ + -m pytest \ + pytest-testslide/tests + %_testslide.py: FORCE coverage_erase @printf "${TERM_BRIGHT}TESTSLIDE $@\n${TERM_NONE}" ${Q} coverage run \ @@ -120,6 +130,7 @@ format_black: tests: \ unittest_tests \ testslide_tests \ + pytest_tests \ mypy \ flake8 \ isort \ diff --git a/mypy.ini b/mypy.ini index 39257aba..f521daf1 100644 --- a/mypy.ini +++ b/mypy.ini @@ -6,9 +6,13 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-pygments.*] ignore_missing_imports = True +[mypy-pytest.*] +ignore_missing_imports = True +[mypy-setuptools.*] +ignore_missing_imports = True [mypy-testslide.testslide] disallow_untyped_calls = True disallow_untyped_defs = True disallow_incomplete_defs = True check_untyped_defs = True -disallow_untyped_decorators = True \ No newline at end of file +disallow_untyped_decorators = True diff --git a/pytest-testslide/MANIFEST.in b/pytest-testslide/MANIFEST.in new file mode 100644 index 00000000..ec2be713 --- /dev/null +++ b/pytest-testslide/MANIFEST.in @@ -0,0 +1,2 @@ +include testslide-version +include requirements.txt diff --git a/pytest-testslide/README.md b/pytest-testslide/README.md new file mode 100644 index 00000000..43ee27e9 --- /dev/null +++ b/pytest-testslide/README.md @@ -0,0 +1,39 @@ +[TestSlide](https://testslide.readthedocs.io/) fixture for pytest. + +## Quickstart + +Install: + +``` +pip install pytest-testslide +``` + +In your test file: +``` + import pytest + from pytest_testslide import testslide + from testslide import StrictMock # if you wish to use StrictMock + from testslide import matchers # if you wish to use Rspec style argument matchers + ..... + def test_mock_callable_patching_works(testslide): + testslide.mock_callable(time, "sleep").to_raise(RuntimeError("Mocked!")) #mock_callable + with pytest.raises(RuntimeError): + time.sleep() + + @pytest.mark.asyncio + async def test_mock_async_callable_patching_works(testslide): + testslide.mock_async_callable(sample_module.ParentTarget, "async_static_method").to_raise(RuntimeError("Mocked!")) #mock_async_callable + with pytest.raises(RuntimeError): + await sample_module.ParentTarget.async_static_method("a", "b") + + def test_mock_constructor_patching_works(testslide): + testslide.mock_constructor(sample_module, "ParentTarget").to_raise(RuntimeError("Mocked!")) #mock_constructor + with pytest.raises(RuntimeError): + sample_module.ParentTarget() + + def test_patch_attribute_patching_works(testslide): + testslide.patch_attribute(sample_module.SomeClass, "attribute", "patched") #patch_attribute + assert sample_module.SomeClass.attribute == "patched" + +``` + diff --git a/pytest-testslide/pytest_testslide.py b/pytest-testslide/pytest_testslide.py new file mode 100644 index 00000000..6b236cd8 --- /dev/null +++ b/pytest-testslide/pytest_testslide.py @@ -0,0 +1,70 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +from types import TracebackType +from typing import Any, Callable, Iterator, List, Optional + +import pytest + +import testslide as testslide_module + + +class _TestSlideFixture: + def _register_assertion(self, assertion: Callable) -> None: + self._assertions.append(assertion) + + def __enter__(self) -> "_TestSlideFixture": + self._assertions: List[Callable] = [] + testslide_module.mock_callable.register_assertion = self._register_assertion + return self + + def __exit__( + self, + exc_type: Optional[type], + exc_val: Optional[Exception], + exc_tb: TracebackType, + ): + aggregated_exceptions = testslide_module.AggregatedExceptions() + try: + for assertion in self._assertions: + try: + assertion() + except BaseException as be: + aggregated_exceptions.append_exception(be) + + finally: + testslide_module.mock_callable.unpatch_all_callable_mocks() + testslide_module.mock_constructor.unpatch_all_constructor_mocks() + testslide_module.patch_attribute.unpatch_all_mocked_attributes() + if aggregated_exceptions.exceptions: + pytest.fail(str(aggregated_exceptions), False) + + @staticmethod + def mock_callable( + *args: Any, **kwargs: Any + ) -> testslide_module.mock_callable._MockCallableDSL: + return testslide_module.mock_callable.mock_callable(*args, **kwargs) + + @staticmethod + def mock_async_callable( + *args: Any, **kwargs: Any + ) -> testslide_module.mock_callable._MockAsyncCallableDSL: + return testslide_module.mock_callable.mock_async_callable(*args, **kwargs) + + @staticmethod + def mock_constructor( + *args: Any, **kwargs: Any + ) -> testslide_module.mock_constructor._MockConstructorDSL: + return testslide_module.mock_constructor.mock_constructor(*args, **kwargs) + + @staticmethod + def patch_attribute(*args: Any, **kwargs: Any) -> None: + return testslide_module.patch_attribute.patch_attribute(*args, **kwargs) + + +@pytest.fixture +def testslide() -> Iterator[_TestSlideFixture]: + with _TestSlideFixture() as testslide_fixture: + yield testslide_fixture diff --git a/pytest-testslide/requirements.txt b/pytest-testslide/requirements.txt new file mode 100644 index 00000000..75dfd668 --- /dev/null +++ b/pytest-testslide/requirements.txt @@ -0,0 +1,2 @@ +pytest~=6.2 +pytest-asyncio>=0.14.0 \ No newline at end of file diff --git a/pytest-testslide/setup.py b/pytest-testslide/setup.py new file mode 100644 index 00000000..acaa5b35 --- /dev/null +++ b/pytest-testslide/setup.py @@ -0,0 +1,49 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import os + +from setuptools import setup + +dir_path = os.path.dirname(os.path.realpath(__file__)) + +ts_version = ( + open(os.path.join(dir_path, os.pardir, "testslide_version")).read().rstrip() +) +requirements = open("requirements.txt", encoding="utf8").readlines() + +with open("README.md", encoding="utf8") as f: + readme = f.read() + +setup( + name="pytest-testslide", + version=ts_version, + py_modules=["pytest_testslide"], + maintainer="Balint Csergo", + maintainer_email="deathowlzz@gmail.com", + url="https://github.com/facebookincubator/TestSlide/tree/master/pytest-testslide", + license="MIT", + description="TestSlide fixture for pytest", + long_description=readme, + long_description_content_type="text/markdown", + setup_requires=["setuptools>=38.6.0"], + install_requires=[ + f"testslide>={ts_version}", + ] + + requirements, + extras_require={"build": ["black", "flake8", "mypy"]}, + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Topic :: Software Development :: Testing", + "Topic :: Software Development :: Testing :: Acceptance", + "Topic :: Software Development :: Testing :: BDD", + "Topic :: Software Development :: Testing :: Mocking", + "Topic :: Software Development :: Testing :: Unit ", + ], +) diff --git a/pytest-testslide/tests/conftest.py b/pytest-testslide/tests/conftest.py new file mode 100644 index 00000000..b6d4f037 --- /dev/null +++ b/pytest-testslide/tests/conftest.py @@ -0,0 +1,6 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +pytest_plugins = "pytester" diff --git a/pytest-testslide/tests/test_pytest_testslide.py b/pytest-testslide/tests/test_pytest_testslide.py new file mode 100644 index 00000000..16decef4 --- /dev/null +++ b/pytest-testslide/tests/test_pytest_testslide.py @@ -0,0 +1,168 @@ +# Copyright (c) Facebook, Inc. and its affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. +import re + + +def test_pass(testdir): + testdir.makepyfile( + """ + import time + import pytest + from pytest_testslide import testslide + from tests import sample_module + from testslide import StrictMock + + def test_has_mock_callable(testslide): + testslide.mock_callable + + def test_mock_callable_assertion_works(testslide): + testslide.mock_callable + + def test_mock_callable_unpaches(testslide): + testslide.mock_callable + + def test_has_mock_async_callable(testslide): + testslide.mock_async_callable + + def test_mock_async_callable_assertion_works(testslide): + testslide.mock_async_callable + + def test_mock_async_callable_unpaches(testslide): + testslide.mock_async_callable + + def test_has_mock_constructor(testslide): + testslide.mock_constructor + + def test_mock_constructor_assertion_works(testslide): + testslide.mock_constructor + + def test_mock_constructor_unpaches(testslide): + testslide.mock_constructor + + def test_has_patch_attribute(testslide): + testslide.patch_attribute + + def test_patch_attribute_unpaches(testslide): + testslide.patch_attribute + + # mock_callable integration tests + def test_mock_callable_patching_works(testslide): + testslide.mock_callable(time, "sleep").to_raise(RuntimeError("Mocked!")) + with pytest.raises(RuntimeError): + time.sleep() + + def test_mock_callable_unpatching_works(testslide): + # This will fail if unpatching from test_mock_callable_patching_works does + # not happen + time.sleep(0) + + def test_mock_callable_assertion_works(testslide): + testslide.mock_callable("time", "sleep").for_call(0).to_call_original().and_assert_called_once() + time.sleep(0) + + def test_mock_callable_failing_assertion_works(testslide): + testslide.mock_callable("time", "sleep").for_call(0).to_call_original().and_assert_called_once() + + # mock_async_callable integration test + @pytest.mark.asyncio + async def test_mock_async_callable_patching_works(testslide): + testslide.mock_async_callable(sample_module.ParentTarget, "async_static_method").to_raise(RuntimeError("Mocked!")) + with pytest.raises(RuntimeError): + await sample_module.ParentTarget.async_static_method("a", "b") + + @pytest.mark.asyncio + async def test_mock_async_callable_unpatching_works(testslide): + # This will fail if unpatching from test_mock_async_callable_patching_works does + # not happen + assert await sample_module.ParentTarget.async_static_method("a", "b") == "async original response" + + @pytest.mark.asyncio + async def test_mock_async_callable_assertion_works(testslide): + testslide.mock_async_callable(sample_module.ParentTarget, "async_static_method").for_call("a", "b").to_call_original().and_assert_called_once() + await sample_module.ParentTarget.async_static_method("a", "b") + + def test_mock_async_callable_failing_assertion_works(testslide): + testslide.mock_async_callable(sample_module.ParentTarget, "async_static_method").for_call("a", "b").to_call_original().and_assert_called_once() + + + # mock_constructor integration test + def test_mock_constructor_patching_works(testslide): + testslide.mock_constructor(sample_module, "ParentTarget").to_raise(RuntimeError("Mocked!")) + with pytest.raises(RuntimeError): + sample_module.ParentTarget() + + def test_mock_constructor_unpatching_works(testslide): + # This will fail if unpatching from test_mock_constructor_patching_works does + # not happen + assert sample_module.ParentTarget() + + def test_mock_constructor_assertion_works(testslide): + testslide.mock_constructor(sample_module, "ParentTarget").to_call_original().and_assert_called_once() + sample_module.ParentTarget() + + def test_mock_constructor_failing_assertion_works(testslide): + testslide.mock_constructor(sample_module, "ParentTarget").to_call_original().and_assert_called_once() + + # patch_attribute integration test + def test_patch_attribute_patching_works(testslide): + testslide.patch_attribute(sample_module.SomeClass, "attribute", "patched") + assert sample_module.SomeClass.attribute == "patched" + + def test_patch_attribute_unpatching_works(testslide): + # This will fail if unpatching from test_mock_callable_patching_works does + # not happen + assert sample_module.SomeClass.attribute == "value" + + + def test_aggregated_exceptions(testslide): + mocked_cls = StrictMock(sample_module.CallOrderTarget) + testslide.mock_callable(mocked_cls, 'f1')\ + .for_call("a").to_return_value("mocked")\ + .and_assert_called_once() + testslide.mock_callable(mocked_cls, 'f1')\ + .for_call("b").to_return_value("mocked2")\ + .and_assert_called_once() + sample_module.CallOrderTarget("c").f1("a") + """ + ) + result = testdir.runpytest("-v") + assert "passed, 4 errors" in result.stdout.str() + assert "failed" not in result.stdout.str() + expected_failure = re.compile( + """.*_______ ERROR at teardown of test_mock_callable_failing_assertion_works ________ +1 failures. +: calls did not match assertion. +\'time\', \'sleep\': + expected: called exactly 1 time\(s\) with arguments: + \(0,\) + received: 0 call\(s\) +____ ERROR at teardown of test_mock_async_callable_failing_assertion_works _____ +1 failures. +: calls did not match assertion. +, \'async_static_method\': + expected: called exactly 1 time\(s\) with arguments: + \(\'a\', \'b\'\) + received: 0 call\(s\) +______ ERROR at teardown of test_mock_constructor_failing_assertion_works ______ +1 failures. +: calls did not match assertion. +, \'__new__\': + expected: called exactly 1 time\(s\) with any arguments received: 0 call\(s\) +_______________ ERROR at teardown of test_aggregated_exceptions ________________ +2 failures. +: calls did not match assertion. +, \'f1\': + expected: called exactly 1 time\(s\) with arguments: + \(\'a\',\) + received: 0 call\(s\) +: calls did not match assertion. +, \'f1\': + expected: called exactly 1 time\(s\) with arguments: + \(\'b\',\) + received: 0 call\(s\).*""", + re.MULTILINE | re.DOTALL, + ) + assert expected_failure.match(result.stdout.str()) + assert result.ret != 0 diff --git a/pytest-testslide/testslide-version b/pytest-testslide/testslide-version new file mode 100644 index 00000000..e46a05b1 --- /dev/null +++ b/pytest-testslide/testslide-version @@ -0,0 +1 @@ +2.6.4 \ No newline at end of file diff --git a/release.sh b/release.sh index e71569ff..35ea084b 100755 --- a/release.sh +++ b/release.sh @@ -17,6 +17,7 @@ sed -i -e "s/Version .*/Version $release_version/" \ util/testslide-snippets/README.md \ util/testslide-snippets/CHANGELOG.md sed -i -e "s/\"version\":.*/\"version\": \"$release_version\",/" util/testslide-snippets/package.json +echo $release_version > pytest-testslide/testslide-version git add testslide/version util/testslide-snippets git commit -m "v$release_version" git push