Skip to content

Commit d936a47

Browse files
authored
test: Add Testcontainers and Gherkin execution for our test-harness (#101)
* feat: Add Testcontainers and Gherkin execution for our test-harness Signed-off-by: Simon Schrottner <[email protected]> * fixup: make tests run, only 4 tests are missing now Signed-off-by: Simon Schrottner <[email protected]> * fixup: adding gherkin tests for in-process via file Signed-off-by: Simon Schrottner <[email protected]> * fixup: deactivating the tests Signed-off-by: Simon Schrottner <[email protected]> * fixup: renaming FlagD to Flagd Signed-off-by: Simon Schrottner <[email protected]> --------- Signed-off-by: Simon Schrottner <[email protected]>
1 parent a17c0e7 commit d936a47

17 files changed

+770
-403
lines changed

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@
44
[submodule "providers/openfeature-provider-flagd/test-harness"]
55
path = providers/openfeature-provider-flagd/test-harness
66
url = [email protected]:open-feature/flagd-testbed.git
7+
[submodule "providers/openfeature-provider-flagd/spec"]
8+
path = providers/openfeature-provider-flagd/spec
9+
url = https://github.com/open-feature/spec

providers/openfeature-provider-flagd/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ dependencies = [
3737
"coverage[toml]>=6.5",
3838
"pytest",
3939
"pytest-bdd",
40+
"testcontainers",
41+
"asserts",
42+
"grpcio-health-checking==1.60.0",
4043
]
4144
post-install-commands = [
4245
"./scripts/gen_protos.sh"
Submodule spec added at 3c737a6
Lines changed: 17 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -1,208 +1,33 @@
11
import typing
22

33
import pytest
4-
from pytest_bdd import given, parsers, then, when
5-
from tests.e2e.parsers import to_bool
4+
from testcontainers.core.container import DockerContainer
5+
from tests.e2e.flagd_container import FlagdContainer
6+
from tests.e2e.steps import * # noqa: F403
67

78
from openfeature import api
8-
from openfeature.client import OpenFeatureClient
99
from openfeature.contrib.provider.flagd import FlagdProvider
10-
from openfeature.contrib.provider.flagd.config import ResolverType
11-
from openfeature.evaluation_context import EvaluationContext
1210

1311
JsonPrimitive = typing.Union[str, bool, float, int]
1412

1513

16-
@pytest.fixture
17-
def evaluation_context() -> EvaluationContext:
18-
return EvaluationContext()
19-
20-
21-
@given("a flagd provider is set", target_fixture="client")
22-
def setup_provider(flag_file) -> OpenFeatureClient:
14+
@pytest.fixture(autouse=True, scope="package")
15+
def setup(request, port, image, resolver_type):
16+
container: DockerContainer = FlagdContainer(
17+
image=image,
18+
port=port,
19+
)
20+
# Setup code
21+
c = container.start()
2322
api.set_provider(
2423
FlagdProvider(
25-
resolver_type=ResolverType.IN_PROCESS,
26-
offline_flag_source_path=flag_file,
27-
offline_poll_interval_seconds=0.1,
24+
resolver_type=resolver_type,
25+
port=int(container.get_exposed_port(port)),
2826
)
2927
)
30-
return api.get_client()
31-
32-
33-
@when(
34-
parsers.cfparse(
35-
'a zero-value boolean flag with key "{key}" is evaluated with default value "{default:bool}"',
36-
extra_types={"bool": to_bool},
37-
),
38-
target_fixture="key_and_default",
39-
)
40-
@when(
41-
parsers.cfparse(
42-
'a zero-value string flag with key "{key}" is evaluated with default value "{default}"',
43-
),
44-
target_fixture="key_and_default",
45-
)
46-
@when(
47-
parsers.cfparse(
48-
'a string flag with key "{key}" is evaluated with default value "{default}"'
49-
),
50-
target_fixture="key_and_default",
51-
)
52-
@when(
53-
parsers.cfparse(
54-
'a zero-value integer flag with key "{key}" is evaluated with default value {default:d}',
55-
),
56-
target_fixture="key_and_default",
57-
)
58-
@when(
59-
parsers.cfparse(
60-
'an integer flag with key "{key}" is evaluated with default value {default:d}',
61-
),
62-
target_fixture="key_and_default",
63-
)
64-
@when(
65-
parsers.cfparse(
66-
'a zero-value float flag with key "{key}" is evaluated with default value {default:f}',
67-
),
68-
target_fixture="key_and_default",
69-
)
70-
def setup_key_and_default(
71-
key: str, default: JsonPrimitive
72-
) -> typing.Tuple[str, JsonPrimitive]:
73-
return (key, default)
74-
75-
76-
@when(
77-
parsers.cfparse(
78-
'a context containing a targeting key with value "{targeting_key}"'
79-
),
80-
)
81-
def assign_targeting_context(evaluation_context: EvaluationContext, targeting_key: str):
82-
"""a context containing a targeting key with value <targeting key>."""
83-
evaluation_context.targeting_key = targeting_key
84-
85-
86-
@when(
87-
parsers.cfparse('a context containing a key "{key}", with value "{value}"'),
88-
)
89-
@when(
90-
parsers.cfparse('a context containing a key "{key}", with value {value:d}'),
91-
)
92-
def update_context(
93-
evaluation_context: EvaluationContext, key: str, value: JsonPrimitive
94-
):
95-
"""a context containing a key and value."""
96-
evaluation_context.attributes[key] = value
97-
98-
99-
@when(
100-
parsers.cfparse(
101-
'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value "{value}"'
102-
),
103-
)
104-
@when(
105-
parsers.cfparse(
106-
'a context containing a nested property with outer key "{outer}" and inner key "{inner}", with value {value:d}'
107-
),
108-
)
109-
def update_context_nested(
110-
evaluation_context: EvaluationContext,
111-
outer: str,
112-
inner: str,
113-
value: typing.Union[str, int],
114-
):
115-
"""a context containing a nested property with outer key, and inner key, and value."""
116-
if outer not in evaluation_context.attributes:
117-
evaluation_context.attributes[outer] = {}
118-
evaluation_context.attributes[outer][inner] = value
119-
120-
121-
@then(
122-
parsers.cfparse(
123-
'the resolved boolean zero-value should be "{expected_value:bool}"',
124-
extra_types={"bool": to_bool},
125-
)
126-
)
127-
def assert_boolean_value(
128-
client: OpenFeatureClient,
129-
key_and_default: tuple,
130-
expected_value: bool,
131-
evaluation_context: EvaluationContext,
132-
):
133-
key, default = key_and_default
134-
evaluation_result = client.get_boolean_value(key, default, evaluation_context)
135-
assert evaluation_result == expected_value
136-
137-
138-
@then(
139-
parsers.cfparse(
140-
"the resolved integer zero-value should be {expected_value:d}",
141-
)
142-
)
143-
@then(parsers.cfparse("the returned value should be {expected_value:d}"))
144-
def assert_integer_value(
145-
client: OpenFeatureClient,
146-
key_and_default: tuple,
147-
expected_value: bool,
148-
evaluation_context: EvaluationContext,
149-
):
150-
key, default = key_and_default
151-
evaluation_result = client.get_integer_value(key, default, evaluation_context)
152-
assert evaluation_result == expected_value
153-
154-
155-
@then(
156-
parsers.cfparse(
157-
"the resolved float zero-value should be {expected_value:f}",
158-
)
159-
)
160-
def assert_float_value(
161-
client: OpenFeatureClient,
162-
key_and_default: tuple,
163-
expected_value: bool,
164-
evaluation_context: EvaluationContext,
165-
):
166-
key, default = key_and_default
167-
evaluation_result = client.get_float_value(key, default, evaluation_context)
168-
assert evaluation_result == expected_value
169-
170-
171-
@then(parsers.cfparse('the returned value should be "{expected_value}"'))
172-
def assert_string_value(
173-
client: OpenFeatureClient,
174-
key_and_default: tuple,
175-
expected_value: bool,
176-
evaluation_context: EvaluationContext,
177-
):
178-
key, default = key_and_default
179-
evaluation_result = client.get_string_value(key, default, evaluation_context)
180-
assert evaluation_result == expected_value
181-
182-
183-
@then(
184-
parsers.cfparse(
185-
'the resolved string zero-value should be ""',
186-
)
187-
)
188-
def assert_empty_string(
189-
client: OpenFeatureClient,
190-
key_and_default: tuple,
191-
evaluation_context: EvaluationContext,
192-
):
193-
key, default = key_and_default
194-
evaluation_result = client.get_string_value(key, default, evaluation_context)
195-
assert evaluation_result == ""
19628

29+
def fin():
30+
c.stop()
19731

198-
@then(parsers.cfparse('the returned reason should be "{reason}"'))
199-
def assert_reason(
200-
client: OpenFeatureClient,
201-
key_and_default: tuple,
202-
evaluation_context: EvaluationContext,
203-
reason: str,
204-
):
205-
"""the returned reason should be <reason>."""
206-
key, default = key_and_default
207-
evaluation_result = client.get_string_details(key, default, evaluation_context)
208-
assert evaluation_result.reason.value == reason
32+
# Teardown code
33+
request.addfinalizer(fin)
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import time
2+
3+
import grpc
4+
from grpc_health.v1 import health_pb2, health_pb2_grpc
5+
from testcontainers.core.container import DockerContainer
6+
from testcontainers.core.waiting_utils import wait_container_is_ready, wait_for_logs
7+
8+
HEALTH_CHECK = 8014
9+
10+
11+
class FlagdContainer(DockerContainer):
12+
def __init__(
13+
self,
14+
image: str = "ghcr.io/open-feature/flagd-testbed:v0.5.13",
15+
port: int = 8013,
16+
**kwargs,
17+
) -> None:
18+
super().__init__(image, **kwargs)
19+
self.port = port
20+
self.with_exposed_ports(self.port, HEALTH_CHECK)
21+
22+
def start(self) -> "FlagdContainer":
23+
super().start()
24+
self._checker(self.get_container_host_ip(), self.get_exposed_port(HEALTH_CHECK))
25+
return self
26+
27+
@wait_container_is_ready(ConnectionError)
28+
def _checker(self, host: str, port: str) -> None:
29+
# First we wait for Flagd to say it's listening
30+
wait_for_logs(
31+
self,
32+
"listening",
33+
5,
34+
)
35+
36+
time.sleep(1)
37+
# Second we use the GRPC health check endpoint
38+
with grpc.insecure_channel(host + ":" + port) as channel:
39+
health_stub = health_pb2_grpc.HealthStub(channel)
40+
41+
def health_check_call(stub: health_pb2_grpc.HealthStub):
42+
request = health_pb2.HealthCheckRequest()
43+
resp = stub.Check(request)
44+
if resp.status == health_pb2.HealthCheckResponse.SERVING:
45+
return True
46+
elif resp.status == health_pb2.HealthCheckResponse.NOT_SERVING:
47+
return False
48+
49+
# Should succeed
50+
# Check health status every 1 second for 30 seconds
51+
ok = False
52+
for _ in range(30):
53+
ok = health_check_call(health_stub)
54+
if ok:
55+
break
56+
time.sleep(1)
57+
58+
if not ok:
59+
raise ConnectionError("flagD not ready in time")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
def to_bool(s: str) -> bool:
22
return s.lower() == "true"
3+
4+
5+
def to_list(s: str) -> list:
6+
values = s.replace('"', "").split(",")
7+
return [s.strip() for s in values]

0 commit comments

Comments
 (0)