Skip to content

Commit 52a235c

Browse files
committed
restore some missing test coverage
1 parent 53fca35 commit 52a235c

15 files changed

+348
-311
lines changed

.changeset/live_tests.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@
22
default: minor
33
---
44

5-
# New category of end-to-end tests
5+
# New categories of end-to-end tests
66

7-
There is a new set of tests that generate client code from an API document and then actually import and execute that code. See [`end_to_end_tests/generated_code_live_tests`](./end_to_end_tests/generated_code_live_tests) for more details.
7+
Automated tests have been extended to include two new types of tests:
8+
9+
1. Happy-path tests that run the generator from an inline API document and then actually import and execute the generated code. See [`end_to_end_tests/generated_code_live_tests`](./end_to_end_tests/generated_code_live_tests).
10+
2. Warning/error condition tests that run the generator from an inline API document that contains something invalid, and make assertions about the generator's output.
11+
12+
These provide more efficient and granular test coverage than the "golden record"-based end-to-end tests, and also replace some tests that were previously being done against low-level implementation details in `tests/unit`.
813

914
This does not affect any runtime functionality of openapi-python-client.

CONTRIBUTING.md

+5-3
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,13 @@ The tests run the generator against a small API spec (defined inline for each te
7777

7878
See [`end_to_end_tests/generated_code_live_tests`](./end_to_end_tests/generated_code_live_tests).
7979

80-
#### Unit tests
80+
#### Other unit tests
8181

82-
> **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.
82+
These include:
8383

84-
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.
84+
* Regular unit tests of basic pieces of fairly self-contained low-level functionality, such as helper functions. These are implemented in the `tests/unit` directory, using the `pytest` framework.
85+
* End-to-end tests of invalid spec conditions, where we run the generator against a small spec with some problem, and expect it to print warnings/errors rather than generating code. These are implemented in `end_to_end_tests/generator_errors_and_warnings`.
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, use either unit tests of generated code (to test happy paths), or end-to-end tests of invalid spec conditions (to test for warnings/errors), as described above.
8587

8688
### Creating a Pull Request
8789

end_to_end_tests/end_to_end_test_helpers.py

+4-5
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,17 @@ def generate_client(
7878
extra_args: List[str] = [],
7979
output_path: str = "my-test-api-client",
8080
base_module: str = "my_test_api_client",
81+
specify_output_path_explicitly: bool = True,
8182
overwrite: bool = True,
8283
raise_on_error: bool = True,
8384
) -> GeneratedClientContext:
8485
"""Run the generator and return a GeneratedClientContext for accessing the generated code."""
8586
full_output_path = Path.cwd() / output_path
8687
if not overwrite:
8788
shutil.rmtree(full_output_path, ignore_errors=True)
88-
args = [
89-
*extra_args,
90-
"--output-path",
91-
str(full_output_path),
92-
]
89+
args = extra_args
90+
if specify_output_path_explicitly:
91+
args = [*args, "--output-path", str(full_output_path)]
9392
if overwrite:
9493
args = [*args, "--overwrite"]
9594
generator_result = _run_command("generate", args, openapi_document, raise_on_error=raise_on_error)

end_to_end_tests/generated_code_live_tests/README.md

-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ Each test class follows this pattern:
1818
Example:
1919

2020
```python
21-
2221
@with_generated_client_fixture(
2322
"""
2423
components:
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
21
import datetime
32
import uuid
4-
import pytest
53
from end_to_end_tests.end_to_end_test_helpers import (
6-
assert_bad_schema_warning,
7-
assert_model_decode_encode,
8-
inline_spec_should_cause_warnings,
94
with_generated_client_fixture,
105
with_generated_code_imports,
116
)
@@ -36,6 +31,12 @@
3631
numberWithStringValue: {"type": "number", "default": "5.5"}
3732
stringWithNumberValue: {"type": "string", "default": 6}
3833
stringConst: {"type": "string", "const": "always", "default": "always"}
34+
unionWithValidDefaultForType1:
35+
anyOf: [{"type": "boolean"}, {"type": "integer"}]
36+
default: true
37+
unionWithValidDefaultForType2:
38+
anyOf: [{"type": "boolean"}, {"type": "integer"}]
39+
default: 3
3940
""")
4041
@with_generated_code_imports(".models.MyModel")
4142
class TestSimpleDefaults:
@@ -62,6 +63,8 @@ def test_defaults_in_initializer(self, MyModel):
6263
number_with_string_value=5.5,
6364
string_with_number_value="6",
6465
string_const="always",
66+
union_with_valid_default_for_type_1=True,
67+
union_with_valid_default_for_type_2=3,
6568
)
6669

6770

@@ -86,72 +89,3 @@ def test_defaults_in_initializer(self, MyModel):
8689
class TestEnumDefaults:
8790
def test_enum_default(self, MyEnum, MyModel):
8891
assert MyModel().enum_prop == MyEnum.A
89-
90-
91-
class TestInvalidDefaultValues:
92-
@pytest.fixture(scope="class")
93-
def warnings(self):
94-
return inline_spec_should_cause_warnings(
95-
"""
96-
components:
97-
schemas:
98-
WithBadBoolean:
99-
properties:
100-
badBoolean: {"type": "boolean", "default": "not a boolean"}
101-
WithBadIntAsString:
102-
properties:
103-
badInt: {"type": "integer", "default": "not an int"}
104-
WithBadIntAsOther:
105-
properties:
106-
badInt: {"type": "integer", "default": true}
107-
WithBadFloatAsString:
108-
properties:
109-
badInt: {"type": "number", "default": "not a number"}
110-
WithBadFloatAsOther:
111-
properties:
112-
badInt: {"type": "number", "default": true}
113-
WithBadDateAsString:
114-
properties:
115-
badDate: {"type": "string", "format": "date", "default": "xxx"}
116-
WithBadDateAsOther:
117-
properties:
118-
badDate: {"type": "string", "format": "date", "default": 3}
119-
WithBadDateTimeAsString:
120-
properties:
121-
badDate: {"type": "string", "format": "date-time", "default": "xxx"}
122-
WithBadDateTimeAsOther:
123-
properties:
124-
badDate: {"type": "string", "format": "date-time", "default": 3}
125-
WithBadUuidAsString:
126-
properties:
127-
badUuid: {"type": "string", "format": "uuid", "default": "xxx"}
128-
WithBadUuidAsOther:
129-
properties:
130-
badUuid: {"type": "string", "format": "uuid", "default": 3}
131-
WithBadEnum:
132-
properties:
133-
badEnum: {"type": "string", "enum": ["a", "b"], "default": "x"}
134-
"""
135-
)
136-
# Note, the null/None type, and binary strings (files), are not covered here due to a known bug:
137-
# https://github.com/openapi-generators/openapi-python-client/issues/1162
138-
139-
@pytest.mark.parametrize(
140-
("model_name", "message"),
141-
[
142-
("WithBadBoolean", "Invalid boolean value"),
143-
("WithBadIntAsString", "Invalid int value"),
144-
("WithBadIntAsOther", "Invalid int value"),
145-
("WithBadFloatAsString", "Invalid float value"),
146-
("WithBadFloatAsOther", "Cannot convert True to a float"),
147-
("WithBadDateAsString", "Invalid date"),
148-
("WithBadDateAsOther", "Cannot convert 3 to a date"),
149-
("WithBadDateTimeAsString", "Invalid datetime"),
150-
("WithBadDateTimeAsOther", "Cannot convert 3 to a datetime"),
151-
("WithBadUuidAsString", "Invalid UUID value"),
152-
("WithBadUuidAsOther", "Invalid UUID value"),
153-
("WithBadEnum", "Value x is not valid for enum"),
154-
]
155-
)
156-
def test_bad_default_warning(self, model_name, message, warnings):
157-
assert_bad_schema_warning(warnings, model_name, message)

end_to_end_tests/generated_code_live_tests/test_enum_and_const.py renamed to end_to_end_tests/generated_code_live_tests/test_enums_and_consts.py

+77-45
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,6 @@
1-
2-
from typing import Any
31
import pytest
42
from end_to_end_tests.end_to_end_test_helpers import (
5-
assert_bad_schema_warning,
63
assert_model_decode_encode,
7-
inline_spec_should_cause_warnings,
8-
inline_spec_should_fail,
94
with_generated_code_import,
105
with_generated_client_fixture,
116
with_generated_code_imports,
@@ -133,52 +128,89 @@ def test_invalid_int(self, MyModel):
133128
MyModel.from_dict({"mustBeThirty": 29})
134129

135130

136-
class TestEnumAndConstInvalidSchemas:
137-
@pytest.fixture(scope="class")
138-
def warnings(self):
139-
return inline_spec_should_cause_warnings(
131+
# The following tests of literal enums use basically the same specs as the tests above, but
132+
# the "literal_enums" option is enabled in the test configuration.
133+
134+
@with_generated_client_fixture(
140135
"""
141136
components:
142137
schemas:
143-
WithBadDefaultValue:
144-
enum: ["A"]
145-
default: "B"
146-
WithBadDefaultType:
147-
enum: ["A"]
148-
default: 123
149-
WithMixedTypes:
150-
enum: ["A", 1]
151-
WithUnsupportedType:
152-
enum: [1.4, 1.5]
153-
DefaultNotMatchingConst:
154-
const: "aaa"
155-
default: "bbb"
156-
"""
157-
)
158-
159-
def test_enum_bad_default_value(self, warnings):
160-
assert_bad_schema_warning(warnings, "WithBadDefaultValue", "Value B is not valid")
161-
162-
def test_enum_bad_default_type(self, warnings):
163-
assert_bad_schema_warning(warnings, "WithBadDefaultType", "Cannot convert 123 to enum")
164-
165-
def test_enum_mixed_types(self, warnings):
166-
assert_bad_schema_warning(warnings, "WithMixedTypes", "Enum values must all be the same type")
138+
MyEnum:
139+
type: string
140+
enum: ["a", "A"]
141+
MyIntEnum:
142+
type: integer
143+
enum: [2, 3]
144+
MyEnumIncludingNull:
145+
type: ["string", "null"]
146+
enum: ["a", "b", null]
147+
MyNullOnlyEnum:
148+
enum: [null]
149+
MyModel:
150+
properties:
151+
enumProp: {"$ref": "#/components/schemas/MyEnum"}
152+
intEnumProp: {"$ref": "#/components/schemas/MyIntEnum"}
153+
nullableEnumProp:
154+
oneOf:
155+
- {"$ref": "#/components/schemas/MyEnum"}
156+
- type: "null"
157+
enumIncludingNullProp: {"$ref": "#/components/schemas/MyEnumIncludingNull"}
158+
nullOnlyEnumProp: {"$ref": "#/components/schemas/MyNullOnlyEnum"}
159+
inlineEnumProp:
160+
type: string
161+
enum: ["a", "b"]
162+
""",
163+
config="""
164+
literal_enums: true
165+
""",
166+
)
167+
@with_generated_code_import(".models.MyModel")
168+
class TestLiteralEnums:
169+
def test_enum_prop(self, MyModel):
170+
assert_model_decode_encode(MyModel, {"enumProp": "a"}, MyModel(enum_prop="a"))
171+
assert_model_decode_encode(MyModel, {"enumProp": "A"}, MyModel(enum_prop="A"))
172+
assert_model_decode_encode(MyModel, {"intEnumProp": 2}, MyModel(int_enum_prop=2))
173+
assert_model_decode_encode(MyModel, {"inlineEnumProp": "a"}, MyModel(inline_enum_prop="a"))
174+
175+
def test_enum_prop_type(self, MyModel):
176+
assert MyModel.from_dict({"enumProp": "a"}).enum_prop.__class__ is str
177+
assert MyModel.from_dict({"intEnumProp": 2}).int_enum_prop.__class__ is int
178+
179+
def test_nullable_enum_prop(self, MyModel):
180+
assert_model_decode_encode(MyModel, {"nullableEnumProp": "B"}, MyModel(nullable_enum_prop="B"))
181+
assert_model_decode_encode(MyModel, {"nullableEnumProp": None}, MyModel(nullable_enum_prop=None))
182+
assert_model_decode_encode(MyModel, {"enumIncludingNullProp": "a"}, MyModel(enum_including_null_prop="a"))
183+
assert_model_decode_encode(MyModel, {"enumIncludingNullProp": None}, MyModel(enum_including_null_prop=None))
184+
assert_model_decode_encode(MyModel, {"nullOnlyEnumProp": None}, MyModel(null_only_enum_prop=None))
167185

168-
def test_enum_unsupported_type(self, warnings):
169-
assert_bad_schema_warning(warnings, "WithUnsupportedType", "Unsupported enum type")
186+
def test_invalid_values(self, MyModel):
187+
with pytest.raises(TypeError):
188+
MyModel.from_dict({"enumProp": "c"})
189+
with pytest.raises(TypeError):
190+
MyModel.from_dict({"enumProp": 2})
191+
with pytest.raises(TypeError):
192+
MyModel.from_dict({"intEnumProp": 0})
193+
with pytest.raises(TypeError):
194+
MyModel.from_dict({"intEnumProp": "a"})
170195

171-
def test_const_default_not_matching(self, warnings):
172-
assert_bad_schema_warning(warnings, "DefaultNotMatchingConst", "Invalid value for const")
173196

174-
def test_enum_duplicate_values(self):
175-
# This one currently causes a full generator failure rather than a warning
176-
result = inline_spec_should_fail(
197+
@with_generated_client_fixture(
177198
"""
178199
components:
179200
schemas:
180-
WithDuplicateValues:
181-
enum: ["x", "x"]
182-
"""
183-
)
184-
assert "Duplicate key X in enum" in str(result.exception)
201+
MyEnum:
202+
type: string
203+
enum: ["a", "A"]
204+
MyModel:
205+
properties:
206+
enumProp:
207+
allOf:
208+
- $ref: "#/components/schemas/MyEnum"
209+
default: A
210+
""",
211+
config="literal_enums: true",
212+
)
213+
@with_generated_code_import(".models.MyModel")
214+
class TestLiteralEnumDefaults:
215+
def test_default_value(self, MyModel):
216+
assert MyModel().enum_prop == "A"

0 commit comments

Comments
 (0)