Skip to content

Commit

Permalink
Significantly improved support for pytest provider verification of pacts
Browse files Browse the repository at this point in the history
Closes #23
  • Loading branch information
richard-reece committed Feb 5, 2019
1 parent a8a90d8 commit 8ff4c1f
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 13 deletions.
52 changes: 52 additions & 0 deletions README.md
Expand Up @@ -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=<URL>` provides the base URL of the Pact broker to retrieve pacts from for the
provider. You must also provide `--provider-name=<ProviderName>` to identify which provider to
retrieve pacts for from the broker.

`--pact-files=<file pattern>` 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=<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)

Expand All @@ -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

Expand Down
15 changes: 12 additions & 3 deletions pactman/verifier/broker_pact.py
Expand Up @@ -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):
Expand All @@ -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'<Pact consumer={self.consumer} provider={self.provider}>'

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
Expand Down
85 changes: 83 additions & 2 deletions 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)
34 changes: 30 additions & 4 deletions pactman/verifier/verify.py
Expand Up @@ -16,6 +16,10 @@ class ProviderStateError(Exception):
pass


class ProviderStateMissing(ProviderStateError):
pass


class Interaction:
def __init__(self, pact, interaction, result_factory):
self.pact = pact
Expand All @@ -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"<Interaction {self.pact.consumer}:{self.description}>"

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)
Expand Down
8 changes: 4 additions & 4 deletions setup.py
Expand Up @@ -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',
Expand Down

0 comments on commit 8ff4c1f

Please sign in to comment.