Skip to content

Commit

Permalink
Initial version, forked from pytest-cases.
Browse files Browse the repository at this point in the history
  • Loading branch information
smarie committed Jul 27, 2018
1 parent 4846973 commit c4580bb
Show file tree
Hide file tree
Showing 10 changed files with 589 additions and 2 deletions.
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,13 @@ venv.bak/

# mypy
.mypy_cache/

# Pycharm
.idea/

# Mkdocs
site/

# travis CI
github_travis_rsa*
reports
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,74 @@
# python-pytest-steps
A tiny package to ease the creation of test steps with shared intermediate results/state
# pytest-steps

Create step-wise / incremental tests in `pytest`.

[![Build Status](https://travis-ci.org/smarie/python-pytest-steps.svg?branch=master)](https://travis-ci.org/smarie/python-pytest-steps) [![Tests Status](https://smarie.github.io/python-pytest-steps/junit/junit-badge.svg?dummy=8484744)](https://smarie.github.io/python-pytest-steps/junit/report.html) [![codecov](https://codecov.io/gh/smarie/python-pytest-steps/branch/master/graph/badge.svg)](https://codecov.io/gh/smarie/python-pytest-steps) [![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://smarie.github.io/python-pytest-steps/) [![PyPI](https://img.shields.io/badge/PyPI-pytest_steps-blue.svg)](https://pypi.python.org/pypi/pytest_steps/)

**This is the readme for developers.** The documentation for users is available here: [https://smarie.github.io/python-pytest-steps/](https://smarie.github.io/python-pytest-steps/)

## Want to contribute ?

Contributions are welcome ! Simply fork this project on github, commit your contributions, and create pull requests.

Here is a non-exhaustive list of interesting open topics: [https://github.com/smarie/python-pytest-steps/issues](https://github.com/smarie/python-pytest-steps/issues)

## Running the tests

This project uses `pytest`.

```bash
pytest -v pytest_steps/tests/
```

You may need to install requirements for setup beforehand, using

```bash
pip install -r ci_tools/requirements-test.txt
```

## Packaging

This project uses `setuptools_scm` to synchronise the version number. Therefore the following command should be used for development snapshots as well as official releases:

```bash
python setup.py egg_info bdist_wheel rotate -m.whl -k3
```

You may need to install requirements for setup beforehand, using

```bash
pip install -r ci_tools/requirements-setup.txt
```

## Generating the documentation page

This project uses `mkdocs` to generate its documentation page. Therefore building a local copy of the doc page may be done using:

```bash
mkdocs build
```

You may need to install requirements for doc beforehand, using

```bash
pip install -r ci_tools/requirements-doc.txt
```

## Generating the test reports

The following commands generate the html test report and the associated badge.

```bash
pytest --junitxml=junit.xml -v pytest_steps/tests/
ant -f ci_tools/generate-junit-html.xml
python ci_tools/generate-junit-badge.py
```

### PyPI Releasing memo

This project is now automatically deployed to PyPI when a tag is created. Anyway, for manual deployment we can use:

```bash
twine upload dist/* -r pypitest
twine upload dist/*
```
8 changes: 8 additions & 0 deletions pytest_steps/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from pytest_steps.steps import test_steps, ResultsHolder

__all__ = [
# the submodule
'steps',
# all symbols imported above
'test_steps', 'ResultsHolder'
]
150 changes: 150 additions & 0 deletions pytest_steps/steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
from functools import lru_cache
from inspect import signature, getmodule

import pytest


class ResultsHolder:
"""
An object that is passed along the various steps of your tests.
You can put intermediate results in here, and find them in the following steps.
Note: you can use `vars(results)` to see the available results.
"""
pass


def test_steps(*steps, test_step_argname: str='test_step', test_results_argname: str='results'):
"""
Decorates a test function so as to automatically parametrize it with all steps listed as arguments.
When the steps are functions, this is equivalent to
`@pytest.mark.parametrize(test_step_argname, steps, ids=lambda x: x.__name__)`
```python
from pytest_steps import test_steps
def step_a():
# perform this step
print("step a")
assert not False
def step_b():
# perform this step
print("step b")
assert not False
@test_steps(step_a, step_b)
def test_suite_no_results(test_step):
# Execute the step
test_step()
```
You can add a 'results' parameter to your test function if you wish to share a `ResultsHolder` object between your
steps.
```python
def step_a(results: ResultsHolder):
# perform this step
print("step a")
assert not False
# intermediate results can be stored in results
results.intermediate_a = 'some intermediate result created in step a'
def step_b(results: ResultsHolder):
# perform this step, leveraging the previous step's results
print("step b")
new_text = results.intermediate_a + " ... augmented"
print(new_text)
assert len(new_text) == 56
@test_steps(step_a, step_b)
def test_suite_with_results(test_step, results: ResultsHolder):
# Execute the step with access to the results holder
test_step(results)
```
:param steps: a list of test steps. They can be anything, but typically they are non-test (not prefixed with 'test')
functions.
:param test_step_argname: the optional name of the function argument that will receive the test step object.
Default is 'test_step'.
:param test_results_argname: the optional name of the function argument that will receive the shared `ResultsHolder`
object if present. Default is 'results'.
:return:
"""
def steps_decorator(test_func):
"""
The generated test function decorator.
It is equivalent to @mark.parametrize('case_data', cases) where cases is a tuple containing a CaseDataGetter for
all case generator functions
:param test_func:
:return:
"""
def get_id(f):
if callable(f) and hasattr(f, '__name__'):
return f.__name__
else:
return str(f)

step_ids = [get_id(f) for f in steps]

# Finally create the pytest decorator and apply it
# depending on the presence of test_results_argname in signature
s = signature(test_func)
if test_results_argname in s.parameters:
# the user wishes to share results across test steps. Create a cached fixture
@lru_cache(maxsize=None)
def get_results_holder(**kwargs):
"""
A factory for the ResultsHolder objects. Since it uses @lru_cache, the same ResultsHolder will be
returned when the keyword arguments are the same.
:param kwargs:
:return:
"""
return ResultsHolder() # TODO use Munch or MaxiMunch from `mixture` project, when publicly available ?

@pytest.fixture(name=test_results_argname)
def results(request):
"""
The fixture for the ResultsHolder. It implements an intelligent cache so that the same ResultsHolder
object is used across test steps.
:param request:
:return:
"""
# The object should be different everytime anything changes, except when the test step changes
dont_change_when_these_change = {test_step_argname}

# We also do not want the 'results' itself nor the pytest 'request' to be taken into account, since
# the first is not yet defined and the second is an internal pytest variable
dont_change_when_these_change.update({test_results_argname, 'request'})

# List the values of all the test function parameters that matter
kwargs = {argname: request.getfuncargvalue(argname)
for argname in request.funcargnames
if argname not in dont_change_when_these_change}

# Get or create the cached Result holder for this combination of parameters
return get_results_holder(**kwargs)

# Add the fixture dynamically: we have to add it to the function holder module as explained in
# https://github.com/pytest-dev/pytest/issues/2424
module = getmodule(test_func)
if test_results_argname not in dir(module):
setattr(module, test_results_argname, results)
else:
raise ValueError("The {} fixture already exists in module {}: please specify a different "
"`test_results_argname` in `@test_steps`".format(test_results_argname, module))

# Finally parametrize the function with the test steps
parametrizer = pytest.mark.parametrize(test_step_argname, steps, ids=step_ids)
return parametrizer(test_func)

return steps_decorator


test_steps.__test__ = False # to prevent pytest to think that this is a test !
Empty file added pytest_steps/tests/__init__.py
Empty file.
83 changes: 83 additions & 0 deletions pytest_steps/tests/test_pytest_capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
from functools import lru_cache
import pytest


# ------------------- The same code than what is generated dynamically by @test_steps
class ResultsHolder:
pass


@lru_cache(maxsize=None)
def get_results_holder(**kwargs):
"""
A factory for the ResultsHolder objects. Since it uses @lru_cache, the same ResultsHolder will be returned when
the keyword arguments are the same.
:param kwargs:
:return:
"""
return ResultsHolder()


@pytest.fixture
def results(request):
"""
The fixture for the ResultsHolder
:param request:
:return:
"""
kwargs = {argname: request.getfuncargvalue(argname)
for argname in request.funcargnames
if argname not in {'test_step', 'results', 'request'}}
return get_results_holder(**kwargs)
# -------------------------------------


def step_a(results: ResultsHolder, stupid_param):
""" Step a of the test """

# perform this step
print("step a - " + stupid_param)
assert not False

# Assert that the ResultsHolder object is a brand new one everytime we start this step
assert not hasattr(results, 'intermediate_a')

# intermediate results can be stored in results
results.intermediate_a = 'some intermediate result created in step a for test ' + stupid_param

assert not hasattr(results, 'p')
results.p = stupid_param


def step_b(results: ResultsHolder, stupid_param):
""" Step b of the test """

# perform this step
print("step b - " + stupid_param)

# assert that step a has been done
assert hasattr(results, 'intermediate_a')
# ... and that the resultsholder object that we get is the one for our test suite (same parameters)
assert results.intermediate_a == 'some intermediate result created in step a for test ' + stupid_param

new_text = results.intermediate_a + " ... augmented"
print(new_text)

assert results.p == stupid_param


@pytest.fixture(params=['F', 'G'])
def fix(request):
return request.param


@pytest.mark.parametrize('really_stupid_param2', ["2A", "2B"])
@pytest.mark.parametrize('test_step', [step_a, step_b])
@pytest.mark.parametrize('stupid_param1', ["1a", "1b"])
def test_manual_pytest_equivalent(test_step, stupid_param1, really_stupid_param2, fix, results: ResultsHolder):
"""This test performs the same thing than @test_steps but manually.
See the test_steps_with_results.py for details"""

# Execute the step
test_step(results, stupid_param1 + really_stupid_param2 + fix)
27 changes: 27 additions & 0 deletions pytest_steps/tests/test_steps_no_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
from pytest_steps import test_steps


def step_a():
""" Step a of the test """

# perform this step
print("step a")
assert not False


def step_b():
""" Step b of the test """

# perform this step
print("step b")
assert not False


# equivalent to
# @pytest.mark.parametrize('test_step', (step_check_a, step_check_b), ids=lambda x: x.__name__)
@test_steps(step_a, step_b)
def test_suite_no_results(test_step):
""" """

# Execute the step
test_step()
Loading

0 comments on commit c4580bb

Please sign in to comment.