diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml index 9727c2a..478c152 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-to-pypi.yml @@ -3,8 +3,9 @@ on: push jobs: build-and-publish-test: - name: Build and publish python distribution to PyPI + name: Build and publish python distribution to test PyPI runs-on: ubuntu-18.04 + if: "!(startsWith(github.event.ref, 'refs/tags') || github.ref == 'refs/heads/master')" continue-on-error: true steps: - name: Checkout Code @@ -41,7 +42,7 @@ jobs: if [ "${INSTALLED:23}" != "$EXPECTED" ]; then exit 1; fi build-and-publish-production: - name: Build and publish python distribution to PyPI + name: Build and publish python distribution to production PyPI runs-on: ubuntu-18.04 if: startsWith(github.event.ref, 'refs/tags') steps: diff --git a/README.md b/README.md index 47bfd3b..3e00fc4 100644 --- a/README.md +++ b/README.md @@ -21,12 +21,152 @@ A pytest fixture wrapper for https://pypi.org/project/mock-generator pip install pytest-mock-generator ``` -or install with `Poetry` +or install with [poetry](https://github.com/python-poetry/poetry): ```bash poetry add pytest-mock-generator ``` +## Usage +This [pytest plugin](https://docs.pytest.org/en/latest/how-to/writing_plugins.html) +adds the `mg` [fixture](https://docs.pytest.org/en/latest/reference/fixtures.html#fixture) +which helps when writing tests that use [python mocks](https://docs.python.org/3.7/library/unittest.mock.html). + +Let's start with an easy example. Assume you have a module named `tested_module.py` which holds a function +to process a string sent to it and then add it to a zip file: +```python +import zipfile + +def process_and_zip(zip_path, file_name, contents): + processed_contents = "processed " + contents # some complex logic + with zipfile.ZipFile(zip_path, 'w') as zip_container: + zip_container.writestr(file_name, processed_contents) +``` +This is the unit under test, or UUT. + +Although this is a very short function, +writing the test code takes a lot of time. It's the fact that the function uses +a context manager makes the testing more complex than it should be. +*If you don't believe me, try to manually write mocks and asserts which verify +that `zip_container.writestr` was called with the right parameters.* + +In any case, you start with a test skeleton: + +```python +from tests.sample.code.tested_module import process_and_zip + +def test_process_and_zip(mocker, mg): + # Arrange: todo + + # Act: invoking the tested code + process_and_zip('/path/to.zip', 'in_zip.txt', 'foo bar') + + # Assert: todo +``` +Now it's time to use Mock Generator instead of manually writing the 'Arrange' +and 'Assert' sections. + +### Generating the 'Arrange' section +To generate the 'Arrange' section, simply put this code at the beginning of +your test function skeleton and run it (make sure to add the `mg` fixture to +your test function): +```python +mg.generate_uut_mocks(process_and_zip) +``` +This will generate the 'Arrange' section for you: +```python +# mocked dependencies +mock_ZipFile = mocker.MagicMock(name='ZipFile') +mocker.patch('tests.sample.code.tested_module.zipfile.ZipFile', new=mock_ZipFile) +``` +The generated code is returned, printed to the console and also copied to the +clipboard for your convenience. +Just paste it (as simple as ctrl+V) at the start of your test function: +```python +from tests.sample.code.tested_module import process_and_zip + +def test_process_and_zip(mocker, mg): + # mocked dependencies + mock_ZipFile = mocker.MagicMock(name='ZipFile') + mocker.patch('tests.sample.code.tested_module.zipfile.ZipFile', new=mock_ZipFile) + + # Act: invoking the tested code + process_and_zip('/path/to.zip', 'in_zip.txt', 'foo bar') + + # Assert: todo +``` + +Excellent! Arrange section is ready. + +### Generating the Assert section +Now it's time to add the asserts. Add the following code +**at the 'Assert'** step: +```python +mg.generate_asserts(mock_ZipFile) +``` +The `mock_ZipFile` is the mock object you generated earlier. +Now execute the test function to get the assert section: +```python +assert 1 == mock_ZipFile.call_count +mock_ZipFile.assert_called_once_with('/path/to.zip', 'w') +mock_ZipFile.return_value.__enter__.assert_called_once_with() +mock_ZipFile.return_value.__enter__.return_value.writestr.assert_called_once_with('in_zip.txt', 'processed foo bar') +mock_ZipFile.return_value.__exit__.assert_called_once_with(None, None, None) +``` +Wow, that's a handful of asserts! Some are very useful: +* Checking that we opened the zip file with the right parameters. +* Checking that we wrote the correct data to the proper file. +* Finally, ensuring that `__enter__` and `__exit__` are called, so there +are no open file handles which could cause problems. + +You can remove any generated line which you find unnecessary. + +Paste that code right after the act phase, and you're done! + +The complete test function: +```python +from tests.sample.code.tested_module import process_and_zip + +def test_process_and_zip(mocker): + # mocked dependencies + mock_ZipFile = mocker.MagicMock(name='ZipFile') + mocker.patch('tests.sample.code.tested_module.zipfile.ZipFile', new=mock_ZipFile) + + # Act: invoking the tested code + process_and_zip('/path/to.zip', 'in_zip.txt', 'foo bar') + + assert 1 == mock_ZipFile.call_count + mock_ZipFile.assert_called_once_with('/path/to.zip', 'w') + mock_ZipFile.return_value.__enter__.assert_called_once_with() + mock_ZipFile.return_value.__enter__.return_value.writestr.assert_called_once_with('in_zip.txt', 'processed foo bar') + mock_ZipFile.return_value.__exit__.assert_called_once_with(None, None, None) +``` +Can you imagine the time it would have taken you to code this on your own? + +### What's Next +#### Asserting Existing Mocks +At times, you may be editing a test code already containing mocks, or +you choose to write the mocks yourself, to gain some extra control. + +Mock Generator can generate the assert section for standard +Python mocks, even if they were not created using the Mock Generator. + +Put this after the 'Act' (replace `mock_obj` with your mock object name): +```python +mg.generate_asserts(mock_obj) +``` +Take the generated code and paste it at the 'Assert' section. + +#### Generating the 'Arrange' and 'Assert' sections in one call +You can make the `generate_uut_mocks_with_asserts` call create the +`generate_asserts` code for you (instead of having to call +`generate_uut_mocks`): +```python +mg.generate_uut_mocks_with_asserts(function_under_test) +``` + +## More information +Additional documentation can be found in the [mock-generator pypi](https://pypi.org/project/mock-generator). ## 📈 Releases diff --git a/pyproject.toml b/pyproject.toml index 563887c..4a456a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "pytest-mock-generator" -version = "0.3.0" +version = "1.0.0" description = "A pytest fixture wrapper for https://pypi.org/project/mock-generator" readme = "README.md" authors = ["Peter Kogan "] @@ -32,7 +32,7 @@ classifiers = [ "Topic :: Software Development :: Testing", 'Framework :: Pytest', "License :: OSI Approved :: MIT License", - "Development Status :: 3 - Alpha", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Operating System :: OS Independent", ]