├── .github └── workflows │ └── main.yml ├── .gitignore ├── .markdownlint.yaml ├── LICENSE ├── README.md ├── Taskfile.yml ├── docs ├── api.md ├── conf.py ├── experimental.md ├── fixture.md ├── fixtures.md ├── index.md ├── motivation.md ├── parametrize.md └── recipes.md ├── integration ├── __init__.py ├── test_args.py ├── test_args_n_teardown.py ├── test_attr.py ├── test_parametrize.py ├── test_scope_class.py ├── test_simple.py ├── test_teardown.py └── test_unwrap.py ├── netlify.sh ├── netlify.toml ├── pyproject.toml ├── pytypest ├── __init__.py ├── _autouse.py ├── _case.py ├── _fixture.py ├── _fixture_factory.py ├── _hub.py ├── _manager.py ├── _parametrize.py ├── _plugin.py ├── _scope.py ├── _scope_manager.py ├── experimental │ ├── __init__.py │ ├── _attr.py │ └── _patcher.py ├── fixtures │ ├── __init__.py │ ├── _helpers.py │ ├── _misc.py │ └── _pytest.py └── py.typed ├── setup.cfg └── tests ├── __init__.py ├── conftest.py ├── test_autouse.py ├── test_experimental.py ├── test_fixtrues.py ├── test_fixture.py └── test_parametrize.py /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | workflow_dispatch: 9 | 10 | concurrency: 11 | group: ${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | lint: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v3 19 | - uses: actions/setup-python@v4 20 | with: 21 | python-version: "3.8" 22 | - uses: arduino/setup-task@v1 23 | with: 24 | repo-token: ${{ github.token }} 25 | - run: task lint 26 | 27 | test: 28 | runs-on: ubuntu-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | python-version: ["3.8", "3.9", "3.10", "3.11"] 33 | steps: 34 | - uses: actions/checkout@v3 35 | - uses: actions/setup-python@v4 36 | with: 37 | python-version: ${{ matrix.python-version }} 38 | - uses: arduino/setup-task@v1 39 | with: 40 | repo-token: ${{ github.token }} 41 | - run: task pytest 42 | 43 | markdownlint-cli: 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v3 47 | - uses: nosborn/github-action-markdown-cli@v3.2.0 48 | with: 49 | files: . 50 | config_file: .markdownlint.yaml 51 | dot: true 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | /.task/ 3 | .*_cache/ 4 | __pycache__/ 5 | /.coverage 6 | /htmlcov/ 7 | /.venvs/ 8 | /.hypothesis/ 9 | /docs/build/ 10 | -------------------------------------------------------------------------------- /.markdownlint.yaml: -------------------------------------------------------------------------------- 1 | # https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml 2 | default: true # enable all by default 3 | MD007: # unordered list indentation 4 | indent: 2 5 | MD013: false # do not validate line length 6 | MD014: false # allow $ before command output 7 | MD029: # ordered list prefix 8 | style: "one" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | 2022 Gram 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included 13 | in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytypest 2 | 3 | Type-safe and maintainable fixtures and parametrization for [pytest](https://github.com/pytest-dev/pytest). 4 | 5 | Features: 6 | 7 | + 100% type safe. 8 | + Great IDE integration, go-to-definition always takes you in the right place. 9 | + Test parametrization that is readable even with many arguments. 10 | + Plug-and-play integration with pytest. 11 | + No vendor-lock, you can use only the features you need and don't touch the rest. 12 | + Fixtures can be cached, and you are in control of for how long. 13 | + Fixtures can accept arguments. 14 | 15 | Check out [motivation](https://pytypest.orsinium.dev/motivation.html) if you want to know more about why this project was born. 16 | 17 | ## Installation 18 | 19 | ```bash 20 | python3 -m pip install pytypest 21 | ``` 22 | 23 | ## Usage 24 | 25 | Fixtures are regular helper functions that `yield` their result and do teardown afterwards: 26 | 27 | ```python 28 | from typing import Iterator 29 | from pytypest import fixture 30 | 31 | @fixture 32 | def get_user(anonymous: bool) -> Iterator[User]: 33 | u = User(anonymous=anonymous) 34 | u.save() 35 | yield u 36 | u.delete() 37 | 38 | def test_user() -> None: 39 | u = get_user(anonymous=False) 40 | assert u.anonymous is False 41 | ``` 42 | 43 | Compared to built-in pytest fixtures, these are explicit, type-safe, can accept arguments, support go-to-definition in IDE, and can be used as context managers. And like pytest fixtures, they are cached and can be scoped to the module or the whole session. 44 | 45 | Read more in the **documentation**: [pytypest.orsinium.dev](https://pytypest.orsinium.dev/). 46 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | # https://taskfile.dev/ 2 | version: "3" 3 | 4 | vars: 5 | PYTHON: python3 6 | VENVS: .venvs 7 | TEST_ENV: .venvs/test 8 | LINT_ENV: .venvs/lint 9 | DOCS_ENV: .venvs/docs 10 | TEST_PYTHON: "{{.TEST_ENV}}/bin/python3" 11 | LINT_PYTHON: "{{.LINT_ENV}}/bin/python3" 12 | DOCS_PYTHON: "{{.DOCS_ENV}}/bin/python3" 13 | 14 | env: 15 | FLIT_ROOT_INSTALL: "1" 16 | 17 | tasks: 18 | install:flit: 19 | status: 20 | - which flit 21 | cmds: 22 | - python3 -m pip install flit 23 | venv:test: 24 | status: 25 | - test -d {{.TEST_ENV}} 26 | cmds: 27 | - "{{.PYTHON}} -m venv {{.TEST_ENV}}" 28 | venv:lint: 29 | status: 30 | - test -d {{.LINT_ENV}} 31 | cmds: 32 | - "{{.PYTHON}} -m venv {{.LINT_ENV}}" 33 | venv:docs: 34 | status: 35 | - test -d {{.DOCS_ENV}} 36 | cmds: 37 | - "{{.PYTHON}} -m venv {{.DOCS_ENV}}" 38 | install:test: 39 | sources: 40 | - pyproject.toml 41 | deps: 42 | - install:flit 43 | - venv:test 44 | cmds: 45 | - > 46 | flit install 47 | --python {{.TEST_PYTHON}} 48 | --extras=test 49 | --deps=production 50 | --symlink 51 | install:lint: 52 | sources: 53 | - pyproject.toml 54 | deps: 55 | - install:flit 56 | - venv:lint 57 | cmds: 58 | - > 59 | flit install 60 | --python {{.LINT_PYTHON}} 61 | --extras=lint 62 | --deps=production 63 | --symlink 64 | install:docs: 65 | sources: 66 | - pyproject.toml 67 | deps: 68 | - install:flit 69 | - venv:docs 70 | cmds: 71 | - > 72 | flit install 73 | --python {{.DOCS_PYTHON}} 74 | --extras=docs 75 | --deps=production 76 | --symlink 77 | 78 | release: 79 | desc: generate and upload a new release 80 | deps: 81 | - install:flit 82 | cmds: 83 | - which gh 84 | - test {{.CLI_ARGS}} 85 | - cat pytypest/__init__.py | grep {{.CLI_ARGS}} 86 | - rm -rf dist/ 87 | - flit build 88 | - flit publish 89 | - git tag {{.CLI_ARGS}} 90 | - git push 91 | - git push --tags 92 | - gh release create --generate-notes {{.CLI_ARGS}} 93 | - gh release upload {{.CLI_ARGS}} ./dist/* 94 | 95 | pytest: 96 | desc: "run Python tests" 97 | deps: 98 | - install:test 99 | cmds: 100 | - "{{.TEST_PYTHON}} -m pytest {{.CLI_ARGS}} --cov-fail-under=100 tests/" 101 | pytest:integration: 102 | desc: "run Python integration tests for pytest" 103 | deps: 104 | - install:test 105 | env: 106 | PYTEST_PLUGINS: pytypest._plugin 107 | cmds: 108 | - "{{.TEST_PYTHON}} -m pytest {{.CLI_ARGS}} integration/" 109 | flake8: 110 | desc: "lint Python code" 111 | deps: 112 | - install:lint 113 | cmds: 114 | - "{{.LINT_PYTHON}} -m flake8 {{.CLI_ARGS}} ." 115 | ruff: 116 | desc: "lint Python code" 117 | deps: 118 | - install:lint 119 | cmds: 120 | - "{{.LINT_PYTHON}} -m ruff check {{.CLI_ARGS}} ." 121 | mypy: 122 | desc: "check type annotations" 123 | deps: 124 | - install:lint 125 | cmds: 126 | - "{{.LINT_PYTHON}} -m mypy {{.CLI_ARGS}}" 127 | unify: 128 | desc: "convert double quotes to single ones" 129 | deps: 130 | - install:lint 131 | cmds: 132 | - "{{.LINT_PYTHON}} -m unify -r -i --quote=\\' {{.CLI_ARGS}} pytypest tests" 133 | isort: 134 | desc: "sort imports" 135 | deps: 136 | - install:lint 137 | cmds: 138 | - "{{.LINT_PYTHON}} -m isort {{.CLI_ARGS}} ." 139 | isort:check: 140 | desc: "sort imports" 141 | deps: 142 | - install:lint 143 | cmds: 144 | - "{{.LINT_PYTHON}} -m isort --check {{.CLI_ARGS}} ." 145 | sphinx: 146 | desc: "generate HTML documentation" 147 | deps: 148 | - install:docs 149 | cmds: 150 | - rm -rf docs/build 151 | - "{{.DOCS_ENV}}/bin/sphinx-build -W docs docs/build {{.CLI_ARGS}}" 152 | 153 | # groups 154 | format: 155 | desc: "run all code formatters" 156 | cmds: 157 | - task: isort 158 | - task: unify 159 | lint: 160 | desc: "run all linters" 161 | cmds: 162 | - task: ruff 163 | - task: flake8 164 | - task: mypy 165 | - task: isort:check 166 | test: 167 | desc: "run all tests" 168 | cmds: 169 | - task: pytest:integration 170 | - task: pytest 171 | all: 172 | desc: "run all code formatters, linters, and tests" 173 | cmds: 174 | - task: format 175 | - task: lint 176 | - task: test 177 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | ```{eval-rst} 4 | .. currentmodule:: pytypest 5 | .. autosummary:: 6 | :nosignatures: 7 | 8 | autouse 9 | case 10 | fixture 11 | parametrize 12 | Scope 13 | ``` 14 | 15 | ## Modules 16 | 17 | + [pytypest.fixtures](./fixtures.md) 18 | + [pytypest.experimental](./experimental.md) 19 | 20 | ## Public 21 | 22 | ```{eval-rst} 23 | .. autofunction:: autouse 24 | .. autofunction:: case 25 | .. autofunction:: fixture 26 | .. autofunction:: parametrize 27 | .. autoclass:: Scope() 28 | :members: 29 | ``` 30 | 31 | ## Private 32 | 33 | ```{eval-rst} 34 | .. autoclass:: pytypest._fixture.Fixture() 35 | :members: __call__, __enter__, setup, teardown 36 | .. autoclass:: pytypest._case.CaseMaker() 37 | :members: 38 | .. autoclass:: pytypest._case.Case() 39 | :members: 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | project = 'pytypest' 2 | copyright = '2023, @orsinium' 3 | author = '@orsinium' 4 | templates_path = ['_templates'] 5 | html_theme = 'alabaster' 6 | autodoc_typehints_format = 'short' 7 | autodoc_preserve_defaults = True 8 | autodoc_member_order = 'bysource' 9 | 10 | extensions = [ 11 | 'sphinx.ext.autodoc', 12 | 'sphinx.ext.napoleon', 13 | 'sphinx.ext.autosummary', 14 | 'sphinx.ext.extlinks', 15 | 'myst_parser', 16 | ] 17 | 18 | extlinks = { 19 | 'pytest': ('https://docs.pytest.org/en/latest/reference/reference.html#%s', '%s'), 20 | } 21 | -------------------------------------------------------------------------------- /docs/experimental.md: -------------------------------------------------------------------------------- 1 | # Experimental 2 | 3 | There are some features that I find neat and helpful but there is no consensus on how readable they are. 4 | 5 | ```{eval-rst} 6 | .. currentmodule:: pytypest.experimental 7 | .. autosummary:: 8 | :nosignatures: 9 | 10 | attr 11 | patcher 12 | 13 | .. autoclass:: attr 14 | .. autofunction:: patcher 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/fixture.md: -------------------------------------------------------------------------------- 1 | # Fixture 2 | 3 | Fixtures are helper functions that are cached for the duration of a single test and may have teardown logic to be executed when the test finishes. For example, a fixture may start a database transaction for each test and then rollback that transaction when the test finishes, so that changes done by one test won't affect tests running after it. 4 | 5 | ## Defining fixtures 6 | 7 | Fixture is a generator function decorated with `pytypest.fixture`. It has 3 parts: 8 | 9 | 1. **Setup** is everything that goes before `yield`. It prepares environment for the test, establishes connections, creates fake data. 10 | 1. **Result** is what goes on the right from `yield`. This is what the fixture returns into the test function to use. 11 | 1. **Teardown** is everything that goes after `yield`. It cleans up environment after the test, closes connections, removes data from the database. 12 | 13 | It's similar to [@contextlib.contextmanager](https://docs.python.org/3/library/contextlib.html#contextlib.contextmanager) except that `yield` never raises an exception, even if the test fails (so you don't need to wrap it into `try-finally`). 14 | 15 | ```python 16 | from typing import Iterator 17 | from pytypest import fixture 18 | 19 | cache = {} 20 | 21 | @fixture 22 | def get_cache() -> Iterator[dict]: 23 | # setup: prepare environment for the test 24 | old_cache = cache.copy() 25 | cache.clear() 26 | 27 | # yield fixture result for the test to use 28 | yield cache 29 | 30 | # teardown: clean up environment after the test 31 | cache.clear() 32 | cache.update(old_cache) 33 | ``` 34 | 35 | ## Using fixtures 36 | 37 | You can call fixtures from test functions and other fixtures as a regular function: 38 | 39 | ```python 40 | cache = get_cache() 41 | assert cache == {} 42 | ``` 43 | 44 | ## Scope 45 | 46 | You can specify `scope` for a fixture which controls when tear down will be executed. For example, `Scope.SESSION` indicates that the fixture must be executed only once for all tests. Setup will be executed when the fixture is first called and teardown will be executed when pytest finished running all the tests. 47 | 48 | ```python 49 | from pytypest import fixture, Scope 50 | 51 | @fixture(scope=Scope.SESSION) 52 | def connect_to_db(): 53 | ... 54 | ``` 55 | 56 | Be careful with the scope. If the fixture returns a mutable object, one test may change it affection all the tests running after it. Consider using [pytest-randomly](https://github.com/pytest-dev/pytest-randomly) to randomize the tests' order and catch side-effects early. 57 | 58 | ## Fixtures with arguments 59 | 60 | Fixtures can accept arguments. It's especially useful for factories. 61 | 62 | ```python 63 | @fixture 64 | def make_user(name: str = 'Guido') -> Iterator[User]: 65 | u = User(name=name) 66 | u.save() 67 | yield u 68 | u.delete() 69 | ``` 70 | 71 | ## Caching 72 | 73 | Fixtures **without arguments** are cached for the duration of their scope. 74 | 75 | ```python 76 | cache1 = get_cache() 77 | cache2 = get_cache() 78 | assert cache1 is cache2 79 | ``` 80 | 81 | ## Context manager 82 | 83 | You can use any fixture as a context manager. Then setup will be executed when entering the context and teardown when leaving it. The cached value, even if available, will not be used. 84 | 85 | ```python 86 | with connect_to_db() as connection: 87 | ... 88 | ``` 89 | 90 | ## Fixtures without teardown 91 | 92 | If a fixture doesn't have teardown, you can use `return` instead of `yield`: 93 | 94 | ```python 95 | @fixture 96 | def make_user() -> User: 97 | return User() 98 | ``` 99 | 100 | You should use fixtures only if you need teardown, scoping, or caching. Otherwise, prefer plain old helper functions. 101 | 102 | ## Mixing with pytest 103 | 104 | You can call fixtures from anywhere within running pytest tests, including other pytypest fixtures, pytest fixtures, and helper functions. 105 | 106 | If you want to call a pytest fixture, use {py:func}`pytypest.fixtures.get_pytest_fixture`: 107 | 108 | ```python 109 | from pytypest.fixtures import get_pytest_fixture 110 | django_db_keepdb = get_pytest_fixture('django_db_keepdb') 111 | ``` 112 | 113 | You usually need to use it only for accessing fixtures defined in pytest plugins (like the example below fetching a fixture defined in [pytest-django](https://pytest-django.readthedocs.io/)) because [pytypest.fixtures](./fixtures.md) already defines wrappers for all built-in pytest fixtures. 114 | 115 | ## autouse 116 | 117 | You can specify fixtures to be used automatically when entering their scope, regardless if they were explicitly called or not. It's especially useful for fixtures that ensure isolation for all tests, like the ones forbidding network interactions, unclosed files, or having unhandled warnings. Don't overuse it, though, and prefer explicitly called fixtures over implicit ones. 118 | 119 | To register such fixtures, call `pytypest.autouse` and pass inside all fixtures that should be automatically used for all tests. The best place to do that is in `tests/conftest.py`. 120 | 121 | ```python 122 | import os 123 | from pytypest import autouse, fixture 124 | from pytypest.fixtures import forbid_networking 125 | 126 | 127 | @fixture 128 | def ensure_environ_unchanged(): 129 | old = os.environ.copy() 130 | yield 131 | assert os.environ == old 132 | 133 | autouse( 134 | forbid_networking, 135 | ensure_environ_unchanged, 136 | ) 137 | ``` 138 | 139 | The `autouse` function can be called only once, so that there is only one place in the whole project where all such fixtures are listed. 140 | -------------------------------------------------------------------------------- /docs/fixtures.md: -------------------------------------------------------------------------------- 1 | # Fixtures 2 | 3 | ```{eval-rst} 4 | .. currentmodule:: pytypest.fixtures 5 | .. autosummary:: 6 | :nosignatures: 7 | 8 | capture_logs 9 | capture_std 10 | chdir 11 | defer 12 | delattr 13 | enter_context 14 | forbid_networking 15 | get_project_root 16 | get_pytest_fixture 17 | get_request 18 | make_temp_dir 19 | monkeypatch 20 | preserve_mapping 21 | record_warnings 22 | setattr 23 | 24 | .. autofunction:: capture_logs 25 | .. autofunction:: capture_std 26 | .. autofunction:: chdir 27 | .. autofunction:: defer 28 | .. autofunction:: delattr 29 | .. autofunction:: enter_context 30 | .. autofunction:: forbid_networking 31 | .. autofunction:: get_project_root 32 | .. autofunction:: get_pytest_fixture 33 | .. autofunction:: get_request 34 | .. autofunction:: make_temp_dir 35 | .. autofunction:: monkeypatch 36 | .. autofunction:: preserve_mapping 37 | .. autofunction:: record_warnings 38 | .. autofunction:: setattr 39 | ``` 40 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # pytypest 2 | 3 | ```{include} ../README.md 4 | :start-after: "# pytypest" 5 | :end-before: "## Usage" 6 | ``` 7 | 8 | **Source code:** [github.com/orsinium-labs/pytypest](https://github.com/orsinium-labs/pytypest). 9 | 10 | ```{eval-rst} 11 | .. toctree:: 12 | :maxdepth: 1 13 | 14 | fixture 15 | parametrize 16 | fixtures 17 | experimental 18 | recipes 19 | api 20 | motivation 21 | ``` 22 | -------------------------------------------------------------------------------- /docs/motivation.md: -------------------------------------------------------------------------------- 1 | # Motivation 2 | 3 | The framework was created to solve very specific problems with pytest inherit in its design. 4 | 5 | ## pytest.fixture 6 | 7 | 1. **Fixtures aren't type annotated**. To have an autocomplete for a fixture inside of test function, I have to explicitly annotate the fixture each time I use it. 8 | 1. **Fixtures aren't namespaced**. Big projects end up with fixtures like `client_with_cart_and_items_in_it`. 9 | 1. **Tests cannot pass parameters into fixtures**. People end up making awful workarounds like overriding fixtures, or parametrizing the test for just the fixture, and it's all very implicit and hard to maintain. 10 | 1. **Fixtures can be overriden in submodules**. When there is a test function that uses `parcel` fixture, and the project defines 6 different fixtures with the same name, you need to check each one of them, and find the one that shares the most of the path with the test. 11 | 1. **Go-to-definition on fixtures doesn't work**. Again, each time you want to see the fixture implementation, it turns into a treasure hunt. 12 | 1. **Fixtures aren't namespaced**. When you have a global fixture `order` that uses a global fixture `user` and then the `user` fixture is overriden for a particular test file or by a test parameter, the `order` fiture will start using that new version. This violates [open-closed principle](https://en.wikipedia.org/wiki/Open%E2%80%93closed_principle) (particular test can modify a global behavior) and that's the reason why the modern practice is to prefer [composition over inheritance](https://en.wikipedia.org/wiki/Composition_over_inheritance). 13 | 1. **The order of fixtures run is implicit**. In theory, the order of fixtures shouldn't matter because they shouldn't have implicit dependencies on each other. On practice, you often will have global side-effects in fixtures that may affect other fixtures. For example, if you add a fixture for [freezegun](https://github.com/spulec/freezegun), the time will be frozen for some fixtures but not others. And when using it as a decorator, the time won't be frozen for any fixtures at all. 14 | 1. **There is no distinction between setup and data fixtures**. When tests gets changed or copy-pasted, you often will have tests that require fixtures that it actually doesn't need. Each such fixture increases coupling and slowes down each test run. 15 | 1. **There is no lazy execution of fixtures**. If a function requires a fixture, it will be executed before the function is even called. 16 | 17 | ## pytest.mark.parametrize 18 | 19 | 1. **Arguments are positional-only**. It's ok when you have only 1-2 arguments, but when the parametrization includes multiple parameters, it gets impossible to read. You can use a dict for each parameter, but that makes sense only for relevant items, and you won't get autocomplete. 20 | 1. **Arguments can't have default values**. If you have a parameter that differs in 90% of cases, you still have to repeat it for each case. 21 | 1. **Arguments aren't type-safe**. Type-checking tests helps to ensure that type annotations for your code reflect how you use it. Without it, type annotations may lie. And documentation that lies is worse than no documentation. 22 | 1. **Test cases go before test function**. When you open a file with a test with many test cases, the first thing you see is a bunch of obscure values that don't make any sense for you yet. The test name, description, and implementation must go first, and only then specific test values, when you already know what they mean. Humans read from top to bottom. 23 | -------------------------------------------------------------------------------- /docs/parametrize.md: -------------------------------------------------------------------------------- 1 | # Parametrize 2 | 3 | ## Intro into table-driven tests 4 | 5 | If you already familiar with table-driven tests and `pytest.mark.parametrize`, got to the next section. 6 | 7 | Let's say you have a function `concat` that concatenates the two given strings. For example, `concat('ohhi', 'mark')` should return `'ohhimark'`. Now, it's time to test it: 8 | 9 | ```python 10 | def test_concat__simple(): 11 | assert concat('ohhi', 'mark') == 'ohhimark' 12 | 13 | def test_concat__empty(): 14 | assert concat('', '') == '' 15 | 16 | def test_concat__unicode(): 17 | assert concat('привет', 'марк') == 'приветмарк' 18 | ``` 19 | 20 | Each time you want to test a new set of parameters, you have to copy-paste the whole test function body, and it doesn't scale well. Especially if the test has fixtures, setup, multiple assertions, and all that stuff. So, instead you try using a loop: 21 | 22 | ```python 23 | def test_concat(): 24 | cases = [ 25 | ('ohhi', 'mark', 'ohhimark'), 26 | ('', '', ''), 27 | ('привет', 'марк', 'приветмарк'), 28 | ] 29 | for case in cases: 30 | a, b, exp = case 31 | assert concat(a, b) == exp 32 | ``` 33 | 34 | It's much easier to read and extend, but when one case fails, you don't know if other cases will fail or pass (the test function exits on the first failure) and there is no way run only this specific failing case. 35 | 36 | Enter pytypest. 37 | 38 | ## Parametrization 39 | 40 | The `pytypest.parametrize` function provides a nice way to parametrize tests. You pass in it a test function that accepts parameters as arguments, specify test cases as a set of function arguments, and pytypest will generate a separate test function for each case: 41 | 42 | ```python 43 | from pytypest import case, parametrize 44 | 45 | def _test_concat(a: str, b: str, exp: str): 46 | assert concat(a, b) == exp 47 | 48 | test_concat = parametrize( 49 | _test_concat, 50 | case('ohhi', 'mark', exp='ohhimark'), 51 | case('', '', exp=''), 52 | case('привет', 'марк', exp='приветмарк'), 53 | ) 54 | ``` 55 | 56 | It is much better than the loop we had earlier: 57 | 58 | 1. You can use keyword arguments, which is great for readability when you have multiple parameters. 59 | 1. Test cases go after the test function implementation. Most humans read from top to bottom, so it helps the readability to show first the test logic and how test parameters are used (and hence what they mean) and only after that specific values for parameters. 60 | 1. For each test case, a new test will be generated. Hence you can run only specific test cases, and failures in one test case won't affect others. 61 | 62 | ## Naming test cases 63 | 64 | By default, pytest will do its best to generate a unique name for each test case. It works well if there are just a few parameters and each is a short primitive type, but doesn't work so well for more ocmplex cases. A good test name is helpful for more descriptive failure messages. So, if you want to explicitly specify a good meaningful name for a test case, pass it as a keyword argument: 65 | 66 | ```python 67 | test_concat = parametrize( 68 | _test_concat, 69 | case('ohhi', 'mark', exp='ohhimark'), 70 | empty_strings=case('', '', exp=''), 71 | unicode=case('привет', 'марк', exp='приветмарк'), 72 | ) 73 | ``` 74 | 75 | That's the preferred way to do that because then your IDE automatically ensures each name is unique. However, if you want to use as a name a string that isn't a valid python identifier, use `case.id` method: 76 | 77 | ```python 78 | test_concat = parametrize( 79 | _test_concat, 80 | case('ohhi', 'mark', exp='ohhimark'), 81 | case.id('empty strings')('', '', exp=''), 82 | case.id('unicode')('привет', 'марк', exp='приветмарк'), 83 | ) 84 | ``` 85 | -------------------------------------------------------------------------------- /docs/recipes.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | ## Skipping tests 4 | 5 | Use {pytest}`pytest.skip` to skip a test: 6 | 7 | ```python 8 | def test_something() -> None: 9 | if sys.version_info < (3, 10): 10 | pytest.skip('unsupported python version') 11 | ... 12 | ``` 13 | 14 | ## Fixtures' parametrization 15 | 16 | You can use in parametrization any objects, including helper functions, fixtures, and arguments for fixtures: 17 | 18 | ```python 19 | @fixture 20 | def make_user(anonymous: bool): 21 | return User(anonymous=anonymous) 22 | 23 | def _test_user(anonymous: bool): 24 | u = make_user(anonymous=anonymous) 25 | 26 | test_user = parametrize( 27 | _test_user, 28 | case(anonymous=False), 29 | case(anonymous=True), 30 | ) 31 | ``` 32 | -------------------------------------------------------------------------------- /integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/pytypest/bfb92199079cfe35b22e301a748a840b69854af8/integration/__init__.py -------------------------------------------------------------------------------- /integration/test_args.py: -------------------------------------------------------------------------------- 1 | """Fixtures with arguments aren't cached. 2 | """ 3 | from typing import Iterator 4 | 5 | from pytypest import fixture 6 | 7 | 8 | _setup = [] 9 | 10 | 11 | @fixture 12 | def fixt(a, b) -> Iterator[int]: 13 | _setup.append(a) 14 | return a + b 15 | 16 | 17 | def test_simple1(): 18 | n = fixt(6, b=7) 19 | assert n == 13 20 | assert _setup == [6] 21 | 22 | 23 | def test_simple2(): 24 | n = fixt(a=3, b=4) 25 | assert n == 7 26 | assert _setup == [6, 3] 27 | 28 | 29 | def test_double(): 30 | n1 = fixt(a=3, b=4) 31 | assert n1 == 7 32 | assert _setup == [6, 3, 3] 33 | n1 = fixt(a=4, b=5) 34 | assert n1 == 9 35 | assert _setup == [6, 3, 3, 4] 36 | 37 | 38 | def test_after(): 39 | assert _setup == [6, 3, 3, 4] 40 | -------------------------------------------------------------------------------- /integration/test_args_n_teardown.py: -------------------------------------------------------------------------------- 1 | """Teardown must be executed for fixtures with arguments. 2 | """ 3 | from typing import Iterator 4 | 5 | from pytypest import fixture 6 | 7 | 8 | _setup = [] 9 | _teardown = [] 10 | 11 | 12 | @fixture 13 | def fixt(a, b) -> Iterator[int]: 14 | _setup.append(a) 15 | yield a + b 16 | _teardown.append(a) 17 | 18 | 19 | def test_simple1(): 20 | n = fixt(6, b=7) 21 | assert n == 13 22 | assert _setup == [6] 23 | assert _teardown == [] 24 | 25 | 26 | def test_simple2(): 27 | n1 = fixt(a=3, b=4) 28 | assert n1 == 7 29 | n2 = fixt(a=5, b=4) 30 | assert n2 == 9 31 | assert _setup == [6, 3, 5] 32 | assert _teardown == [6] 33 | 34 | 35 | def test_after(): 36 | assert _setup == [6, 3, 5] 37 | assert _teardown == [6, 3, 5] 38 | -------------------------------------------------------------------------------- /integration/test_attr.py: -------------------------------------------------------------------------------- 1 | """Fixtures can be combined into containers. 2 | 3 | Fixtures in a container instance must be available without calling them. 4 | """ 5 | from pytypest import fixture 6 | from pytypest.experimental import attr 7 | 8 | 9 | _setup = [] 10 | 11 | 12 | @fixture 13 | def fixt() -> int: 14 | _setup.append(0) 15 | return 13 16 | 17 | 18 | class Container: 19 | val = attr(fixt) 20 | 21 | 22 | def test_simple(): 23 | assert _setup == [] 24 | assert Container.val.fixture is fixt 25 | assert _setup == [] 26 | c = Container() 27 | assert _setup == [] 28 | assert c.val == 13 29 | assert _setup == [0] 30 | -------------------------------------------------------------------------------- /integration/test_parametrize.py: -------------------------------------------------------------------------------- 1 | """The basic test for `parametrize` and `case`. 2 | """ 3 | from pytypest import case, parametrize 4 | 5 | 6 | def _test_double(x: int, exp: int): 7 | assert x * 2 == exp 8 | 9 | 10 | test_double = parametrize( 11 | _test_double, 12 | case(3, 6), 13 | case(3, exp=6), 14 | case(x=3, exp=6), 15 | case.id('pos-only')(3, 6), 16 | ) 17 | 18 | 19 | def _test_divide(x: int, y: int = 1, *, exp: int): 20 | assert x // y == exp 21 | 22 | 23 | test_divide = parametrize( 24 | _test_divide, 25 | case(8, 2, exp=4), 26 | case(3, exp=3), 27 | ) 28 | -------------------------------------------------------------------------------- /integration/test_scope_class.py: -------------------------------------------------------------------------------- 1 | """Teardown for class-scoped fixtures is executed after leaving the class scope. 2 | """ 3 | from typing import Iterator 4 | 5 | from pytypest import Scope, fixture 6 | 7 | 8 | _setup = [] 9 | _teardown = [] 10 | 11 | 12 | @fixture(scope=Scope.CLASS) 13 | def fixt() -> Iterator[int]: 14 | _setup.append(0) 15 | yield 13 16 | _teardown.append(0) 17 | 18 | 19 | class TestClass: 20 | def test_simple_1(self): 21 | n = fixt() 22 | assert n == 13 23 | assert _setup == [0] 24 | assert _teardown == [] 25 | 26 | def test_simple_2(self): 27 | n = fixt() 28 | assert n == 13 29 | assert _setup == [0] 30 | assert _teardown == [] 31 | 32 | def test_after_test(self): 33 | assert _setup == [0] 34 | assert _teardown == [] 35 | 36 | 37 | def test_after_class(): 38 | assert _setup == [0] 39 | assert _teardown == [0] 40 | -------------------------------------------------------------------------------- /integration/test_simple.py: -------------------------------------------------------------------------------- 1 | """The most basic test for the most basic fixture. 2 | """ 3 | from pytypest import fixture 4 | 5 | 6 | @fixture 7 | def fixt() -> int: 8 | return 13 9 | 10 | 11 | def test_simple(): 12 | n = fixt() 13 | assert n == 13 14 | -------------------------------------------------------------------------------- /integration/test_teardown.py: -------------------------------------------------------------------------------- 1 | """The basic test for fixtures with teardown. 2 | """ 3 | from typing import Iterator 4 | 5 | from pytypest import fixture 6 | 7 | 8 | _setup = [] 9 | _teardown = [] 10 | 11 | 12 | @fixture 13 | def fixt() -> Iterator[int]: 14 | _setup.append(0) 15 | yield 13 16 | _teardown.append(0) 17 | 18 | 19 | def test_simple(): 20 | n = fixt() 21 | assert n == 13 22 | assert _setup == [0] 23 | assert _teardown == [] 24 | 25 | 26 | def test_after(): 27 | assert _setup == [0] 28 | assert _teardown == [0] 29 | -------------------------------------------------------------------------------- /integration/test_unwrap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Teardown for fixtures must be executed in the reverse order to how they are called. 3 | 4 | So, the first called fixture is teared down the last. 5 | """ 6 | from __future__ import annotations 7 | 8 | from typing import Iterator 9 | 10 | from pytypest import fixture 11 | 12 | 13 | _setup = [] 14 | _teardown = [] 15 | 16 | 17 | @fixture 18 | def a() -> Iterator[None]: 19 | _setup.append('a') 20 | yield 21 | _teardown.append('a') 22 | 23 | 24 | @fixture 25 | def b() -> Iterator[None]: 26 | _setup.append('b') 27 | yield 28 | _teardown.append('b') 29 | 30 | 31 | @fixture 32 | def c() -> Iterator[None]: 33 | a() 34 | _setup.append('c') 35 | yield 36 | _teardown.append('c') 37 | 38 | 39 | def test_simple() -> None: 40 | c() 41 | b() 42 | a() 43 | assert _setup == ['a', 'c', 'b'] 44 | 45 | 46 | def test_after() -> None: 47 | assert _teardown == ['b', 'c', 'a'] 48 | -------------------------------------------------------------------------------- /netlify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script is used by netlify 3 | python3 -m pip install '.[docs]' 4 | sphinx-build docs docs/build 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "docs/build" 3 | command = "./netlify.sh" 4 | environment = {PYTHON_VERSION = "3.8"} 5 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "pytypest" 7 | authors = [ 8 | {name = "Gram", email = "git@orsinium.dev"}, 9 | ] 10 | license = {file = "LICENSE"} 11 | readme = "README.md" 12 | requires-python = ">=3.8" 13 | dynamic = ["version", "description"] 14 | classifiers = [ 15 | "Development Status :: 5 - Production/Stable", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python", 19 | "Typing :: Typed", 20 | ] 21 | keywords = [ 22 | "pytest", 23 | "tests", 24 | "testing", 25 | "framework", 26 | "fixtures", 27 | ] 28 | dependencies = [ 29 | "pytest", 30 | "typing-extensions", 31 | ] 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | "pytest", 36 | "pytest-cov", 37 | "requests", 38 | ] 39 | lint = [ 40 | "flake8", 41 | "isort", 42 | "mypy", 43 | "unify", 44 | "types-requests", 45 | "ruff", 46 | ] 47 | docs = [ 48 | "sphinx", 49 | "myst-parser", 50 | ] 51 | 52 | 53 | [project.urls] 54 | Source = "https://github.com/orsinium-labs/pytypest" 55 | 56 | [tool.mypy] 57 | files = ["pytypest", "tests", "integration"] 58 | python_version = "3.8" 59 | ignore_missing_imports = true 60 | # follow_imports = "silent" 61 | show_error_codes = true 62 | check_untyped_defs = true 63 | no_implicit_optional = true 64 | strict_equality = true 65 | warn_redundant_casts = true 66 | warn_unused_ignores = true 67 | 68 | [tool.isort] 69 | profile = "django" 70 | lines_after_imports = 2 71 | skip = ".venvs/" 72 | 73 | [tool.pytest.ini_options] 74 | addopts = [ 75 | "--cov=pytypest", 76 | "--cov-report=html", 77 | "--cov-report=term-missing:skip-covered", 78 | ] 79 | markers = ["two", "three"] 80 | 81 | [tool.coverage.report] 82 | exclude_lines = [ 83 | "pragma: no cover", 84 | "if TYPE_CHECKING", 85 | " pass", 86 | "except ImportError:", 87 | ] 88 | 89 | [tool.coverage.run] 90 | branch = true 91 | 92 | [tool.ruff] 93 | select = [ 94 | "E", "W", "F", "N", "B", 95 | "COM", "ISC", "PIE", "Q", 96 | "SIM", "PTH", "PL", "RUF", 97 | ] 98 | ignore = [ 99 | "PLR2004", # allow hardcoded constants 100 | "SIM117", # allow nested with 101 | "SIM105", # allow try-except-pass 102 | "PIE790", # allow unnecessary pass statements 103 | ] 104 | target-version = "py38" 105 | 106 | [tool.ruff.flake8-quotes] 107 | inline-quotes = "single" 108 | -------------------------------------------------------------------------------- /pytypest/__init__.py: -------------------------------------------------------------------------------- 1 | """Type-safe and maintainable fixtures and parametrization for pytest. 2 | """ 3 | 4 | from . import experimental, fixtures 5 | from ._autouse import autouse 6 | from ._case import case 7 | from ._fixture_factory import fixture 8 | from ._parametrize import parametrize 9 | from ._scope import Scope 10 | 11 | 12 | __version__ = '1.0.1' 13 | __all__ = [ 14 | 'autouse', 15 | 'case', 16 | 'experimental', 17 | 'fixture', 18 | 'fixtures', 19 | 'parametrize', 20 | 'Scope', 21 | ] 22 | -------------------------------------------------------------------------------- /pytypest/_autouse.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from ._hub import hub 6 | 7 | 8 | if TYPE_CHECKING: 9 | from ._fixture import Fixture 10 | 11 | 12 | def autouse(*fixtures: Fixture[[], None]) -> None: 13 | """Register fixtures to be used automatically when entering a scope. 14 | 15 | Can be called only once in runtime. 16 | 17 | :: 18 | 19 | autouse( 20 | create_database, 21 | clear_cache, 22 | fixtures.forbid_networking, 23 | ) 24 | 25 | """ 26 | if hub.autouse is not None: 27 | raise RuntimeError('autouse can be called only once') 28 | hub.autouse = fixtures 29 | -------------------------------------------------------------------------------- /pytypest/_case.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import dataclasses 4 | from typing import Any, Generic, TypeVar 5 | 6 | from typing_extensions import ParamSpec 7 | 8 | 9 | P = ParamSpec('P') 10 | S = TypeVar('S') 11 | 12 | 13 | @dataclasses.dataclass(frozen=True) 14 | class CaseMaker: 15 | """Create a new test case to be used with parametrized tests. 16 | 17 | :: 18 | 19 | def _test_add(a: int, b: int, exp: int): 20 | assert a + b == exp 21 | 22 | test_add = parametrize( 23 | _test_add, 24 | case(4, 5, exp=9), 25 | ) 26 | 27 | """ 28 | _id: str | None = None 29 | _tags: tuple[str, ...] | None = None 30 | 31 | def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Case[P]: 32 | return Case(args=args, kwargs=kwargs, id=self._id, tags=self._tags) 33 | 34 | def id(self, id: str) -> CaseMaker: 35 | """Give a name to the test case. 36 | 37 | :: 38 | 39 | test_logout = parametrize( 40 | _test_logout, 41 | case.id('anonymous_user')(user1), 42 | ) 43 | 44 | """ 45 | return dataclasses.replace(self, _id=id) 46 | 47 | def tags(self, *tags: str) -> CaseMaker: 48 | """Mark the case with tags that can be used to filter specific tests. 49 | 50 | :: 51 | 52 | test_logout = parametrize( 53 | _test_logout, 54 | case.tags('slow', 'integration')(user1), 55 | ) 56 | 57 | """ 58 | return dataclasses.replace(self, _tags=tags) 59 | 60 | 61 | case = CaseMaker() 62 | 63 | 64 | @dataclasses.dataclass(frozen=True) 65 | class Case(Generic[P]): 66 | """A single test case for parametrized tests. 67 | 68 | Use :func:`pytypest.case` to create a new one. 69 | """ 70 | args: tuple 71 | kwargs: dict[str, Any] 72 | id: str | None = None 73 | tags: tuple[str, ...] | None = None 74 | 75 | def with_id(self, id: str) -> Case[P]: 76 | return dataclasses.replace(self, id=id) 77 | -------------------------------------------------------------------------------- /pytypest/_fixture.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from dataclasses import dataclass, field 5 | from enum import Enum 6 | from typing import Callable, Generic, Iterator, Literal, TypeVar 7 | 8 | from typing_extensions import ParamSpec 9 | 10 | from ._manager import defer 11 | from ._scope import Scope 12 | 13 | 14 | R = TypeVar('R') 15 | P = ParamSpec('P') 16 | 17 | 18 | class Sentinel(Enum): 19 | """A helper to define a singleton sentinel object in a mypy-friendly way. 20 | 21 | :: 22 | 23 | _: Literal[Sentinel.UNSET] = Sentinel.UNSET 24 | 25 | """ 26 | UNSET = object() 27 | 28 | 29 | @dataclass 30 | class Fixture(Generic[P, R]): 31 | """A test fixture with setup and optional teardown. 32 | 33 | Should be constructed using :func:`pytypest.fixture`:: 34 | 35 | @fixture 36 | def get_user() -> Iterator[User]: 37 | ... # setup 38 | yield User() 39 | ... # teardown 40 | 41 | """ 42 | _callback: Callable[P, R | Iterator[R]] 43 | scope: Scope = Scope.FUNCTION 44 | _iters: list[Iterator[R]] = field(default_factory=list) 45 | _result: R | Literal[Sentinel.UNSET] = Sentinel.UNSET 46 | 47 | def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: 48 | """Allows the fixture to be called as a function. 49 | 50 | :: 51 | 52 | @fixture 53 | def get_user(): 54 | ... 55 | 56 | user = get_user() 57 | 58 | """ 59 | if self.scope != Scope.FUNCTION: 60 | msg = 'fixtures with non-function scope must not accept arguments' 61 | assert not args and not kwargs, msg 62 | is_cached = self._result != Sentinel.UNSET and not args and not kwargs 63 | if is_cached: 64 | return self._result # type: ignore[return-value] 65 | result = self.setup(*args, **kwargs) 66 | defer(self.scope, self.teardown) 67 | return result 68 | 69 | def setup(self, *args: P.args, **kwargs: P.kwargs) -> R: 70 | """Execute setup logic of the fixture and get its result. 71 | 72 | Setup is everything that goes before `yield` or `return`. 73 | 74 | Avoid using this method directly. It doesn't use cached results, 75 | doesn't use the scope, and doesn't defer teardown. 76 | Prefer calling the fixture or using it as a context manager. 77 | """ 78 | if inspect.isgeneratorfunction(self._callback): 79 | iterator = self._callback(*args, **kwargs) 80 | result = next(iterator) 81 | self._iters.append(iterator) 82 | else: 83 | result = self._callback(*args, **kwargs) 84 | if not args and not kwargs: 85 | self._result = result 86 | return result 87 | 88 | def teardown(self) -> None: 89 | """Execute teardown logic of the fixture (if available). 90 | 91 | Teardown is the code that goes after `yield` (if `yield` is present). 92 | 93 | Can be safely called mutiple times. 94 | """ 95 | for iterator in self._iters: 96 | try: 97 | next(iterator) 98 | except StopIteration: 99 | pass 100 | else: 101 | raise RuntimeError('fixture must have at most one yield') 102 | self._iters = [] 103 | self._result = Sentinel.UNSET 104 | 105 | def __enter__(self) -> R: 106 | """Allows the fixture to be used as a context manager. 107 | 108 | :: 109 | 110 | @fixture 111 | def get_user(): 112 | ... 113 | 114 | with get_user as user: 115 | ... 116 | 117 | Regardless of the scope, the setup is executed when entering 118 | the context, and the teardown is when leaving it. 119 | 120 | """ 121 | return self.setup() 122 | 123 | def __exit__(self, *exc_info) -> None: 124 | self.teardown() 125 | -------------------------------------------------------------------------------- /pytypest/_fixture_factory.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from functools import update_wrapper 4 | from typing import TYPE_CHECKING, Protocol, overload 5 | 6 | from ._fixture import Fixture 7 | from ._scope import Scope 8 | 9 | 10 | if TYPE_CHECKING: 11 | from typing import Callable, Iterator, Literal, TypeVar 12 | 13 | from typing_extensions import ParamSpec 14 | 15 | R = TypeVar('R') 16 | P = ParamSpec('P') 17 | 18 | 19 | class FixtureMaker(Protocol): 20 | """ 21 | The type of the callback returned by the @fixture 22 | when the decorator is called without the wrapped function 23 | and it has a function scope. 24 | """ 25 | @overload 26 | def __call__(self, callback: Callable[P, Iterator[R]]) -> Fixture[P, R]: 27 | pass 28 | 29 | @overload 30 | def __call__(self, callback: Callable[P, R]) -> Fixture[P, R]: 31 | pass 32 | 33 | def __call__(self, callback): 34 | pass 35 | 36 | 37 | class FixtureMakerWithScope(Protocol): 38 | """ 39 | The type of the callback returned by the @fixture 40 | when non-function `scope` is passed. 41 | 42 | For non-function scope, fixtures must not accept arguments. 43 | The reason is that it cannot be properly cached. 44 | """ 45 | @overload 46 | def __call__(self, callback: Callable[[], Iterator[R]]) -> Fixture[[], R]: 47 | pass 48 | 49 | @overload 50 | def __call__(self, callback: Callable[[], R]) -> Fixture[[], R]: 51 | pass 52 | 53 | def __call__(self, callback): 54 | pass 55 | 56 | 57 | @overload 58 | def fixture( 59 | callback: None = None, 60 | *, 61 | scope: Literal[Scope.FUNCTION] = Scope.FUNCTION, 62 | ) -> FixtureMaker: 63 | """fixture decorator with explicit function scope. 64 | 65 | :: 66 | @fixture(scope=Scope.FUNCTION) 67 | def get_user(): 68 | return User() 69 | 70 | """ 71 | pass 72 | 73 | 74 | @overload 75 | def fixture( 76 | callback: None = None, 77 | *, 78 | scope: Scope, 79 | ) -> FixtureMakerWithScope: 80 | """fixture decorator with scope. 81 | 82 | :: 83 | 84 | @fixture(scope=Scope.SESSION) 85 | def get_user(): 86 | return User() 87 | 88 | """ 89 | pass 90 | 91 | 92 | @overload 93 | def fixture(callback: Callable[P, Iterator[R]]) -> Fixture[P, R]: 94 | """fixture decorator with teardown without scope. 95 | 96 | :: 97 | 98 | @fixture 99 | def get_user(): 100 | yield User() 101 | 102 | """ 103 | pass 104 | 105 | 106 | @overload 107 | def fixture(callback: Callable[P, R]) -> Fixture[P, R]: 108 | """fixture decorator without teardown without scope. 109 | 110 | :: 111 | 112 | @fixture 113 | def get_user(): 114 | return User() 115 | 116 | """ 117 | pass 118 | 119 | 120 | def fixture( 121 | callback: Callable | None = None, 122 | **kwargs, 123 | ) -> Fixture[P, R] | Callable[[Callable], Fixture]: 124 | """A decorator to create a new fixture. 125 | 126 | Fixtures are executed only when called, cached for the given scope, 127 | and may have teardown logic that is executed when exiting the scope. 128 | 129 | :: 130 | 131 | @fixture 132 | def get_user() -> Iterator[User]: 133 | # setup 134 | u = User() 135 | # fixtures can use other fixtures 136 | db = get_database() 137 | db.insert(u) 138 | 139 | # provide data for the test 140 | yield u 141 | 142 | # teardown 143 | db.delete(u) 144 | 145 | You can call the fixture to get the yielded value:: 146 | 147 | def test_user(): 148 | user = get_user() 149 | 150 | Or you can use it as a context manager:: 151 | 152 | def test_user(): 153 | with get_user as user: 154 | ... 155 | 156 | Fixtures can accept arguments:: 157 | 158 | @fixture 159 | def get_user(name: str): 160 | ... 161 | 162 | def test_user(): 163 | conn = get_user(name='Guido') 164 | 165 | Fixtures without teardown may use `return` instead of `yield`:: 166 | 167 | @fixture 168 | def get_user() -> User: 169 | return User() 170 | 171 | Fixtures can be called not only from test functions, 172 | but from other fixtures, pytest fixtures, or helper functions 173 | within a test run. 174 | 175 | """ 176 | if callback is not None: 177 | fixture = Fixture(callback, **kwargs) 178 | return update_wrapper(fixture, callback) 179 | 180 | def wrapper(callback: Callable) -> Fixture: 181 | fixture = Fixture(callback, **kwargs) 182 | return update_wrapper(fixture, callback) 183 | return wrapper 184 | -------------------------------------------------------------------------------- /pytypest/_hub.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING 5 | 6 | 7 | if TYPE_CHECKING: 8 | import pytest 9 | 10 | from ._fixture import Fixture 11 | from ._manager import Manager 12 | 13 | 14 | @dataclass 15 | class Hub: 16 | """Singleton holding all global state. 17 | """ 18 | manager: Manager | None = None 19 | request: pytest.FixtureRequest | None = None 20 | autouse: tuple[Fixture[[], None], ...] | None = None 21 | 22 | def reset(self) -> None: 23 | """Clean up all global state. 24 | 25 | Used for pytypest's unit tests' isolation. 26 | """ 27 | self.manager = None 28 | self.request = None 29 | self.autouse = None 30 | 31 | 32 | hub = Hub() 33 | -------------------------------------------------------------------------------- /pytypest/_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass, field 4 | from typing import Callable 5 | 6 | from ._hub import hub 7 | from ._scope import Scope 8 | from ._scope_manager import ScopeManager 9 | 10 | 11 | def defer(scope: Scope, callback: Callable[[], None]) -> None: 12 | """Schedule the callback to be called when leaving the scope. 13 | 14 | :: 15 | 16 | defer(Scope.FUNCTION, self.teardown) 17 | 18 | """ 19 | if hub.manager is None: 20 | raise RuntimeError('pytest plugin is not activated') 21 | scope_manager = hub.manager.get_scope(scope) 22 | scope_manager.defer(callback) 23 | 24 | 25 | @dataclass(frozen=True) 26 | class Manager: 27 | """Holds a stack of scope managers with smaller scope being on top. 28 | """ 29 | _scopes: list[ScopeManager] = field(default_factory=list) 30 | 31 | def get_scope(self, scope: Scope) -> ScopeManager: 32 | for scope_manager in self._scopes: 33 | if scope_manager.scope is scope: 34 | return scope_manager 35 | raise LookupError(f'cannot find ScopeManager for `{scope.value}` scope') 36 | 37 | def enter_scope(self, scope: Scope) -> None: 38 | scope_manager = ScopeManager(scope) 39 | self._scopes.append(scope_manager) 40 | scope_manager.enter_scope() 41 | 42 | def exit_scope(self, scope: Scope) -> None: 43 | scope_manager = self._scopes.pop() 44 | assert scope_manager.scope == scope 45 | scope_manager.exit_scope() 46 | -------------------------------------------------------------------------------- /pytypest/_parametrize.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | from typing import TYPE_CHECKING, Callable 5 | 6 | import pytest 7 | from typing_extensions import ParamSpec 8 | 9 | from ._case import Case 10 | 11 | 12 | if TYPE_CHECKING: 13 | from _pytest.mark import ParameterSet 14 | 15 | P = ParamSpec('P') 16 | 17 | 18 | def parametrize( 19 | func: Callable[P, None], 20 | *cases: Case[P], 21 | **named_cases: Case[P], 22 | ) -> Callable[[], None]: 23 | """Create a test for each case, each test calling the given func. 24 | 25 | :: 26 | 27 | def _test_add(a: int, b: int, exp: int) -> None: 28 | assert a + b == exp 29 | 30 | test_add = parametrize( 31 | _test_add, 32 | case(3, 4, exp=7), 33 | case(4, 5, exp=9), 34 | zeros=case(0, 0, exp=0), 35 | ) 36 | 37 | """ 38 | sig = inspect.Signature.from_callable(func) 39 | params = list(sig.parameters) 40 | table: list[ParameterSet | list] = [] 41 | row: ParameterSet | list 42 | all_cases = list(cases) 43 | for name, case in named_cases.items(): 44 | all_cases.append(case.with_id(name)) 45 | for case in all_cases: 46 | bound = sig.bind(*case.args, **case.kwargs) 47 | bound.apply_defaults() 48 | row = [bound.arguments[p] for p in params] 49 | if case.id or case.tags: 50 | marks = [getattr(pytest.mark, tag) for tag in (case.tags or [])] 51 | row = pytest.param(*row, id=case.id, marks=tuple(marks)) 52 | table.append(row) 53 | func.__defaults__ = () 54 | return pytest.mark.parametrize(params, table)(func) 55 | -------------------------------------------------------------------------------- /pytypest/_plugin.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Iterator 4 | 5 | import pytest 6 | 7 | from ._hub import hub 8 | from ._manager import Manager 9 | from ._scope import Scope 10 | 11 | 12 | SESSION_ATTR = '_pytypest_manager' 13 | 14 | 15 | def pytest_sessionstart(session: pytest.Session) -> None: 16 | manager = Manager() 17 | hub.manager = manager 18 | setattr(session, SESSION_ATTR, manager) 19 | 20 | 21 | def _manage_scope(request: pytest.FixtureRequest) -> Iterator[None]: 22 | hub.request = request 23 | manager: Manager = getattr(request.session, SESSION_ATTR) 24 | scope = Scope(request.scope) 25 | manager.enter_scope(scope) 26 | if hub.autouse: 27 | for fixture in hub.autouse: 28 | if fixture.scope == scope: 29 | fixture() 30 | yield 31 | manager.exit_scope(scope) 32 | hub.request = None 33 | 34 | 35 | enter_function = pytest.fixture(scope='function', autouse=True)(_manage_scope) 36 | enter_class = pytest.fixture(scope='class', autouse=True)(_manage_scope) 37 | enter_module = pytest.fixture(scope='module', autouse=True)(_manage_scope) 38 | enter_package = pytest.fixture(scope='package', autouse=True)(_manage_scope) 39 | enter_session = pytest.fixture(scope='session', autouse=True)(_manage_scope) 40 | -------------------------------------------------------------------------------- /pytypest/_scope.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Scope(Enum): 5 | """Scope for which the fixture. 6 | 7 | The scope defines when the fixture cache will be reset 8 | and the teardown executed. 9 | """ 10 | 11 | FUNCTION = 'function' 12 | """ 13 | Default. Teardown is called at the end of the test function. 14 | """ 15 | 16 | CLASS = 'class' 17 | """ 18 | Teardown is called after the last test in a test class. 19 | """ 20 | 21 | MODULE = 'module' 22 | """ 23 | Teardown is called after the last test in a file. 24 | """ 25 | 26 | PACKAGE = 'package' 27 | """ 28 | Experimental. Teardown is called after the last test in a directory. 29 | """ 30 | 31 | SESSION = 'session' 32 | """ 33 | Teardown is called after the last tests overall in the current session. 34 | """ 35 | -------------------------------------------------------------------------------- /pytypest/_scope_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import deque 4 | from dataclasses import dataclass, field 5 | from typing import Callable 6 | 7 | from ._scope import Scope 8 | 9 | 10 | Finalizer = Callable[[], None] 11 | 12 | 13 | @dataclass 14 | class ScopeManager: 15 | scope: Scope 16 | _deferred: deque[Finalizer] = field(default_factory=deque) 17 | 18 | def defer(self, callback: Finalizer) -> None: 19 | self._deferred.append(callback) 20 | 21 | def enter_scope(self) -> None: 22 | assert not self._deferred 23 | 24 | def exit_scope(self) -> None: 25 | while self._deferred: 26 | callback = self._deferred.pop() 27 | callback() 28 | -------------------------------------------------------------------------------- /pytypest/experimental/__init__.py: -------------------------------------------------------------------------------- 1 | from ._attr import attr 2 | from ._patcher import patcher 3 | 4 | 5 | __all__ = ['attr', 'patcher'] 6 | -------------------------------------------------------------------------------- /pytypest/experimental/_attr.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any, Generic, TypeVar, overload 5 | 6 | from typing_extensions import ParamSpec 7 | 8 | 9 | if TYPE_CHECKING: 10 | from .._fixture import Fixture 11 | 12 | 13 | P = ParamSpec('P') 14 | R = TypeVar('R') 15 | 16 | 17 | def attr(fixture: Fixture[P, R], *args: P.args, **kwargs: P.kwargs) -> Attr[P, R]: 18 | """A wrapper to use a fixture as a container attribute. 19 | 20 | A fixture wrapped with ``attr`` can be accessed as a class attribute 21 | without explicitly calling it. It's equivalent to defining a ``@property`` 22 | that calls the fixture inside and returns its result but shorter. 23 | 24 | :: 25 | 26 | class Fixtures: 27 | user = attr(get_user) 28 | 29 | def test_user(): 30 | f = Fixtures() 31 | assert f.user.name == 'mark' 32 | 33 | """ 34 | return Attr(fixture, args, kwargs) 35 | 36 | 37 | @dataclass(frozen=True) 38 | class Attr(Generic[P, R]): 39 | fixture: Fixture[P, R] 40 | args: tuple 41 | kwargs: dict[str, Any] 42 | 43 | @overload 44 | def __get__(self, obj: None, objtype: type) -> Attr[P, R]: 45 | pass 46 | 47 | @overload 48 | def __get__(self, obj: object, objtype: type) -> R: 49 | pass 50 | 51 | def __get__(self, obj: object | None, objtype: type) -> Attr[P, R] | R: 52 | if obj is None: 53 | return self 54 | return self.fixture(*self.args, **self.kwargs) 55 | -------------------------------------------------------------------------------- /pytypest/experimental/_patcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Iterator, TypeVar 4 | 5 | import pytest 6 | 7 | from .._fixture_factory import fixture 8 | 9 | 10 | K = TypeVar('K') 11 | V = TypeVar('V') 12 | 13 | 14 | class AttrPatcher: 15 | def __init__( 16 | self, 17 | patcher: pytest.MonkeyPatch, 18 | target: object | str, 19 | ) -> None: 20 | self.__patcher = patcher 21 | self.__target = target 22 | 23 | def __setattr__(self, name: str, value: object) -> None: 24 | if name.startswith('_AttrPatcher__'): 25 | return super().__setattr__(name, value) 26 | if isinstance(self.__target, str): 27 | self.__patcher.setattr(f'{self.__target}.{name}', value) 28 | else: 29 | self.__patcher.setattr(self.__target, name, value) 30 | 31 | def __delattr__(self, name: str) -> None: 32 | self.__patcher.delattr(self.__target, name) 33 | 34 | 35 | @fixture 36 | def patcher(target: object | str) -> Iterator[Any]: 37 | """A fixture to patch and delete attributes of the given object. 38 | 39 | Patch an attribute:: 40 | 41 | patcher(logging).info = Mock() 42 | 43 | Delete an attribute:: 44 | 45 | del patcher(logging).info 46 | 47 | The object can be also specified as a full import path string:: 48 | 49 | patcher('logging').info = Mock() 50 | 51 | All changes to the object will be reverted when leaving the context. 52 | """ 53 | monkey_patcher = pytest.MonkeyPatch() 54 | yield AttrPatcher(monkey_patcher, target) 55 | monkey_patcher.undo() 56 | -------------------------------------------------------------------------------- /pytypest/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from ._misc import ( 4 | chdir, defer, enter_context, forbid_networking, get_project_root, 5 | preserve_mapping, 6 | ) 7 | from ._pytest import ( 8 | capture_logs, capture_std, delattr, get_pytest_fixture, get_request, 9 | make_temp_dir, monkeypatch, record_warnings, setattr, 10 | ) 11 | 12 | 13 | __all__ = [ 14 | 'capture_logs', 15 | 'capture_std', 16 | 'chdir', 17 | 'defer', 18 | 'delattr', 19 | 'enter_context', 20 | 'forbid_networking', 21 | 'get_project_root', 22 | 'get_pytest_fixture', 23 | 'get_request', 24 | 'make_temp_dir', 25 | 'monkeypatch', 26 | 'preserve_mapping', 27 | 'record_warnings', 28 | 'setattr', 29 | ] 30 | -------------------------------------------------------------------------------- /pytypest/fixtures/_helpers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from dataclasses import dataclass 4 | from typing import Callable 5 | 6 | import pytest 7 | 8 | 9 | @dataclass(frozen=True) 10 | class NetworkGuard: 11 | allowed: frozenset[tuple[str, int]] 12 | wrapped: Callable[..., list] 13 | 14 | def __call__( 15 | self, 16 | host: bytes | str | None, 17 | port: bytes | str | int | None, 18 | *args, 19 | **kwargs, 20 | ) -> list: 21 | if (host, port) not in self.allowed: 22 | msg = f'connection to {host}:{port} is not allowed' # type: ignore 23 | pytest.fail(msg) 24 | return self.wrapped(host, port, *args, **kwargs) 25 | -------------------------------------------------------------------------------- /pytypest/fixtures/_misc.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import socket 5 | import unittest.mock 6 | from pathlib import Path 7 | from typing import ( 8 | Callable, ContextManager, Iterator, MutableMapping, Sequence, TypeVar, 9 | ) 10 | 11 | from .._fixture_factory import fixture 12 | from .._hub import hub 13 | from ._helpers import NetworkGuard 14 | 15 | 16 | T = TypeVar('T') 17 | 18 | 19 | @fixture 20 | def defer(callback: Callable[[], object]) -> Iterator[None]: 21 | """Execute the given callback when leaving the test function. 22 | 23 | It's a nice way to clean up after a test function without 24 | creating a fixture or a context manager. 25 | 26 | Similar to :pytest:`pytest.FixtureRequest.addfinalizer`. 27 | 28 | :: 29 | 30 | stream = open('some-file.txt') 31 | defer(stream.close) 32 | 33 | """ 34 | yield 35 | callback() 36 | 37 | 38 | @fixture 39 | def enter_context(manager: ContextManager[T]) -> Iterator[T]: 40 | """ 41 | Enter the context manager, return its result, 42 | and exit the context when leaving the test function. 43 | 44 | It's a bit imilar to `contextlib.ExitStack` in a sense 45 | that it helps to keep code indentation low 46 | when entering multiple context managers. 47 | 48 | :: 49 | 50 | stream = enter_context(open('some_file')) 51 | 52 | """ 53 | with manager as value: 54 | yield value 55 | 56 | 57 | @fixture 58 | def forbid_networking( 59 | *, 60 | allowed: Sequence[tuple[str, int]] = (), 61 | ) -> Iterator[None]: 62 | """Forbid network connections during the test. 63 | 64 | This fixture is a good candidate for :func:`pytypest.autouse`. 65 | 66 | The `allowed` argument accepts a sequence of `(host, port)` pairs 67 | to which connections should still be allowed. 68 | 69 | :: 70 | 71 | forbid_networking(allowed=[('example.com', 443)]) 72 | 73 | """ 74 | guard = NetworkGuard( 75 | allowed=frozenset(allowed), 76 | wrapped=socket.getaddrinfo, 77 | ) 78 | socket.getaddrinfo = guard 79 | yield 80 | socket.getaddrinfo = guard.wrapped 81 | 82 | 83 | @fixture 84 | def chdir(path: Path | str) -> Iterator[None]: 85 | """Change the current working dir to the given path. 86 | 87 | Similar to :pytest:`pytest.MonkeyPatch.chdir`. 88 | 89 | :: 90 | 91 | chdir('/') 92 | 93 | """ 94 | old_path = Path.cwd() 95 | os.chdir(path) 96 | yield 97 | os.chdir(old_path) 98 | 99 | 100 | @fixture 101 | def preserve_mapping(target: MutableMapping) -> Iterator[None]: 102 | """Restore the current state of the mapping after leaving the test. 103 | 104 | After calling the fixture, you can safely modify the given mapping, 105 | and these changes will be reverted before the next test starts. 106 | 107 | It's not a deep copy, though. If you modify a list inside of the mapping, 108 | that modification will escape the test. 109 | 110 | :: 111 | 112 | import sys 113 | preserve_mapping(sys.modules) 114 | sys.modules['requests'] = Mock() 115 | 116 | """ 117 | with unittest.mock.patch.dict(target): 118 | yield 119 | 120 | 121 | def get_project_root() -> Path: 122 | """Get the path to the root directory of the project. 123 | 124 | :: 125 | 126 | root = get_project_root() 127 | assert (root / 'pyproject.toml').exists() 128 | 129 | https://docs.pytest.org/en/7.1.x/reference/customize.html#finding-the-rootdir 130 | """ 131 | if hub.request is None: 132 | raise RuntimeError('pytest plugin is not active') 133 | return hub.request.session.config.rootpath 134 | -------------------------------------------------------------------------------- /pytypest/fixtures/_pytest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pathlib import Path 4 | from typing import Any, Iterator 5 | 6 | import pytest 7 | 8 | from .._fixture_factory import fixture 9 | from .._hub import hub 10 | 11 | 12 | @fixture 13 | def get_request() -> pytest.FixtureRequest: 14 | """Get meta information about the currently running test. 15 | 16 | A wrapper around :pytest:`request` pytest fixture. 17 | 18 | :: 19 | 20 | request = get_request() 21 | verbosity = request.config.getoption("verbose") 22 | if verbosity > 0: 23 | ... 24 | 25 | """ 26 | if hub.request is None: 27 | raise RuntimeError('pytest plugin is not active') 28 | return hub.request 29 | 30 | 31 | @fixture 32 | def get_pytest_fixture(name: str) -> Any: 33 | """Get a pytest fixture by its name. 34 | 35 | A wrapper around :pytest:`pytest.FixtureRequest.getfixturevalue`. 36 | 37 | This is useful for using fixtures from third-party pytest plugins. 38 | All built-in pytest fixtures already have a convenient wrapper in pytypest. 39 | 40 | For example, get ``event_loop`` fixture from pytest-asyncio:: 41 | 42 | from asyncio import AbstractEventLoop 43 | loop: AbstractEventLoop = get_pytest_fixture('event_loop') 44 | 45 | """ 46 | request = get_request() 47 | return request.getfixturevalue(name) 48 | 49 | 50 | @fixture 51 | def capture_std(*, binary: bool = False, fd: bool = False) -> pytest.CaptureFixture: 52 | """Capture stdout and stderr. 53 | 54 | A wrapper around :pytest:`capsys`, :pytest:`capfd`, :pytest:`capsysbinary`, 55 | and :pytest:`capfdbinary` pytest fixtures. 56 | 57 | :: 58 | 59 | cap = capture_std() 60 | print('hello') 61 | captured = cap.readouterr() 62 | assert captured.out.rstrip() == 'hello' 63 | 64 | """ 65 | root = 'fd' if fd else 'sys' 66 | suffix = 'binary' if binary else '' 67 | return get_pytest_fixture(f'cap{root}{suffix}') 68 | 69 | 70 | @fixture 71 | def capture_logs() -> pytest.LogCaptureFixture: 72 | """Capture all log records. 73 | 74 | A wrapper around :pytest:`caplog` pytest fixture. 75 | 76 | :: 77 | 78 | import logging 79 | cap = capture_logs() 80 | logging.warning('oh hi mark') 81 | record = cap.records[-1] 82 | assert record.message == 'oh hi mark' 83 | 84 | """ 85 | return get_pytest_fixture('caplog') 86 | 87 | 88 | @fixture 89 | def record_warnings() -> pytest.WarningsRecorder: 90 | """Record all warnings (emitted using ``warnings`` module). 91 | 92 | A wrapper around :pytest:`recwarn` pytest fixture. 93 | 94 | :: 95 | 96 | import warnings 97 | rec = fixtures.record_warnings() 98 | warnings.warn('oh hi mark', UserWarning) 99 | w = rec.pop(UserWarning) 100 | assert str(w.message) == 'oh hi mark' 101 | 102 | """ 103 | return get_pytest_fixture('recwarn') 104 | 105 | 106 | @fixture 107 | def make_temp_dir(basename: str | None = None, numbered: bool = True) -> Path: 108 | """Create a temporary directory. 109 | 110 | A wrapper around :pytest:`tmp_path` and :pytest:`tmp_path_factory` 111 | pytest fixtures. 112 | 113 | Args: 114 | basename: if specified, the created directory will have this name. 115 | numbered: if True (default), ensure the directory is unique 116 | by adding a numbered suffix greater than any existing one. 117 | 118 | :: 119 | 120 | dir_path = fixtures.make_temp_dir() 121 | file_path = dir_path / 'example.py' 122 | file_path.write_text('1 + 2') 123 | ... 124 | content = file_path.read_text() 125 | assert content == '1 + 2' 126 | 127 | """ 128 | if basename is not None: 129 | factory: pytest.TempPathFactory = get_pytest_fixture('tmp_path_factory') 130 | return factory.mktemp(basename=basename, numbered=numbered) 131 | return get_pytest_fixture('tmp_path') 132 | 133 | 134 | @fixture 135 | def monkeypatch() -> Iterator[pytest.MonkeyPatch]: 136 | """Patch attributes of objects for the duration of test. 137 | 138 | A wrapper around :pytest:`monkeypatch` pytest fixture. 139 | 140 | Usually, you don't need to use this fixture directly. The preferred way to 141 | patch things is using :func:`pytypest.fixtures.setattr`, 142 | :func:`pytypest.fixtures.delattr`, and :func:`pytypest.fixtures.preserve_mapping`. 143 | """ 144 | patcher = pytest.MonkeyPatch() 145 | yield patcher 146 | patcher.undo() 147 | 148 | 149 | @fixture 150 | def setattr( 151 | target: object | str, 152 | name: str, 153 | value: object, 154 | *, 155 | must_exist: bool = True, 156 | ) -> Iterator[None]: 157 | """Patch an attribute for the duration of test. 158 | 159 | A wrapper around :pytest:`pytest.MonkeyPatch.setattr`. 160 | 161 | The target can be either the object to patch or the full import path to the object. 162 | The target can be any object, including modules, classes, methods, and functions. 163 | 164 | :: 165 | 166 | from unittest.mock import Mock 167 | mock = Mock() 168 | setattr('logging', 'info', mock) 169 | 170 | """ 171 | patcher = pytest.MonkeyPatch() 172 | if isinstance(target, str): 173 | patcher.setattr(f'{target}.{name}', value, raising=must_exist) 174 | else: 175 | patcher.setattr(target, name, value, raising=must_exist) 176 | yield 177 | patcher.undo() 178 | 179 | 180 | @fixture 181 | def delattr( 182 | target: object | str, 183 | name: str, 184 | *, 185 | must_exist: bool = True, 186 | ) -> Iterator[None]: 187 | """Delete attribute of an object for the duration of test. 188 | 189 | A wrapper around :pytest:`pytest.MonkeyPatch.delattr`. 190 | 191 | The target can be either the object to patch or the full import path to the object. 192 | The target can be any object, including modules, classes, methods, and functions. 193 | 194 | :: 195 | 196 | delattr(logging, 'info') 197 | 198 | """ 199 | patcher = pytest.MonkeyPatch() 200 | if isinstance(target, str): 201 | patcher.delattr(f'{target}.{name}', raising=must_exist) 202 | else: 203 | patcher.delattr(target, name, raising=must_exist) 204 | yield 205 | patcher.undo() 206 | -------------------------------------------------------------------------------- /pytypest/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/pytypest/bfb92199079cfe35b22e301a748a840b69854af8/pytypest/py.typed -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 90 3 | ignore = W503 4 | exclude = 5 | .venvs/ 6 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/orsinium-labs/pytypest/bfb92199079cfe35b22e301a748a840b69854af8/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import contextmanager 4 | from typing import Callable, Iterator 5 | 6 | import pytest 7 | 8 | from pytypest import _plugin 9 | from pytypest._hub import hub 10 | 11 | 12 | @pytest.fixture 13 | def isolated(request: pytest.FixtureRequest) -> Iterator[None]: 14 | _plugin.pytest_sessionstart(request.session) 15 | yield 16 | hub.reset() 17 | delattr(request.session, _plugin.SESSION_ATTR) 18 | 19 | 20 | @pytest.fixture 21 | def scoped(request: pytest.FixtureRequest) -> Iterator[Callable]: 22 | 23 | @contextmanager 24 | def wrapper(scope: str): 25 | from _pytest.scope import Scope 26 | 27 | old_scope = request._scope 28 | request._scope = Scope(scope) 29 | it = _plugin._manage_scope(request) 30 | next(it) 31 | try: 32 | yield 33 | finally: 34 | try: 35 | next(it) 36 | except StopIteration: 37 | pass 38 | request._scope = old_scope 39 | 40 | yield wrapper 41 | -------------------------------------------------------------------------------- /tests/test_autouse.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pytypest import Scope, autouse, fixture 4 | 5 | 6 | def test_double_autouse(isolated) -> None: 7 | @fixture 8 | def fixt(): 9 | yield 10 | 11 | autouse(fixt) 12 | msg = 'autouse can be called only once' 13 | with pytest.raises(RuntimeError, match=msg): 14 | autouse(fixt) 15 | 16 | 17 | def test_autouse(isolated, scoped) -> None: 18 | log = [] 19 | 20 | @fixture(scope=Scope.CLASS) 21 | def fixt(): 22 | log.append('s') 23 | yield 24 | log.append('t') 25 | 26 | autouse(fixt) 27 | assert log == [] 28 | with scoped('class'): 29 | assert log == ['s'] 30 | with scoped('function'): 31 | assert log == ['s'] 32 | assert log == ['s'] 33 | assert log == ['s', 't'] 34 | -------------------------------------------------------------------------------- /tests/test_experimental.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from pytypest import experimental, fixture 4 | 5 | 6 | class Global: 7 | attr: int = 42 8 | 9 | 10 | def test_setattr(isolated, scoped): 11 | class A: 12 | a = 13 13 | 14 | with scoped('function'): 15 | p = experimental.patcher(A) 16 | p.a = 54 17 | assert A.a == 54 18 | assert A.a == 13 19 | 20 | 21 | def test_setattr__str_target(isolated, scoped): 22 | target = f'{Global.__module__}.{Global.__name__}' 23 | with scoped('function'): 24 | p = experimental.patcher(target) 25 | p.attr = 99 26 | assert Global.attr == 99 27 | assert Global.attr == 42 28 | 29 | 30 | def test_delattr(isolated, scoped): 31 | class A: 32 | a = 13 33 | 34 | with scoped('function'): 35 | p = experimental.patcher(A) 36 | del p.a 37 | assert not hasattr(A, 'a') 38 | assert A.a == 13 39 | 40 | 41 | def test_attr(isolated, scoped): 42 | log = [] 43 | 44 | @fixture 45 | def fixt(): 46 | log.append('s') 47 | yield 14 48 | log.append('t') 49 | 50 | class Container: 51 | val = experimental.attr(fixt) 52 | 53 | c = Container() 54 | assert Container.val.fixture is fixt 55 | with scoped('function'): 56 | assert log == [] 57 | for _ in range(4): 58 | assert c.val == 14 59 | assert log == ['s'] 60 | assert log == ['s', 't'] 61 | -------------------------------------------------------------------------------- /tests/test_fixtrues.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import contextmanager 4 | from pathlib import Path 5 | 6 | import pytest 7 | import requests 8 | 9 | from pytypest import fixtures 10 | 11 | 12 | class Global: 13 | attr: int = 42 14 | 15 | 16 | def test_get_request(isolated, scoped) -> None: 17 | with scoped('function'): 18 | req = fixtures.get_request() 19 | assert req.function is test_get_request 20 | assert req.scope == 'function' 21 | 22 | 23 | def test_get_request__not_active() -> None: 24 | msg = 'pytest plugin is not active' 25 | with pytest.raises(RuntimeError, match=msg): 26 | fixtures.get_request() 27 | 28 | 29 | def test_make_temp_dir(isolated, scoped) -> None: 30 | with scoped('function'): 31 | path = fixtures.make_temp_dir() 32 | assert path.is_dir() 33 | 34 | 35 | def test_make_temp_dir__basename(isolated, scoped) -> None: 36 | with scoped('function'): 37 | path = fixtures.make_temp_dir('hello', numbered=False) 38 | assert path.is_dir() 39 | assert path.name == 'hello' 40 | 41 | 42 | def test_make_temp_dir__numbered(isolated, scoped) -> None: 43 | with scoped('function'): 44 | path = fixtures.make_temp_dir('hello', numbered=True) 45 | assert path.is_dir() 46 | assert path.name == 'hello0' 47 | 48 | 49 | def test_chdir(isolated, scoped) -> None: 50 | dir1 = Path.cwd() 51 | with scoped('function'): 52 | fixtures.chdir(dir1.parent) 53 | dir2 = Path.cwd() 54 | assert dir2 == dir1.parent 55 | 56 | 57 | def test_get_pytest_fixture(isolated, scoped, tmp_path) -> None: 58 | with scoped('function'): 59 | path = fixtures.get_pytest_fixture('tmp_path') 60 | assert path is tmp_path 61 | 62 | 63 | @pytest.mark.parametrize('given, expected', [ 64 | (fixtures.capture_std, 'capsys'), 65 | (lambda: fixtures.capture_std(binary=True), 'capsysbinary'), 66 | (lambda: fixtures.capture_std(fd=True), 'capfd'), 67 | (lambda: fixtures.capture_std(binary=True, fd=True), 'capfdbinary'), 68 | (fixtures.capture_logs, 'caplog'), 69 | (fixtures.record_warnings, 'recwarn'), 70 | ]) 71 | def test_proxying(isolated, scoped, given, expected, request) -> None: 72 | with scoped('function'): 73 | fixt1 = request.getfixturevalue(expected) 74 | fixt2 = fixtures.get_pytest_fixture(expected) 75 | fixt3 = given() 76 | assert fixt1 is fixt2 77 | assert fixt2 is fixt3 78 | 79 | 80 | def test_defer(isolated, scoped) -> None: 81 | log = [] 82 | with scoped('function'): 83 | fixtures.defer(lambda: log.append(1)) 84 | assert log == [] 85 | assert log == [1] 86 | 87 | 88 | def test_defer__no_scope(isolated, scoped) -> None: 89 | msg = 'cannot find ScopeManager for `function` scope' 90 | with pytest.raises(LookupError, match=msg): 91 | fixtures.defer(lambda: None) 92 | with scoped('class'): 93 | with pytest.raises(LookupError, match=msg): 94 | fixtures.defer(lambda: None) 95 | 96 | 97 | def test_enter_context(isolated, scoped) -> None: 98 | log = [] 99 | 100 | @contextmanager 101 | def man(): 102 | log.append('enter') 103 | yield 17 104 | log.append('exit') 105 | 106 | with scoped('function'): 107 | res = fixtures.enter_context(man()) 108 | assert log == ['enter'] 109 | assert res == 17 110 | assert log == ['enter', 'exit'] 111 | 112 | 113 | def test_forbid_networking__bad_host(isolated, scoped) -> None: 114 | with scoped('function'): 115 | fixtures.forbid_networking() 116 | msg = 'connection to example.com:443 is not allowed' 117 | with pytest.raises(BaseException, match=msg): 118 | requests.get('https://example.com/') 119 | 120 | 121 | def test_forbid_networking__bad_port(isolated, scoped) -> None: 122 | with scoped('function'): 123 | fixtures.forbid_networking(allowed=[('example.com', 80)]) 124 | msg = 'connection to example.com:443 is not allowed' 125 | with pytest.raises(BaseException, match=msg): 126 | requests.get('https://example.com/') 127 | 128 | 129 | def test_forbid_networking__allowed_host_port(isolated, scoped) -> None: 130 | with scoped('function'): 131 | fixtures.forbid_networking( 132 | allowed=[('example.com', 443)], 133 | ) 134 | requests.get('https://example.com/') 135 | 136 | 137 | def test_monkeypatch(isolated, scoped): 138 | class A: 139 | a = 13 140 | 141 | with scoped('function'): 142 | p = fixtures.monkeypatch() 143 | p.setattr(A, 'a', 54) 144 | assert A.a == 54 145 | 146 | 147 | def test_setattr(isolated, scoped): 148 | class A: 149 | a = 13 150 | 151 | with scoped('function'): 152 | fixtures.setattr(A, 'a', 54) 153 | assert A.a == 54 154 | assert A.a == 13 155 | 156 | 157 | def test_setattr__str_target(isolated, scoped): 158 | target = f'{Global.__module__}.{Global.__name__}' 159 | with scoped('function'): 160 | fixtures.setattr(target, 'attr', 99) 161 | assert Global.attr == 99 162 | assert Global.attr == 42 163 | 164 | 165 | def test_delattr(isolated, scoped): 166 | class A: 167 | a = 13 168 | 169 | with scoped('function'): 170 | fixtures.delattr(A, 'a') 171 | assert not hasattr(A, 'a') 172 | assert A.a == 13 173 | 174 | 175 | def test_delattr__str_target(isolated, scoped): 176 | target = f'{Global.__module__}.{Global.__name__}' 177 | with scoped('function'): 178 | fixtures.delattr(target, 'attr') 179 | assert not hasattr(Global, 'attr') 180 | assert Global.attr == 42 181 | 182 | 183 | def test_preserve_mapping(isolated, scoped): 184 | d = {1: 2, 3: 4, 5: 6} 185 | with scoped('function'): 186 | fixtures.preserve_mapping(d) 187 | d[1] = 7 188 | del d[5] 189 | assert d == {1: 7, 3: 4} 190 | assert d == {1: 2, 3: 4, 5: 6} 191 | 192 | 193 | def test_get_project_root(isolated, scoped): 194 | with scoped('function'): 195 | root = fixtures.get_project_root() 196 | assert (root / 'pyproject.toml').is_file() 197 | assert (root / 'pytypest').is_dir() 198 | 199 | 200 | def test_get_project_root__not_active() -> None: 201 | msg = 'pytest plugin is not active' 202 | with pytest.raises(RuntimeError, match=msg): 203 | fixtures.get_project_root() 204 | 205 | 206 | def test_capture_std(isolated, scoped): 207 | with scoped('function'): 208 | cap = fixtures.capture_std() 209 | print('hello') 210 | captured = cap.readouterr() 211 | assert captured.out == 'hello\n' 212 | 213 | 214 | def test_capture_logs(isolated, scoped): 215 | with scoped('function'): 216 | import logging 217 | cap = fixtures.capture_logs() 218 | logging.warning('oh hi mark') 219 | record = cap.records[-1] 220 | assert record.message == 'oh hi mark' 221 | 222 | 223 | def test_record_warnings(isolated, scoped): 224 | with scoped('function'): 225 | import warnings 226 | rec = fixtures.record_warnings() 227 | warnings.warn('oh hi mark', UserWarning, stacklevel=1) 228 | w = rec.pop(UserWarning) 229 | assert str(w.message) == 'oh hi mark' 230 | -------------------------------------------------------------------------------- /tests/test_fixture.py: -------------------------------------------------------------------------------- 1 | from typing import Callable 2 | 3 | import pytest 4 | 5 | from pytypest import Scope, fixture 6 | 7 | 8 | def test_setup_return() -> None: 9 | log = [] 10 | 11 | @fixture 12 | def fixt(): 13 | log.append(42) 14 | return 13 15 | 16 | assert fixt.setup() == 13 17 | assert log == [42] 18 | 19 | 20 | def test_setup_yield() -> None: 21 | log = [] 22 | 23 | @fixture 24 | def fixt(): 25 | log.append(42) 26 | yield 13 27 | 28 | assert fixt.setup() == 13 29 | assert log == [42] 30 | 31 | 32 | def test_teardown_return() -> None: 33 | @fixture 34 | def fixt(): 35 | return 13 36 | 37 | fixt.teardown() 38 | assert fixt.setup() == 13 39 | fixt.teardown() 40 | 41 | 42 | def test_teardown_yield() -> None: 43 | log = [] 44 | 45 | @fixture 46 | def fixt(): 47 | yield 13 48 | log.append(42) 49 | 50 | fixt.teardown() 51 | assert fixt.setup() == 13 52 | fixt.teardown() 53 | assert log == [42] 54 | 55 | 56 | def test_teardown_on_leaving_scope(isolated: None, scoped: Callable) -> None: 57 | log = [] 58 | 59 | @fixture(scope=Scope.CLASS) 60 | def fixt(): 61 | log.append('s') 62 | yield 62 63 | log.append('t') 64 | 65 | with scoped('class'): 66 | with scoped('function'): 67 | assert log == [] 68 | for _ in range(4): 69 | assert fixt() == 62 70 | assert log == ['s'] 71 | assert log == ['s'] 72 | 73 | assert log == ['s', 't'] 74 | 75 | 76 | def test_disallow_double_yield(isolated, scoped): 77 | @fixture 78 | def fixt(): 79 | yield 80 | yield 81 | 82 | msg = 'fixture must have at most one yield' 83 | with pytest.raises(RuntimeError, match=msg): 84 | with scoped('function'): 85 | fixt() 86 | 87 | 88 | def test_plugin_not_active(): 89 | @fixture 90 | def fixt(): 91 | yield 92 | 93 | msg = 'pytest plugin is not activated' 94 | with pytest.raises(RuntimeError, match=msg): 95 | fixt() 96 | 97 | 98 | def test_context_manager(isolated, scoped): 99 | log = [] 100 | 101 | @fixture 102 | def fixt(): 103 | log.append('s') 104 | yield 67 105 | log.append('t') 106 | 107 | with scoped('function'): 108 | assert log == [] 109 | with fixt as val: 110 | assert log == ['s'] 111 | assert val == 67 112 | assert log == ['s', 't'] 113 | assert log == ['s', 't'] 114 | -------------------------------------------------------------------------------- /tests/test_parametrize.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from pytypest import case, parametrize 8 | 9 | 10 | if TYPE_CHECKING: 11 | from _pytest.mark.structures import Mark 12 | 13 | 14 | def test_parametrize() -> None: 15 | def inner(a: int, b: int): 16 | pass 17 | wrapped = parametrize( 18 | inner, 19 | case(3, 4), 20 | case(5, b=6), 21 | case(a=7, b=8), 22 | case(b=10, a=9), 23 | case.id('one')(11, 12), 24 | case.tags('two', 'three')(13, 14), 25 | four=case(15, 16), 26 | ) 27 | mark: Mark 28 | (mark,) = wrapped.pytestmark # type: ignore[attr-defined] 29 | assert mark.name == 'parametrize' 30 | assert mark.args == ( 31 | ['a', 'b'], 32 | [ 33 | [3, 4], [5, 6], [7, 8], [9, 10], 34 | pytest.param(11, 12, id='one'), 35 | pytest.param(13, 14, marks=(pytest.mark.two, pytest.mark.three)), 36 | pytest.param(15, 16, id='four'), 37 | ], 38 | ) 39 | 40 | 41 | def test_preserve_marks() -> None: 42 | @pytest.mark.two 43 | def inner(a: int, b: int): 44 | pass 45 | wrapped = parametrize(inner, case(3, 4)) 46 | wrapped = pytest.mark.three(wrapped) 47 | marks = wrapped.pytestmark # type: ignore[attr-defined] 48 | assert len(marks) == 3 49 | --------------------------------------------------------------------------------