From 8ff4c1f0ffc1a7a4b5130c550977d128ebe25c76 Mon Sep 17 00:00:00 2001 From: Richard Jones Date: Tue, 5 Feb 2019 13:53:10 +1100 Subject: [PATCH] Significantly improved support for pytest provider verification of pacts Closes #23 --- README.md | 52 +++++++++++++++++++ pactman/verifier/broker_pact.py | 15 ++++-- pactman/verifier/pytest_plugin.py | 85 ++++++++++++++++++++++++++++++- pactman/verifier/verify.py | 34 +++++++++++-- setup.py | 8 +-- 5 files changed, 181 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0f3bf76..0cdbf56 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,57 @@ on the provider application or a separate one. Some strategies for managing stat For more information about provider states, refer to the [Pact documentation] on [Provider States]. +## Verifying Pacts Using pytest + +Rather than run the separate `pactman-verifier` command, you may opt to use pytest to run your provider +pact verification. To verify pacts for a provider you would write a new pytest test module, typically +called `verify_pacts.py` (so that it is not picked up by pytest during normal test execution) which +for a Django project contains: + +```python +def provider_state(name, **params): + if name == 'the user "pat" exists': + User.objects.create(username='pat', fullname=params['fullname']) + else: + raise ProviderStateMissing(name) + +def test_pacts(live_server, pact_verifier): + pact_verifier.verify(live_server.url, provider_state) +``` + +The test function may do any level of mocking and data setup using standard pytest fixtures - so mocking +downstream APIs or other interactions within the provider may be done with standard monkeypatching. +The `provider_state` function will be passed the `providerState` and `providerStates` for all pacts being +verified. The `providerStates` arrays look like this in the pact: + +For **providerState** the `name` argument will be the `providerState` value, and `params` will be empty. + +For **providerStates** the function will be invoked once per entry in `providerStates` array with the `name` +argument taken from the array entry `name` parameter, and `params` from the `params` parameter. + +Once you have written the pytest code, you need to invoke pytest with additional arguments: + +`--pact-broker-url=` provides the base URL of the Pact broker to retrieve pacts from for the +provider. You must also provide `--provider-name=` to identify which provider to +retrieve pacts for from the broker. + +`--pact-files=` verifies the on-disk pact JSON files identified by the wildcard pattern +(unix glob pattern matching). + +If you pulled the pacts from a broker and wish to publish verification results, use `--publish-results` +to turn on publishing the results. This option also requires you to specify `--provider-version=`. + +So, for example: + +```bash +# verify some local pacts in /tmp/pacts +$ pytest --pact-files=/tmp/pacts/*.json tests/verify_pacts.py + +# verify some pacts in a broker for the provider MyService +$ pytest --pact-broker-url=http://pact-broker.example/ --provider-name=MyService tests/verify_pacts.py +``` + + # Development Please read [CONTRIBUTING.md](CONTRIBUTING.md) @@ -462,6 +513,7 @@ From there you can use pip to install it: - Add `Equals` and `Includes` matchers for pact v3+ - Make verification fail if missing header specified in interaction +- Significantly improved support for pytest provider verification of pacts 2.11.0 diff --git a/pactman/verifier/broker_pact.py b/pactman/verifier/broker_pact.py index 54e2d29..02c6451 100644 --- a/pactman/verifier/broker_pact.py +++ b/pactman/verifier/broker_pact.py @@ -52,6 +52,9 @@ def all_interactions(self): for pact in self.consumers(): yield from pact.interactions + def __iter__(self): + return self.all_interactions() + class BrokerPact: def __init__(self, pact, result_factory, broker_pact=None): @@ -68,14 +71,20 @@ def __init__(self, pact, result_factory, broker_pact=None): self.interactions = [Interaction(self, interaction, result_factory) for interaction in pact['interactions']] self.broker_pact = broker_pact - def __str__(self): + def __repr__(self): return f'' + def __str__(self): + return f'Pact between consumer {self.consumer} and provider {self.provider}' + + @property + def success(self): + return all(interaction.result.success for interaction in self.interactions) + def publish_result(self, version): if self.broker_pact is None: return - success = all(interaction.result.success for interaction in self.interactions) - self.broker_pact['publish-verification-results'].create(dict(success=success, + self.broker_pact['publish-verification-results'].create(dict(success=self.success, providerApplicationVersion=version)) @classmethod diff --git a/pactman/verifier/pytest_plugin.py b/pactman/verifier/pytest_plugin.py index a221efa..b8923a7 100644 --- a/pactman/verifier/pytest_plugin.py +++ b/pactman/verifier/pytest_plugin.py @@ -1,7 +1,88 @@ -from . import broker_pact +import glob +import os + +import pytest + +from .broker_pact import BrokerPact, BrokerPacts +from .result import PytestResult + + +def pytest_addoption(parser): + parser.addoption("--pact-files", default=None, + help="pact JSON files to verify (wildcards allowed)") + parser.addoption("--pact-broker-url", default='', + help="pact broker URL") + parser.addoption("--provider-name", default=None, + help="provider pact name") + parser.addoption("--publish-results", action="store_true", default=False, + help="report pact results to pact broker") + parser.addoption("--provider-version", default=None, + help="provider version to use when reporting pact results to pact broker") + + +def get_broker_url(config): + if config.getoption('pact_broker_url'): + return config.getoption('pact_broker_url') + if os.environ.get('PACT_BROKER_URL'): + return os.environ.get('PACT_BROKER_URL') + return None # add the pact broker URL to the pytest output if running verbose def pytest_report_header(config): if config.getoption('verbose') > 0: - return [f'Loading pacts from {broker_pact.PACT_BROKER_URL}'] + location = get_broker_url(config) or config.getoption('pact_files') + return [f'Loading pacts from {location}'] + + +class PytestPactVerifier: + def __init__(self, publish_results, provider_version, interaction_or_pact): + self.publish_results = publish_results + self.provider_version = provider_version + self.interaction_or_pact = interaction_or_pact + + def verify(self, provider_url, provider_setup): + if isinstance(self.interaction_or_pact, BrokerPact): + if self.publish_results and self.provider_version: + self.interaction_or_pact.publish_result(self.provider_version) + assert self.interaction_or_pact.success, f'Verification of {self.interaction_or_pact} failed' + else: + self.interaction_or_pact.verify_with_callable_setup(provider_url, provider_setup) + + +def flatten_pacts(pacts, with_consumer=True): + for consumer in pacts: + yield from consumer.interactions + if with_consumer: + yield consumer + + +def get_pact_files(file_location): + if not file_location: + return [] + for filename in glob.glob(file_location): + yield BrokerPact.load_file(filename, result_factory=PytestResult) + + +def pytest_generate_tests(metafunc): + if 'pact_verifier' in metafunc.fixturenames: + broker_url = get_broker_url(metafunc.config) + if not broker_url: + pact_files = get_pact_files(metafunc.config.getoption('pact_files')) + if not pact_files: + raise ValueError('need a --pact-broker-url or --pact-files option') + metafunc.parametrize("pact_verifier", flatten_pacts(pact_files, with_consumer=False), ids=str, + indirect=True) + else: + provider_name = metafunc.config.getoption('provider_name') + if not provider_name: + raise ValueError('--pact-broker-url requires the --provider-name option') + broker_pacts = BrokerPacts(provider_name, pact_broker_url=broker_url, result_factory=PytestResult) + metafunc.parametrize("pact_verifier", flatten_pacts(broker_pacts.consumers()), + ids=str, indirect=True) + + +@pytest.fixture() +def pact_verifier(pytestconfig, request): + return PytestPactVerifier(pytestconfig.getoption('publish_results'), pytestconfig.getoption('provider_version'), + request.param) diff --git a/pactman/verifier/verify.py b/pactman/verifier/verify.py index e4a5a47..a7f4a9f 100644 --- a/pactman/verifier/verify.py +++ b/pactman/verifier/verify.py @@ -16,6 +16,10 @@ class ProviderStateError(Exception): pass +class ProviderStateMissing(ProviderStateError): + pass + + class Interaction: def __init__(self, pact, interaction, result_factory): self.pact = pact @@ -27,18 +31,40 @@ def __init__(self, pact, interaction, result_factory): self.response = ResponseVerifier(pact, interaction['response'], self.result) def __repr__(self): - return f"{self.pact.consumer}:{self.description}" + return f"" + + def __str__(self): + return f"{self.pact.consumer} verifying '{self.description}'" def verify(self, service_url, setup_url): self.result.start(self) try: self.result.success = self.setup(setup_url) - if not self.result.success: - return False - self.result.success = self.run_service(service_url) + if self.result.success: + self.result.success = self.run_service(service_url) + finally: + self.result.end() + + def verify_with_callable_setup(self, service_url, provider_setup): + self.result.start(self) + try: + try: + self.setup_state(provider_setup) + except Exception as e: + self.result.fail(f'Unable to configure provider state: {e}') + else: + self.result.success = self.run_service(service_url) finally: self.result.end() + def setup_state(self, provider_setup): + if self.providerState is not None: + return provider_setup(self.providerState) + for state in self.providerStates: + if 'name' not in state: + raise ValueError(f'provider state missing "name": {state}') + provider_setup(state['name'], **state.get('params', {})) + def run_service(self, service_url): method = self.request['method'].upper() handler = getattr(self, f'service_{method}', None) diff --git a/setup.py b/setup.py index 759f236..1d84818 100644 --- a/setup.py +++ b/setup.py @@ -25,10 +25,10 @@ def read(filename): author='ReeceTech', author_email='richard.jones@reece.com.au', url='https://github.com/reecetech/pactman', - entry_points=''' - [console_scripts] - pactman-verifier=pactman.verifier.command_line:main - ''', + entry_points={ + 'pytest11': ['pactman-verifier=pactman.verifier.pytest_plugin'], + 'console_scripts': ['pactman-verifier=pactman.verifier.command_line:main'], + }, install_requires=[ 'pytest', 'requests',