Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docs(examples a->b->c): starting point of implementing Pact tests where there is a chain of services #276

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 29 additions & 0 deletions MANIFEST
@@ -0,0 +1,29 @@
# file GENERATED by distutils, do NOT edit
CHANGELOG.md
CONTRIBUTING.md
LICENSE
README.md
RELEASING.md
requirements_dev.txt
setup.cfg
setup.py
pact/__init__.py
pact/__version__.py
pact/broker.py
pact/constants.py
pact/consumer.py
pact/http_proxy.py
pact/matchers.py
pact/message_consumer.py
pact/message_pact.py
pact/message_provider.py
pact/pact.py
pact/provider.py
pact/verifier.py
pact/verify_wrapper.py
pact/bin/pact-1.88.77-linux-x86.tar.gz
pact/bin/pact-1.88.77-linux-x86_64.tar.gz
pact/bin/pact-1.88.77-osx.tar.gz
pact/bin/pact-1.88.77-win32.zip
pact/cli/__init__.py
pact/cli/verify.py
15 changes: 15 additions & 0 deletions examples/README.md
Expand Up @@ -9,6 +9,7 @@
* [fastapi_provider](#fastapi_provider)
* [message](#message)
* [pacts](#pacts)
* [abc](#abc)

## Overview

Expand Down Expand Up @@ -195,6 +196,20 @@ Both the Flask and the FastAPI [Provider] examples implement the same service th
This folder contains the generated [Pact file] for reference, which is also used when running the [Provider] tests
without a [Pact Broker].

## abc

In some situations, there may be a chain of dependent components e.g. `A -> B -> C`. Both Layers i.e. `A -> B` and
`B -> C` will need to be Pact tested separately, but the apparent "state" of `C` will need to be different for the
various tests of `A`.

To deal with this, one approach is to use the provider state of `B` to mock the response from `C` using the Pact "given".

In the examples here, we have the consumer A, `consumer_a_client` which sends all requests via B, `provider_b_hub`.
Sitting behind B are the various "internal" services, in this case `provider_c_products` and `provider_d_orders`.

When defining the Pact between A and B, we declare the "state" of C and D - how B should mock the behaviour.


[Pact Broker]: https://docs.pact.io/pact_broker
[Pact Introduction]: https://docs.pact.io/
[Consumer]: https://docs.pact.io/getting_started/terminology#service-consumer
Expand Down
49 changes: 49 additions & 0 deletions examples/abc/consumer_a_client/conftest.py
@@ -0,0 +1,49 @@
import pytest
from testcontainers.compose import DockerCompose


def pytest_addoption(parser):
parser.addoption(
"--publish-pact",
type=str,
action="store",
help="Upload generated Pact file to Pact Broker with the version provided",
)

parser.addoption("--run-broker", type=bool, action="store", help="Whether to run broker in this test or not")


# This fixture is to simulate a managed Pact Broker or Pactflow account.
# For almost all purposes outside this example, you will want to use a real
# broker. See https://github.com/pact-foundation/pact_broker for further details.
@pytest.fixture(scope="session", autouse=True)
def broker(request):
version = request.config.getoption("--publish-pact")
publish = True if version else False

# If the results are not going to be published to the broker, there is
# nothing further to do anyway
if not publish:
yield
return

run_broker = request.config.getoption("--run-broker")

if run_broker:
# Start up the broker using docker-compose
print("Starting broker")
with DockerCompose("../../broker", compose_file_name=["docker-compose.yml"], pull=True) as compose:
stdout, stderr = compose.get_logs()
if stderr:
print("Errors\\n:{}".format(stderr))
print("{}".format(stdout))
print("Started broker")

yield
print("Stopping broker")
print("Broker stopped")
else:
# Assuming there is a broker available already, docker-compose has been
# used manually as the --run-broker option has not been provided
yield
return
4 changes: 4 additions & 0 deletions examples/abc/consumer_a_client/run_pytest.sh
@@ -0,0 +1,4 @@
#!/bin/bash
set -o pipefail

pytest tests --run-broker True --publish-pact 1
75 changes: 75 additions & 0 deletions examples/abc/consumer_a_client/src/consumer_a.py
@@ -0,0 +1,75 @@
from typing import Optional, List

import requests
from pydantic import BaseModel


class Product(BaseModel):
id: int
title: str
author: Optional[str]
category: Optional[str]
isbn: Optional[str]
published: Optional[int]


class Account(BaseModel):
id: int
name: str
phone: Optional[str]


class Order(BaseModel):
id: int
ordered: str
shipped: Optional[str]
product_ids: List[int]


class HubConsumer(object):
def __init__(self, base_uri: str):
self.base_uri = base_uri

def get_account(self, account_id: int) -> Optional[Account]:
uri = f"{self.base_uri}/accounts/{account_id}"
response = requests.get(uri)
if response.status_code == 404:
return None

name = response.json()["name"]
phone = response.json()["phone"]

return Account(id=account_id, name=name, phone=phone)

def get_products(self) -> List[Product]:
uri = f"{self.base_uri}/products"
response = requests.get(uri)

products = [Product(id=j["id"], title=j["title"]) for j in response.json()]

return products

def get_product(self, product_id) -> Optional[Product]:
uri = f"{self.base_uri}/products/{product_id}"
response = requests.get(uri)

j = response.json()
product = Product(
id=j["id"],
title=j["title"],
author=j["author"],
category=j["category"],
isbn=j["isbn"],
published=j["published"],
)

return product

def get_order(self, order_id: int) -> Optional[Order]:
uri = f"{self.base_uri}/orders/{order_id}"
response = requests.get(uri)

j = response.json()
order = Order(id=j["id"], ordered=j["ordered"], shipped=j["shipped"], product_ids=j["product_ids"])

return order
137 changes: 137 additions & 0 deletions examples/abc/consumer_a_client/tests/consumer/test_consumer_a.py
@@ -0,0 +1,137 @@
"""pact test for hub service consumer"""

import atexit
import logging
import os

import pytest

from pact import Consumer, Like, Provider, EachLike
from src.consumer_a import HubConsumer

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)

PACT_BROKER_URL = "http://localhost"
PACT_BROKER_USERNAME = "pactbroker"
PACT_BROKER_PASSWORD = "pactbroker"

PACT_MOCK_HOST = "localhost"
PACT_MOCK_PORT = 1234

PACT_DIR = os.path.dirname(os.path.realpath(__file__))

EXAMPLE_BOOK = {
"id": 1,
"title": "The Last Continent",
"author": "Terry Pratchett",
"category": "Fantasy",
"isbn": "0385409893",
"published": "1998",
}

EXAMPLE_ORDER = {"id": 1, "ordered": "2021-11-01", "shipped": "2021-11-14", "product_ids": [1, 2]}


@pytest.fixture
def consumer() -> HubConsumer:
return HubConsumer("http://{host}:{port}".format(host=PACT_MOCK_HOST, port=PACT_MOCK_PORT))


@pytest.fixture(scope="session")
def pact(request):
"""Setup a Pact Consumer, which provides the Provider mock service. This
will generate and optionally publish Pacts to the Pact Broker"""

# When publishing a Pact to the Pact Broker, a version number of the Consumer
# is required, to be able to construct the compatability matrix between the
# Consumer versions and Provider versions
version = request.config.getoption("--publish-pact")
publish = True if version else False

pact = Consumer("ConsumerAClient", version=version).has_pact_with(
Provider("ProviderBHub"),
host_name=PACT_MOCK_HOST,
port=PACT_MOCK_PORT,
pact_dir=PACT_DIR,
publish_to_broker=publish,
broker_base_url=PACT_BROKER_URL,
broker_username=PACT_BROKER_USERNAME,
broker_password=PACT_BROKER_PASSWORD,
)

pact.start_service()

# Make sure the Pact mocked provider is stopped when we finish, otherwise
# port 1234 may become blocked
atexit.register(pact.stop_service)

yield pact

# This will stop the Pact mock server, and if publish is True, submit Pacts
# to the Pact Broker
pact.stop_service()

# Given we have cleanly stopped the service, we do not want to re-submit the
# Pacts to the Pact Broker again atexit, since the Broker may no longer be
# available if it has been started using the --run-broker option, as it will
# have been torn down at that point
pact.publish_to_broker = False


def test_get_product(pact, consumer):
(
pact.given("Some books exist")
.upon_receiving("a request for product 1")
.with_request("get", "/products/1")
.will_respond_with(200, body=Like(EXAMPLE_BOOK))
)

with pact:
product = consumer.get_product(1)
assert product.title == "The Last Continent"
pact.verify()


def test_get_products(pact, consumer):

(
pact.given("Some books exist")
.upon_receiving("a request for products")
.with_request("get", "/products")
.will_respond_with(200, body=EachLike(EXAMPLE_BOOK))
)

with pact:
products = consumer.get_products()
assert len(products) > 0
pact.verify()


def test_get_products_empty(pact, consumer):

(
pact.given("No books exist")
.upon_receiving("a request for products when none exist")
.with_request("get", "/products")
.will_respond_with(200, body=[])
)

with pact:
products = consumer.get_products()
assert len(products) == 0
pact.verify()


def test_get_order(pact, consumer):
(
pact.given("Some orders exist")
.upon_receiving("a request for order 1")
.with_request("get", "/orders/1")
.will_respond_with(200, body=Like(EXAMPLE_ORDER))
)

with pact:
order = consumer.get_order(1)
assert order.ordered == "2021-11-01"
pact.verify()