Skip to content

Commit 5da0fd0

Browse files
dbantyeli-bl
andauthored
test: Add tests to verify behavior of generated code (#1225)
Replaces #1156 --------- Co-authored-by: Eli Bishop <[email protected]> Co-authored-by: Dylan Anthony <[email protected]>
1 parent e6a597f commit 5da0fd0

32 files changed

+1926
-1354
lines changed

CONTRIBUTING.md

+22-8
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,40 @@ All changes must be tested, I recommend writing the test first, then writing the
5050

5151
If you think that some of the added code is not testable (or testing it would add little value), mention that in your PR and we can discuss it.
5252

53-
1. If you're adding support for a new OpenAPI feature or covering a new edge case, add an [end-to-end test](#end-to-end-tests)
54-
2. If you're modifying the way an existing feature works, make sure an existing test generates the _old_ code in `end_to_end_tests/golden-record`. You'll use this to check for the new code once your changes are complete.
55-
3. If you're improving an error or adding a new error, add a [unit test](#unit-tests)
53+
1. If you're adding support for a new OpenAPI feature or covering a new edge case, add [functional tests](#functional-tests), and optionally an [end-to-end snapshot test](#end-to-end-snapshot-tests).
54+
2. If you're modifying the way an existing feature works, make sure functional tests cover this case. Existing end-to-end snapshot tests might also be affected if you have changed what generated model/endpoint code looks like.
55+
3. If you're improving error handling or adding a new error, add [functional tests](#functional-tests).
56+
4. For tests of low-level pieces of code that are fairly self-contained, and not tightly coupled to other internal implementation details, you can use regular [unit tests](#unit-tests).
5657

57-
#### End-to-end tests
58+
#### End-to-end snapshot tests
5859

59-
This project aims to have all "happy paths" (types of code which _can_ be generated) covered by end to end tests (snapshot tests). In order to check code changes against the previous set of snapshots (called a "golden record" here), you can run `pdm e2e`. To regenerate the snapshots, run `pdm regen`.
60+
This project aims to have all "happy paths" (types of code which _can_ be generated) covered by end-to-end tests. There are two types of these: snapshot tests, and functional tests.
6061

61-
There are 4 types of snapshots generated right now, you may have to update only some or all of these depending on the changes you're making. Within the `end_to_end_tets` directory:
62+
Snapshot tests verify that the generated code is identical to a previously-committed set of snapshots (called a "golden record" here). They are basically regression tests to catch any unintended changes in the generator output.
63+
64+
In order to check code changes against the previous set of snapshots (called a "golden record" here), you can run `pdm e2e`. To regenerate the snapshots, run `pdm regen`.
65+
66+
There are 4 types of snapshots generated right now, you may have to update only some or all of these depending on the changes you're making. Within the `end_to_end_tests` directory:
6267

6368
1. `baseline_openapi_3.0.json` creates `golden-record` for testing OpenAPI 3.0 features
6469
2. `baseline_openapi_3.1.yaml` is checked against `golden-record` for testing OpenAPI 3.1 features (and ensuring consistency with 3.0)
6570
3. `test_custom_templates` are used with `baseline_openapi_3.0.json` to generate `custom-templates-golden-record` for testing custom templates
6671
4. `3.1_specific.openapi.yaml` is used to generate `test-3-1-golden-record` and test 3.1-specific features (things which do not have a 3.0 equivalent)
6772

73+
#### Functional tests
74+
75+
These are black-box tests that verify the runtime behavior of generated code, as well as the generator's validation behavior. They are also end-to-end tests, since they run the generator as a shell command.
76+
77+
This can sometimes identify issues with error handling, validation logic, module imports, etc., that might be harder to diagnose via the snapshot tests, especially during development of a new feature. For instance, they can verify that JSON data is correctly decoded into model class attributes, or that the generator will emit an appropriate warning or error for an invalid spec.
78+
79+
See [`end_to_end_tests/functional_tests`](./end_to_end_tests/functional_tests).
80+
6881
#### Unit tests
6982

70-
> **NOTE**: Several older-style unit tests using mocks exist in this project. These should be phased out rather than updated, as the tests are brittle and difficult to maintain. Only error cases should be tests with unit tests going forward.
83+
These include:
7184

72-
In some cases, we need to test things which cannot be generated—like validating that errors are caught and handled correctly. These should be tested via unit tests in the `tests` directory, using the `pytest` framework.
85+
* Regular unit tests of basic pieces of fairly self-contained low-level functionality, such as helper functions. These are implemented in the `tests` directory, using the `pytest` framework.
86+
* Older-style unit tests of low-level functions like `property_from_data` that have complex behavior. These are brittle and difficult to maintain, and should not be used going forward. Instead, they should be migrated to functional tests.
7387

7488
### Creating a Pull Request
7589

end_to_end_tests/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
""" Generate a complete client and verify that it is correct """
2+
import pytest
3+
4+
pytest.register_assert_rewrite("end_to_end_tests.end_to_end_test_helpers")
5+
pytest.register_assert_rewrite("end_to_end_tests.functional_tests.helpers")
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
## The `functional_tests` module
2+
3+
These are end-to-end tests which run the client generator against many small API documents that are specific to various test cases.
4+
5+
Rather than testing low-level implementation details (like the unit tests in `tests`), or making assertions about the exact content of the generated code (like the "golden record"-based end-to-end tests), these treat both the generator and the generated code as black boxes and make assertions about their behavior.
6+
7+
The tests are in two submodules:
8+
9+
# `generated_code_execution`
10+
11+
These tests use valid API specs, and after running the generator, they _import and execute_ pieces of the generated code to verify that it actually works at runtime.
12+
13+
Each test class follows this pattern:
14+
15+
- Use the decorator `@with_generated_client_fixture`, providing an inline API spec (JSON or YAML) that contains whatever schemas/paths/etc. are relevant to this test class.
16+
- The spec can omit the `openapi:`, `info:`, and `paths:`, blocks, unless those are relevant to the test.
17+
- The decorator creates a temporary file for the inline spec and a temporary directory for the generated code, and runs the client generator.
18+
- It creates a `GeneratedClientContext` object (defined in `end_to_end_test_helpers.py`) to keep track of things like the location of the generated code and the output of the generator command.
19+
- This object is injected into the test class as a fixture called `generated_client`, although most tests will not need to reference the fixture directly.
20+
- `sys.path` is temporarily changed, for the scope of this test class, to allow imports from the generated code.
21+
- Use the decorator `@with_generated_code_imports` or `@with_generated_code_import` to make classes or functions from the generated code available to the tests.
22+
- `@with_generated_code_imports(".models.MyModel1", ".models.MyModel2)` would execute `from [package name].models import MyModel1, MyModel2` and inject the imported classes into the test class as fixtures called `MyModel1` and `MyModel2`.
23+
- `@with_generated_code_import(".api.my_operation.sync", alias="endpoint_method")` would execute `from [package name].api.my_operation import sync`, but the fixture would be named `endpoint_method`.
24+
- After the test class finishes, these imports are discarded.
25+
26+
Example:
27+
28+
```python
29+
@with_generated_client_fixture(
30+
"""
31+
components:
32+
schemas:
33+
MyModel:
34+
type: object
35+
properties:
36+
stringProp: {"type": "string"}
37+
""")
38+
@with_generated_code_import(".models.MyModel")
39+
class TestSimpleJsonObject:
40+
def test_encoding(self, MyModel):
41+
instance = MyModel(string_prop="abc")
42+
assert instance.to_dict() == {"stringProp": "abc"}
43+
```
44+
45+
# `generator_failure_cases`
46+
47+
These run the generator with an invalid API spec and make assertions about the warning/error output. Some of these invalid conditions are expected to only produce warnings about the affected schemas, while others are expected to produce fatal errors that terminate the generator.
48+
49+
For warning conditions, each test class uses `@with_generated_client_fixture` as above, then uses `assert_bad_schema` to parse the output and check for a specific warning message for a specific schema name.
50+
51+
```python
52+
@with_generated_client_fixture(
53+
"""
54+
components:
55+
schemas:
56+
MyModel:
57+
# some kind of invalid schema
58+
""")
59+
class TestBadSchema:
60+
def test_encoding(self, generated_client):
61+
assert_bad_schema(generated_client, "MyModel", "some expected warning text")
62+
```
63+
64+
Or, for fatal error conditions:
65+
66+
- Call `inline_spec_should_fail`, providing an inline API spec (JSON or YAML).
67+
68+
```python
69+
class TestBadSpec:
70+
def test_some_spec_error(self):
71+
result = inline_spec_should_fail("""
72+
# some kind of invalid spec
73+
""")
74+
assert "some expected error text" in result.output
75+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from typing import Any, ForwardRef, Union
2+
3+
from end_to_end_tests.functional_tests.helpers import (
4+
assert_model_decode_encode,
5+
assert_model_property_type_hint,
6+
with_generated_client_fixture,
7+
with_generated_code_imports,
8+
)
9+
10+
11+
@with_generated_client_fixture(
12+
"""
13+
components:
14+
schemas:
15+
SimpleObject:
16+
type: object
17+
properties:
18+
name: {"type": "string"}
19+
ModelWithArrayOfAny:
20+
properties:
21+
arrayProp:
22+
type: array
23+
items: {}
24+
ModelWithArrayOfInts:
25+
properties:
26+
arrayProp:
27+
type: array
28+
items: {"type": "integer"}
29+
ModelWithArrayOfObjects:
30+
properties:
31+
arrayProp:
32+
type: array
33+
items: {"$ref": "#/components/schemas/SimpleObject"}
34+
""")
35+
@with_generated_code_imports(
36+
".models.ModelWithArrayOfAny",
37+
".models.ModelWithArrayOfInts",
38+
".models.ModelWithArrayOfObjects",
39+
".models.SimpleObject",
40+
".types.Unset",
41+
)
42+
class TestArraySchemas:
43+
def test_array_of_any(self, ModelWithArrayOfAny):
44+
assert_model_decode_encode(
45+
ModelWithArrayOfAny,
46+
{"arrayProp": ["a", 1]},
47+
ModelWithArrayOfAny(array_prop=["a", 1]),
48+
)
49+
50+
def test_array_of_int(self, ModelWithArrayOfInts):
51+
assert_model_decode_encode(
52+
ModelWithArrayOfInts,
53+
{"arrayProp": [1, 2]},
54+
ModelWithArrayOfInts(array_prop=[1, 2]),
55+
)
56+
# Note, currently arrays of simple types are not validated, so the following assertion would fail:
57+
# with pytest.raises(TypeError):
58+
# ModelWithArrayOfInt.from_dict({"arrayProp": [1, "a"]})
59+
60+
def test_array_of_object(self, ModelWithArrayOfObjects, SimpleObject):
61+
assert_model_decode_encode(
62+
ModelWithArrayOfObjects,
63+
{"arrayProp": [{"name": "a"}, {"name": "b"}]},
64+
ModelWithArrayOfObjects(array_prop=[SimpleObject(name="a"), SimpleObject(name="b")]),
65+
)
66+
67+
def test_type_hints(self, ModelWithArrayOfAny, ModelWithArrayOfInts, ModelWithArrayOfObjects, Unset):
68+
assert_model_property_type_hint(ModelWithArrayOfAny, "array_prop", Union[list[Any], Unset])
69+
assert_model_property_type_hint(ModelWithArrayOfInts, "array_prop", Union[list[int], Unset])
70+
assert_model_property_type_hint(ModelWithArrayOfObjects, "array_prop", Union[list["SimpleObject"], Unset])
71+
72+
73+
@with_generated_client_fixture(
74+
"""
75+
components:
76+
schemas:
77+
SimpleObject:
78+
type: object
79+
properties:
80+
name: {"type": "string"}
81+
ModelWithSinglePrefixItem:
82+
type: object
83+
properties:
84+
arrayProp:
85+
type: array
86+
prefixItems:
87+
- type: string
88+
ModelWithPrefixItems:
89+
type: object
90+
properties:
91+
arrayProp:
92+
type: array
93+
prefixItems:
94+
- $ref: "#/components/schemas/SimpleObject"
95+
- type: string
96+
ModelWithMixedItems:
97+
type: object
98+
properties:
99+
arrayProp:
100+
type: array
101+
prefixItems:
102+
- $ref: "#/components/schemas/SimpleObject"
103+
items:
104+
type: string
105+
""")
106+
@with_generated_code_imports(
107+
".models.ModelWithSinglePrefixItem",
108+
".models.ModelWithPrefixItems",
109+
".models.ModelWithMixedItems",
110+
".models.SimpleObject",
111+
".types.Unset",
112+
)
113+
class TestArraysWithPrefixItems:
114+
def test_single_prefix_item(self, ModelWithSinglePrefixItem):
115+
assert_model_decode_encode(
116+
ModelWithSinglePrefixItem,
117+
{"arrayProp": ["a"]},
118+
ModelWithSinglePrefixItem(array_prop=["a"]),
119+
)
120+
121+
def test_prefix_items(self, ModelWithPrefixItems, SimpleObject):
122+
assert_model_decode_encode(
123+
ModelWithPrefixItems,
124+
{"arrayProp": [{"name": "a"}, "b"]},
125+
ModelWithPrefixItems(array_prop=[SimpleObject(name="a"), "b"]),
126+
)
127+
128+
def test_prefix_items_and_regular_items(self, ModelWithMixedItems, SimpleObject):
129+
assert_model_decode_encode(
130+
ModelWithMixedItems,
131+
{"arrayProp": [{"name": "a"}, "b"]},
132+
ModelWithMixedItems(array_prop=[SimpleObject(name="a"), "b"]),
133+
)
134+
135+
def test_type_hints(self, ModelWithSinglePrefixItem, ModelWithPrefixItems, ModelWithMixedItems, Unset):
136+
assert_model_property_type_hint(ModelWithSinglePrefixItem, "array_prop", Union[list[str], Unset])
137+
assert_model_property_type_hint(
138+
ModelWithPrefixItems,
139+
"array_prop",
140+
Union[list[Union[ForwardRef("SimpleObject"), str]], Unset],
141+
)
142+
assert_model_property_type_hint(
143+
ModelWithMixedItems,
144+
"array_prop",
145+
Union[list[Union[ForwardRef("SimpleObject"), str]], Unset],
146+
)
147+
# Note, this test is asserting the current behavior which, due to limitations of the implementation
148+
# (see: https://github.com/openapi-generators/openapi-python-client/pull/1130), is not really doing
149+
# tuple type validation-- the ordering of prefixItems is ignored, and instead all of the types are
150+
# simply treated as a union.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import datetime
2+
import uuid
3+
4+
from end_to_end_tests.functional_tests.helpers import (
5+
with_generated_client_fixture,
6+
with_generated_code_imports,
7+
)
8+
9+
10+
@with_generated_client_fixture(
11+
"""
12+
components:
13+
schemas:
14+
MyModel:
15+
type: object
16+
properties:
17+
booleanProp: {"type": "boolean", "default": true}
18+
stringProp: {"type": "string", "default": "a"}
19+
numberProp: {"type": "number", "default": 1.5}
20+
intProp: {"type": "integer", "default": 2}
21+
dateProp: {"type": "string", "format": "date", "default": "2024-01-02"}
22+
dateTimeProp: {"type": "string", "format": "date-time", "default": "2024-01-02T03:04:05Z"}
23+
uuidProp: {"type": "string", "format": "uuid", "default": "07EF8B4D-AA09-4FFA-898D-C710796AFF41"}
24+
anyPropWithString: {"default": "b"}
25+
anyPropWithInt: {"default": 3}
26+
booleanWithStringTrue1: {"type": "boolean", "default": "True"}
27+
booleanWithStringTrue2: {"type": "boolean", "default": "true"}
28+
booleanWithStringFalse1: {"type": "boolean", "default": "False"}
29+
booleanWithStringFalse2: {"type": "boolean", "default": "false"}
30+
intWithStringValue: {"type": "integer", "default": "4"}
31+
numberWithIntValue: {"type": "number", "default": 5}
32+
numberWithStringValue: {"type": "number", "default": "5.5"}
33+
stringWithNumberValue: {"type": "string", "default": 6}
34+
stringConst: {"type": "string", "const": "always", "default": "always"}
35+
unionWithValidDefaultForType1:
36+
anyOf: [{"type": "boolean"}, {"type": "integer"}]
37+
default: true
38+
unionWithValidDefaultForType2:
39+
anyOf: [{"type": "boolean"}, {"type": "integer"}]
40+
default: 3
41+
""")
42+
@with_generated_code_imports(".models.MyModel")
43+
class TestSimpleDefaults:
44+
# Note, the null/None type is not covered here due to a known bug:
45+
# https://github.com/openapi-generators/openapi-python-client/issues/1162
46+
def test_defaults_in_initializer(self, MyModel):
47+
instance = MyModel()
48+
assert instance == MyModel(
49+
boolean_prop=True,
50+
string_prop="a",
51+
number_prop=1.5,
52+
int_prop=2,
53+
date_prop=datetime.date(2024, 1, 2),
54+
date_time_prop=datetime.datetime(2024, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc),
55+
uuid_prop=uuid.UUID("07EF8B4D-AA09-4FFA-898D-C710796AFF41"),
56+
any_prop_with_string="b",
57+
any_prop_with_int=3,
58+
boolean_with_string_true_1=True,
59+
boolean_with_string_true_2=True,
60+
boolean_with_string_false_1=False,
61+
boolean_with_string_false_2=False,
62+
int_with_string_value=4,
63+
number_with_int_value=5,
64+
number_with_string_value=5.5,
65+
string_with_number_value="6",
66+
string_const="always",
67+
union_with_valid_default_for_type_1=True,
68+
union_with_valid_default_for_type_2=3,
69+
)
70+
71+
72+
73+
@with_generated_client_fixture(
74+
"""
75+
components:
76+
schemas:
77+
MyEnum:
78+
type: string
79+
enum: ["a", "b"]
80+
MyModel:
81+
type: object
82+
properties:
83+
enumProp:
84+
allOf:
85+
- $ref: "#/components/schemas/MyEnum"
86+
default: "a"
87+
88+
""")
89+
@with_generated_code_imports(".models.MyEnum", ".models.MyModel")
90+
class TestEnumDefaults:
91+
def test_enum_default(self, MyEnum, MyModel):
92+
assert MyModel().enum_prop == MyEnum.A
93+
94+
95+
@with_generated_client_fixture(
96+
"""
97+
components:
98+
schemas:
99+
MyEnum:
100+
type: string
101+
enum: ["a", "A"]
102+
MyModel:
103+
properties:
104+
enumProp:
105+
allOf:
106+
- $ref: "#/components/schemas/MyEnum"
107+
default: A
108+
""",
109+
config="literal_enums: true",
110+
)
111+
@with_generated_code_imports(".models.MyModel")
112+
class TestLiteralEnumDefaults:
113+
def test_default_value(self, MyModel):
114+
assert MyModel().enum_prop == "A"

0 commit comments

Comments
 (0)