Skip to content

Commit 1b4d79b

Browse files
committed
tests
1 parent b5e13c1 commit 1b4d79b

File tree

10 files changed

+714
-13
lines changed

10 files changed

+714
-13
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ This repository ports the latest Featurevisor JavaScript SDK to Python and inclu
44

55
The package name is `featurevisor`, and it targets Python 3.10+.
66

7+
<!-- FEATUREVISOR_DOCS_BEGIN -->
8+
79
## Installation
810

911
```bash
@@ -317,6 +319,8 @@ featurevisor assess-distribution \
317319
--populateUuid=deviceId
318320
```
319321

322+
<!-- FEATUREVISOR_DOCS_BEGIN -->
323+
320324
## Development
321325

322326
This repository assumes:

src/featurevisor/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@
99
from .hooks import HooksManager
1010
from .instance import FeaturevisorInstance, create_instance
1111
from .logger import Logger, create_logger, default_log_handler, loggerPrefix
12+
from .murmurhash import murmurhash_v3
13+
14+
createInstance = create_instance
15+
createLogger = create_logger
16+
getBucketKey = get_bucket_key
17+
getBucketedNumber = get_bucketed_number
18+
getValueByType = get_value_by_type
19+
conditionIsMatched = condition_is_matched
20+
getValueFromContext = get_value_from_context
1221

1322
__all__ = [
1423
"MAX_BUCKETED_NUMBER",
@@ -32,4 +41,12 @@
3241
"get_value_by_type",
3342
"get_value_from_context",
3443
"loggerPrefix",
44+
"murmurhash_v3",
45+
"createInstance",
46+
"createLogger",
47+
"getBucketKey",
48+
"getBucketedNumber",
49+
"getValueByType",
50+
"conditionIsMatched",
51+
"getValueFromContext",
3552
]

src/featurevisor/tester.py

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,58 @@
1313
from .project import FeaturevisorProject, pretty_duration, timed_build
1414

1515

16+
def _stringify_value(value: Any) -> str:
17+
if isinstance(value, (dict, list)):
18+
return json.dumps(value)
19+
if isinstance(value, bool):
20+
return "true" if value else "false"
21+
if value is None:
22+
return "null"
23+
return str(value)
24+
25+
26+
def _print_test_result(result: dict[str, Any], test_key: str) -> None:
27+
print("")
28+
print(f"Testing: {test_key}.yml ({pretty_duration(result['duration'] / 1000)})")
29+
if result.get("notFound"):
30+
print(f" => {result['type']} {result['key']} not found")
31+
return
32+
print(f' {result["type"]} "{result["key"]}":')
33+
for assertion in result["assertions"]:
34+
marker = "✔" if assertion["passed"] else "✘"
35+
print(f" {marker} {assertion['description']} ({pretty_duration(assertion['duration'] / 1000)})")
36+
if assertion["passed"]:
37+
continue
38+
for error in assertion.get("errors", []):
39+
if error.get("message"):
40+
print(f" => {error['message']}")
41+
continue
42+
section = error["type"]
43+
if error["type"] == "flag":
44+
section = "expectedToBeEnabled"
45+
elif error["type"] == "variation":
46+
section = "expectedVariation"
47+
elif error["type"] == "variable":
48+
section = "expectedVariables"
49+
details = error.get("details") or {}
50+
if details.get("childIndex") is not None:
51+
section = f"children[{details['childIndex']}].{section}"
52+
if error["type"] == "variable":
53+
variable_key = details.get("variableKey")
54+
print(f" => {section}.{variable_key}:")
55+
print(f" => expected: {_stringify_value(error.get('expected'))}")
56+
print(f" => received: {_stringify_value(error.get('actual'))}")
57+
else:
58+
if error["type"] == "evaluation":
59+
if details.get("variableKey"):
60+
section = f"{section}.variables.{details['variableKey']}.{details['evaluationKey']}"
61+
elif details.get("evaluationType"):
62+
section = f"{section}.{details['evaluationType']}.{details['evaluationKey']}"
63+
print(
64+
f' => {section}: expected "{_stringify_value(error.get("expected"))}", received "{_stringify_value(error.get("actual"))}"'
65+
)
66+
67+
1668
def _compare_jsonish(expected: Any, actual: Any) -> bool:
1769
return json.dumps(expected, sort_keys=True) == json.dumps(actual, sort_keys=True)
1870

@@ -134,6 +186,11 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N
134186
features_by_key = {item["key"]: item for item in project.list_features()}
135187
segments_by_key = {item["key"]: item for item in project.list_segments()}
136188
datafile_cache: dict[Any, dict[str, Any]] = {}
189+
start = time.perf_counter()
190+
passed_tests_count = 0
191+
failed_tests_count = 0
192+
passed_assertions_count = 0
193+
failed_assertions_count = 0
137194

138195
environments = config.get("environments")
139196
if environments is False:
@@ -204,19 +261,24 @@ def run_test_project(project_directory_path: str, *, key_pattern: str | None = N
204261
result = {"type": "segment", "key": segment_key, "notFound": True, "passed": False, "duration": 0, "assertions": []}
205262
else:
206263
result = test_segment({"segment": segment_key, "conditions": segment_source["conditions"], "assertions": test["assertions"]}, {"verbose": verbose, "quiet": quiet})
207-
if only_failures and result["passed"]:
208-
continue
209-
symbol = "✓" if result["passed"] else "✘"
210-
print(f"{symbol} {result['type']} {result['key']} ({result['duration']}ms)")
264+
if result["passed"]:
265+
passed_tests_count += 1
266+
else:
267+
failed_tests_count += 1
211268
for assertion in result["assertions"]:
212-
if assertion["passed"] and not verbose:
213-
continue
214-
prefix = " ✓" if assertion["passed"] else " ✘"
215-
print(f"{prefix} {assertion['description']}")
216-
for error in assertion.get("errors", []):
217-
message = error.get("message") or f"expected {error.get('expected')} but received {error.get('actual')}"
218-
print(f" - {error['type']}: {message}")
269+
if assertion["passed"]:
270+
passed_assertions_count += 1
271+
else:
272+
failed_assertions_count += 1
273+
if not (only_failures and result["passed"]):
274+
_print_test_result(result, test["key"])
219275
passed = passed and result["passed"]
276+
if not only_failures or not passed:
277+
print("\n---")
278+
print("")
279+
print(f"Test specs: {passed_tests_count} passed, {failed_tests_count} failed")
280+
print(f"Assertions: {passed_assertions_count} passed, {failed_assertions_count} failed")
281+
print(f"Time: {pretty_duration(time.perf_counter() - start)}")
220282
return passed
221283

222284

tests/test_cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
import sys
44
import unittest
5+
from unittest.mock import patch
56

67
sys.path.insert(0, "src")
78

8-
from featurevisor.cli import build_parser
9+
from featurevisor.cli import build_parser, main
910

1011

1112
class CLITests(unittest.TestCase):
@@ -25,6 +26,10 @@ def test_parse_benchmark_flags(self) -> None:
2526
self.assertEqual(args.n, 20)
2627
self.assertTrue(args.variation)
2728

29+
def test_main_returns_non_zero_on_failed_tests(self) -> None:
30+
with patch("featurevisor.cli.run_test_project", return_value=False):
31+
self.assertEqual(main(["test"]), 1)
32+
2833

2934
if __name__ == "__main__":
3035
unittest.main()

tests/test_conditions_parity.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
from __future__ import annotations
2+
3+
import datetime as dt
4+
import sys
5+
import unittest
6+
7+
sys.path.insert(0, "src")
8+
9+
from featurevisor import DatafileReader, createLogger
10+
11+
12+
class ConditionsParityTests(unittest.TestCase):
13+
@classmethod
14+
def setUpClass(cls) -> None:
15+
cls.reader = DatafileReader(
16+
datafile={"schemaVersion": "2.0", "revision": "1", "segments": {}, "features": {}},
17+
logger=createLogger(),
18+
)
19+
20+
def test_should_be_a_function(self) -> None:
21+
self.assertTrue(callable(self.reader.allConditionsAreMatched))
22+
23+
def test_should_match_all_via_star(self) -> None:
24+
self.assertTrue(self.reader.allConditionsAreMatched("*", {"browser_type": "chrome"}))
25+
self.assertFalse(self.reader.allConditionsAreMatched("blah", {"browser_type": "chrome"}))
26+
27+
def test_operator_cases(self) -> None:
28+
cases = [
29+
([{"attribute": "browser_type", "operator": "equals", "value": "chrome"}], {"browser_type": "chrome"}, True),
30+
([{"attribute": "browser_type", "operator": "equals", "value": "chrome"}], {"browser_type": "firefox"}, False),
31+
([{"attribute": "browser.type", "operator": "equals", "value": "chrome"}], {"browser": {"type": "chrome"}}, True),
32+
([{"attribute": "browser.type", "operator": "equals", "value": "chrome"}], {"browser": {"type": "firefox"}}, False),
33+
([{"attribute": "browser_type", "operator": "notEquals", "value": "chrome"}], {"browser_type": "firefox"}, True),
34+
([{"attribute": "browser_type", "operator": "notEquals", "value": "chrome"}], {"browser_type": "chrome"}, False),
35+
([{"attribute": "browser_type", "operator": "exists"}], {"browser_type": "firefox"}, True),
36+
([{"attribute": "browser_type", "operator": "exists"}], {"not_browser_type": "chrome"}, False),
37+
([{"attribute": "browser.name", "operator": "exists"}], {"browser": {"name": "chrome"}}, True),
38+
([{"attribute": "browser.name", "operator": "exists"}], {"browser": "chrome"}, False),
39+
([{"attribute": "name", "operator": "notExists"}], {"not_name": "Hello World"}, True),
40+
([{"attribute": "name", "operator": "notExists"}], {"name": "Hi World"}, False),
41+
([{"attribute": "name", "operator": "endsWith", "value": "World"}], {"name": "Hello World"}, True),
42+
([{"attribute": "name", "operator": "endsWith", "value": "World"}], {"name": "Hi Universe"}, False),
43+
([{"attribute": "permissions", "operator": "includes", "value": "write"}], {"permissions": ["read", "write"]}, True),
44+
([{"attribute": "permissions", "operator": "includes", "value": "write"}], {"permissions": ["read"]}, False),
45+
([{"attribute": "permissions", "operator": "notIncludes", "value": "write"}], {"permissions": ["read", "admin"]}, True),
46+
([{"attribute": "permissions", "operator": "notIncludes", "value": "write"}], {"permissions": ["read", "write", "admin"]}, False),
47+
([{"attribute": "name", "operator": "contains", "value": "Hello"}], {"name": "Hello World"}, True),
48+
([{"attribute": "name", "operator": "contains", "value": "Hello"}], {"name": "Hi World"}, False),
49+
([{"attribute": "name", "operator": "notContains", "value": "Hello"}], {"name": "Hi World"}, True),
50+
([{"attribute": "name", "operator": "notContains", "value": "Hello"}], {"name": "Hello World"}, False),
51+
([{"attribute": "name", "operator": "matches", "value": "^[a-zA-Z]{2,}$"}], {"name": "Hello"}, True),
52+
([{"attribute": "name", "operator": "matches", "value": "^[a-zA-Z]{2,}$"}], {"name": "Hello World"}, False),
53+
([{"attribute": "name", "operator": "matches", "value": "^[a-zA-Z]{2,}$", "regexFlags": "i"}], {"name": "Hello"}, True),
54+
([{"attribute": "name", "operator": "notMatches", "value": "^[a-zA-Z]{2,}$"}], {"name": "Hi World"}, True),
55+
([{"attribute": "name", "operator": "notMatches", "value": "^[a-zA-Z]{2,}$"}], {"name": "Hello"}, False),
56+
([{"attribute": "browser_type", "operator": "in", "value": ["chrome", "firefox"]}], {"browser_type": "chrome"}, True),
57+
([{"attribute": "browser_type", "operator": "in", "value": ["chrome", "firefox"]}], {"browser_type": "edge"}, False),
58+
([{"attribute": "browser_type", "operator": "notIn", "value": ["chrome", "firefox"]}], {"browser_type": "edge"}, True),
59+
([{"attribute": "browser_type", "operator": "notIn", "value": ["chrome", "firefox"]}], {"browser_type": "chrome"}, False),
60+
([{"attribute": "age", "operator": "greaterThan", "value": 18}], {"age": 19}, True),
61+
([{"attribute": "age", "operator": "greaterThan", "value": 18}], {"age": 17}, False),
62+
([{"attribute": "age", "operator": "greaterThanOrEquals", "value": 18}], {"age": 18}, True),
63+
([{"attribute": "age", "operator": "greaterThanOrEquals", "value": 18}], {"age": 17}, False),
64+
([{"attribute": "age", "operator": "lessThan", "value": 18}], {"age": 17}, True),
65+
([{"attribute": "age", "operator": "lessThan", "value": 18}], {"age": 19}, False),
66+
([{"attribute": "age", "operator": "lessThanOrEquals", "value": 18}], {"age": 18}, True),
67+
([{"attribute": "age", "operator": "lessThanOrEquals", "value": 18}], {"age": 19}, False),
68+
([{"attribute": "version", "operator": "semverEquals", "value": "1.0.0"}], {"version": "1.0.0"}, True),
69+
([{"attribute": "version", "operator": "semverEquals", "value": "1.0.0"}], {"version": "2.0.0"}, False),
70+
([{"attribute": "version", "operator": "semverNotEquals", "value": "1.0.0"}], {"version": "2.0.0"}, True),
71+
([{"attribute": "version", "operator": "semverGreaterThan", "value": "1.0.0"}], {"version": "2.0.0"}, True),
72+
([{"attribute": "version", "operator": "semverGreaterThanOrEquals", "value": "1.0.0"}], {"version": "1.0.0"}, True),
73+
([{"attribute": "version", "operator": "semverLessThan", "value": "1.0.0"}], {"version": "0.9.0"}, True),
74+
([{"attribute": "version", "operator": "semverLessThanOrEquals", "value": "1.0.0"}], {"version": "1.1.0"}, False),
75+
([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-12T00:00:00Z"}, True),
76+
([{"attribute": "date", "operator": "before", "value": "2023-05-13T16:23:59Z"}], {"date": dt.datetime.fromisoformat("2023-05-14T00:00:00+00:00")}, False),
77+
([{"attribute": "date", "operator": "after", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-14T00:00:00Z"}, True),
78+
([{"attribute": "date", "operator": "after", "value": "2023-05-13T16:23:59Z"}], {"date": "2023-05-12T00:00:00Z"}, False),
79+
]
80+
for conditions, context, expected in cases:
81+
with self.subTest(conditions=conditions, context=context):
82+
self.assertEqual(self.reader.allConditionsAreMatched(conditions, context), expected)
83+
84+
def test_simple_and_nested_conditions(self) -> None:
85+
cases = [
86+
({"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"browser_type": "chrome"}, True),
87+
([], {"browser_type": "chrome"}, True),
88+
([{"attribute": "browser_type", "operator": "equals", "value": "chrome"}], {"browser_type": "chrome", "browser_version": "1.0"}, True),
89+
(
90+
[{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}],
91+
{"browser_type": "chrome", "browser_version": "1.0", "foo": "bar"},
92+
True,
93+
),
94+
([{"and": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}]}], {"browser_type": "chrome"}, True),
95+
([{"and": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_type": "chrome"}, False),
96+
([{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_version": "1.0"}, True),
97+
([{"not": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}]}], {"browser_type": "firefox"}, True),
98+
([{"not": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"attribute": "browser_version", "operator": "equals", "value": "1.0"}]}], {"browser_type": "chrome", "browser_version": "1.0"}, False),
99+
([{"and": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"or": [{"attribute": "browser_version", "operator": "equals", "value": "1.0"}, {"attribute": "browser_version", "operator": "equals", "value": "2.0"}]}]}], {"browser_type": "chrome", "browser_version": "1.0"}, True),
100+
(
101+
[
102+
{"attribute": "country", "operator": "equals", "value": "nl"},
103+
{"and": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"or": [{"attribute": "browser_version", "operator": "equals", "value": "1.0"}, {"attribute": "browser_version", "operator": "equals", "value": "2.0"}]}]},
104+
],
105+
{"country": "nl", "browser_type": "chrome", "browser_version": "2.0"},
106+
True,
107+
),
108+
([{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"and": [{"attribute": "device_type", "operator": "equals", "value": "mobile"}, {"attribute": "orientation", "operator": "equals", "value": "portrait"}]}]}], {"browser_type": "firefox", "device_type": "mobile", "orientation": "portrait"}, True),
109+
(
110+
[
111+
{"attribute": "country", "operator": "equals", "value": "nl"},
112+
{"or": [{"attribute": "browser_type", "operator": "equals", "value": "chrome"}, {"and": [{"attribute": "device_type", "operator": "equals", "value": "mobile"}, {"attribute": "orientation", "operator": "equals", "value": "portrait"}]}]},
113+
],
114+
{"country": "de", "browser_type": "firefox", "device_type": "desktop"},
115+
False,
116+
),
117+
]
118+
for conditions, context, expected in cases:
119+
with self.subTest(conditions=conditions, context=context):
120+
self.assertEqual(self.reader.allConditionsAreMatched(conditions, context), expected)
121+
122+
123+
if __name__ == "__main__":
124+
unittest.main()

0 commit comments

Comments
 (0)