Skip to content

Commit 91ece9a

Browse files
committed
Add basic integration tests
Use pytest-docker to run docker-compose as a pytest fixture, bringing up Cadence, Cassandra, and statsd. If possible I'd like to switch to using SQLite in the future, but I need to explore that more. For the moment this is similar to what the Java and Go clients do. Unlike the Java and Go clients we orchestrate docker via the testing framework, rather than running the tests themselves via docker. This seems much easier to use and debug. With a bit of tinkering we should have a lot more flexibility to do things like running tests against an already running set of services to speed up iteration time. Additionally change the pytest `asyncio_mode` to `auto` so that async tests and fixtures are automatically run in an eventloop. One major issue with the current version of pytest-asyncio that we're using is its limited control over the eventloop used for running tests/fixtures. It's currently running each in their own eventloop, which means you can't pass certain objects between them, such as GRPC's async channel objects.
1 parent ced4570 commit 91ece9a

File tree

11 files changed

+193
-39
lines changed

11 files changed

+193
-39
lines changed

.github/workflows/ci_checks.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,24 @@ jobs:
7373
uv sync --extra dev
7474
- name: Run unit tests
7575
run: |
76-
uv run python -m pytest tests/ -v
76+
uv run pytest -v
77+
78+
integration_test:
79+
name: Integration Tests
80+
runs-on: ubuntu-latest
81+
steps:
82+
- uses: actions/checkout@v4
83+
with:
84+
submodules: true
85+
- name: Set up Python
86+
uses: actions/setup-python@v4
87+
with:
88+
python-version: "3.13"
89+
- name: Set up uv
90+
uses: astral-sh/setup-uv@v1
91+
- name: Install dependencies
92+
run: |
93+
uv sync --extra dev
94+
- name: Run unit tests
95+
run: |
96+
uv run pytest -v --integration-tests

cadence/_internal/rpc/yarpc.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ def _replace_details(self, client_call_details: ClientCallDetails) -> ClientCall
4444

4545
return _ClientCallDetails(
4646
method=client_call_details.method,
47-
timeout=client_call_details.timeout,
47+
# YARPC seems to require a TTL value
48+
timeout=client_call_details.timeout or 60.0,
4849
metadata=metadata,
4950
credentials=client_call_details.credentials,
5051
wait_for_ready=client_call_details.wait_for_ready,

cadence/client.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from cadence._internal.rpc.error import CadenceErrorInterceptor
88
from cadence._internal.rpc.yarpc import YarpcMetadataInterceptor
9+
from cadence.api.v1.service_domain_pb2_grpc import DomainAPIStub
910
from cadence.api.v1.service_worker_pb2_grpc import WorkerAPIStub
1011
from grpc.aio import Channel, ClientInterceptor, secure_channel, insecure_channel
1112
from cadence.data_converter import DataConverter, DefaultDataConverter
@@ -39,6 +40,7 @@ def __init__(self, **kwargs: Unpack[ClientOptions]) -> None:
3940
self._options = _validate_and_copy_defaults(ClientOptions(**kwargs))
4041
self._channel = _create_channel(self._options)
4142
self._worker_stub = WorkerAPIStub(self._channel)
43+
self._domain_stub = DomainAPIStub(self._channel)
4244

4345
@property
4446
def data_converter(self) -> DataConverter:
@@ -52,13 +54,26 @@ def domain(self) -> str:
5254
def identity(self) -> str:
5355
return self._options["identity"]
5456

57+
@property
58+
def domain_stub(self) -> DomainAPIStub:
59+
return self._domain_stub
60+
5561
@property
5662
def worker_stub(self) -> WorkerAPIStub:
5763
return self._worker_stub
5864

65+
async def ready(self) -> None:
66+
await self._channel.channel_ready()
67+
5968
async def close(self) -> None:
6069
await self._channel.close()
6170

71+
async def __aenter__(self) -> 'Client':
72+
return self
73+
74+
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
75+
await self.close()
76+
6277
def _validate_and_copy_defaults(options: ClientOptions) -> ClientOptions:
6378
if "target" not in options:
6479
raise ValueError("target must be specified")

cadence/sample/client_example.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@
66

77

88
async def main():
9-
client = Client(target="localhost:7833", domain="foo")
10-
worker = Worker(client, "task_list", Registry())
11-
await worker.run()
9+
async with Client(target="localhost:7833", domain="foo") as client:
10+
worker = Worker(client, "task_list", Registry())
11+
await worker.run()
1212

1313
if __name__ == '__main__':
1414
asyncio.run(main())

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dev = [
4444
"flake8>=6.0.0",
4545
"mypy>=1.0.0",
4646
"pre-commit>=3.0.0",
47+
"pytest-docker>=3.2.3",
4748
]
4849
docs = [
4950
"sphinx>=6.0.0",
@@ -138,14 +139,14 @@ ignore_missing_imports = true
138139

139140
[tool.pytest.ini_options]
140141
minversion = "7.0"
141-
addopts = "-ra -q --strict-markers --strict-config"
142+
addopts = "-ra -q --strict-markers --strict-config --import-mode=importlib"
143+
asyncio_mode = "auto"
142144
testpaths = ["tests"]
143145
python_files = ["test_*.py", "*_test.py"]
144146
python_classes = ["Test*"]
145147
python_functions = ["test_*"]
146148
markers = [
147149
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
148-
"integration: marks tests as integration tests",
149150
"unit: marks tests as unit tests",
150151
"asyncio: marks tests as async tests",
151152
]

tests/conftest.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
2+
3+
ENABLE_INTEGRATION_TESTS = "--integration_tests"
4+
5+
# Need to define the option in the root conftest.py file
6+
def pytest_addoption(parser):
7+
parser.addoption(ENABLE_INTEGRATION_TESTS, action="store_true",
8+
help="enables running integration tests, which rely on docker and docker-compose")
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import asyncio
2+
import os
3+
from datetime import timedelta
4+
5+
import pytest
6+
7+
from google.protobuf.duration import from_timedelta
8+
from pytest_docker import Services
9+
10+
from cadence.api.v1.service_domain_pb2 import RegisterDomainRequest
11+
from cadence.client import ClientOptions
12+
from tests.conftest import ENABLE_INTEGRATION_TESTS
13+
from tests.integration_tests.helper import CadenceHelper, DOMAIN_NAME
14+
15+
# Run tests in this directory and lower only if integration tests are enabled
16+
def pytest_runtest_setup(item):
17+
if not item.config.getoption(ENABLE_INTEGRATION_TESTS):
18+
pytest.skip(f"{ENABLE_INTEGRATION_TESTS} not enabled")
19+
20+
@pytest.fixture(scope="session")
21+
def docker_compose_file(pytestconfig):
22+
return os.path.join(str(pytestconfig.rootdir), "tests", "integration_tests", "docker-compose.yml")
23+
24+
@pytest.fixture(scope="session")
25+
def client_options(docker_ip: str, docker_services: Services) -> ClientOptions:
26+
return ClientOptions(
27+
domain=DOMAIN_NAME,
28+
target=f'{docker_ip}:{docker_services.port_for("cadence", 7833)}',
29+
)
30+
31+
# We can't pass around Client objects between tests/fixtures without changing our pytest-asyncio version
32+
# to ensure that they use the same event loop.
33+
# Instead, we can wait for the server to be ready, create the common domain, and then provide a helper capable
34+
# of creating additional clients within each test as needed
35+
@pytest.fixture(scope="session")
36+
async def helper(client_options: ClientOptions) -> CadenceHelper:
37+
helper = CadenceHelper(client_options)
38+
async with helper.client() as client:
39+
# It takes around a minute for the Cadence server to start up with Cassandra
40+
async with asyncio.timeout(120):
41+
await client.ready()
42+
43+
await client.domain_stub.RegisterDomain(RegisterDomainRequest(
44+
name=DOMAIN_NAME,
45+
workflow_execution_retention_period=from_timedelta(timedelta(days=1)),
46+
))
47+
return CadenceHelper(client_options)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
version: "3.5"
2+
3+
services:
4+
cassandra:
5+
image: cassandra:4.1.3
6+
ports:
7+
- "9042:9042"
8+
networks:
9+
services-network:
10+
aliases:
11+
- cassandra
12+
13+
statsd:
14+
image: hopsoft/graphite-statsd
15+
ports:
16+
- "8080:80"
17+
- "2003:2003"
18+
- "8125:8125"
19+
- "8126:8126"
20+
networks:
21+
services-network:
22+
aliases:
23+
- statsd
24+
25+
cadence:
26+
image: ubercadence/server:master-auto-setup
27+
ports:
28+
- "7933:7933"
29+
- "7833:7833"
30+
- "7934:7934"
31+
- "7935:7935"
32+
- "7939:7939"
33+
environment:
34+
- "CASSANDRA_SEEDS=cassandra"
35+
- "STATSD_ENDPOINT=statsd:8125"
36+
- "DYNAMIC_CONFIG_FILE_PATH=config/dynamicconfig/development.yaml"
37+
depends_on:
38+
- cassandra
39+
- statsd
40+
networks:
41+
services-network:
42+
aliases:
43+
- cadence
44+
networks:
45+
services-network:
46+
name: services-network
47+
driver: bridge

tests/integration_tests/helper.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from cadence.client import ClientOptions, Client
2+
3+
DOMAIN_NAME = "test-domain"
4+
5+
6+
class CadenceHelper:
7+
def __init__(self, options: ClientOptions):
8+
self.options = options
9+
10+
def client(self):
11+
return Client(**self.options)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import pytest
2+
3+
from cadence.api.v1.service_domain_pb2 import DescribeDomainRequest, DescribeDomainResponse
4+
from cadence.error import EntityNotExistsError
5+
from tests.integration_tests.helper import CadenceHelper, DOMAIN_NAME
6+
7+
8+
@pytest.mark.usefixtures("helper")
9+
async def test_domain_exists(helper: CadenceHelper):
10+
async with helper.client() as client:
11+
response: DescribeDomainResponse = await client.domain_stub.DescribeDomain(DescribeDomainRequest(name=DOMAIN_NAME))
12+
assert response.domain.name == DOMAIN_NAME
13+
14+
@pytest.mark.usefixtures("helper")
15+
async def test_domain_not_exists(helper: CadenceHelper):
16+
with pytest.raises(EntityNotExistsError):
17+
async with helper.client() as client:
18+
await client.domain_stub.DescribeDomain(DescribeDomainRequest(name="unknown-domain"))

0 commit comments

Comments
 (0)