Skip to content

Commit 0928032

Browse files
committed
add tests that verify actual behavior of generated code
1 parent 40d63f9 commit 0928032

File tree

6 files changed

+819
-98
lines changed

6 files changed

+819
-98
lines changed
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import importlib
2+
import os
3+
import shutil
4+
from filecmp import cmpfiles, dircmp
5+
from pathlib import Path
6+
import sys
7+
import tempfile
8+
from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple
9+
10+
from attrs import define
11+
import pytest
12+
from click.testing import Result
13+
from typer.testing import CliRunner
14+
15+
from openapi_python_client.cli import app
16+
from openapi_python_client.utils import snake_case
17+
18+
19+
@define
20+
class GeneratedClientContext:
21+
"""A context manager with helpers for tests that run against generated client code.
22+
23+
On entering this context, sys.path is changed to include the root directory of the
24+
generated code, so its modules can be imported. On exit, the original sys.path is
25+
restored, and any modules that were loaded within the context are removed.
26+
"""
27+
28+
output_path: Path
29+
generator_result: Result
30+
base_module: str
31+
monkeypatch: pytest.MonkeyPatch
32+
old_modules: Optional[Set[str]] = None
33+
34+
def __enter__(self) -> "GeneratedClientContext":
35+
self.monkeypatch.syspath_prepend(self.output_path)
36+
self.old_modules = set(sys.modules.keys())
37+
return self
38+
39+
def __exit__(self, exc_type, exc_value, traceback):
40+
self.monkeypatch.undo()
41+
for module_name in set(sys.modules.keys()) - self.old_modules:
42+
del sys.modules[module_name]
43+
shutil.rmtree(self.output_path, ignore_errors=True)
44+
45+
def import_module(self, module_path: str) -> Any:
46+
"""Attempt to import a module from the generated code."""
47+
return importlib.import_module(f"{self.base_module}{module_path}")
48+
49+
50+
def _run_command(
51+
command: str,
52+
extra_args: Optional[List[str]] = None,
53+
openapi_document: Optional[str] = None,
54+
url: Optional[str] = None,
55+
config_path: Optional[Path] = None,
56+
raise_on_error: bool = True,
57+
) -> Result:
58+
"""Generate a client from an OpenAPI document and return the result of the command."""
59+
runner = CliRunner()
60+
if openapi_document is not None:
61+
openapi_path = Path(__file__).parent / openapi_document
62+
source_arg = f"--path={openapi_path}"
63+
else:
64+
source_arg = f"--url={url}"
65+
config_path = config_path or (Path(__file__).parent / "config.yml")
66+
args = [command, f"--config={config_path}", source_arg]
67+
if extra_args:
68+
args.extend(extra_args)
69+
result = runner.invoke(app, args)
70+
if result.exit_code != 0 and raise_on_error:
71+
raise Exception(result.stdout)
72+
return result
73+
74+
75+
def generate_client(
76+
openapi_document: str,
77+
extra_args: List[str] = [],
78+
output_path: str = "my-test-api-client",
79+
base_module: str = "my_test_api_client",
80+
overwrite: bool = True,
81+
raise_on_error: bool = True,
82+
) -> GeneratedClientContext:
83+
"""Run the generator and return a GeneratedClientContext for accessing the generated code."""
84+
full_output_path = Path.cwd() / output_path
85+
if not overwrite:
86+
shutil.rmtree(full_output_path, ignore_errors=True)
87+
args = [
88+
*extra_args,
89+
"--output-path",
90+
str(full_output_path),
91+
]
92+
if overwrite:
93+
args = [*args, "--overwrite"]
94+
generator_result = _run_command("generate", args, openapi_document, raise_on_error=raise_on_error)
95+
print(generator_result.stdout)
96+
return GeneratedClientContext(
97+
full_output_path,
98+
generator_result,
99+
base_module,
100+
pytest.MonkeyPatch(),
101+
)
102+
103+
104+
def generate_client_from_inline_spec(
105+
openapi_spec: str,
106+
extra_args: List[str] = [],
107+
filename_suffix: Optional[str] = None,
108+
config: str = "",
109+
base_module: str = "testapi_client",
110+
add_openapi_info = True,
111+
raise_on_error: bool = True,
112+
) -> GeneratedClientContext:
113+
"""Run the generator on a temporary file created with the specified contents.
114+
115+
You can also optionally tell it to create a temporary config file.
116+
"""
117+
if add_openapi_info and not openapi_spec.lstrip().startswith("openapi:"):
118+
openapi_spec += """
119+
openapi: "3.1.0"
120+
info:
121+
title: "testapi"
122+
description: "my test api"
123+
version: "0.0.1"
124+
"""
125+
126+
output_path = tempfile.mkdtemp()
127+
file = tempfile.NamedTemporaryFile(suffix=filename_suffix, delete=False)
128+
file.write(openapi_spec.encode('utf-8'))
129+
file.close()
130+
131+
if config:
132+
config_file = tempfile.NamedTemporaryFile(delete=False)
133+
config_file.write(config.encode('utf-8'))
134+
config_file.close()
135+
extra_args = [*extra_args, "--config", config_file.name]
136+
137+
generated_client = generate_client(
138+
file.name,
139+
extra_args,
140+
output_path,
141+
base_module,
142+
raise_on_error=raise_on_error,
143+
)
144+
os.unlink(file.name)
145+
if config:
146+
os.unlink(config_file.name)
147+
148+
return generated_client
149+
150+
151+
def with_generated_client_fixture(
152+
openapi_spec: str,
153+
name: str="generated_client",
154+
config: str="",
155+
extra_args: List[str] = [],
156+
):
157+
"""Decorator to apply to a test class to create a fixture inside it called 'generated_client'.
158+
159+
The fixture value will be a GeneratedClientContext created by calling
160+
generate_client_from_inline_spec().
161+
"""
162+
def _decorator(cls):
163+
def generated_client(self):
164+
with generate_client_from_inline_spec(openapi_spec, extra_args=extra_args, config=config) as g:
165+
yield g
166+
167+
setattr(cls, name, pytest.fixture(scope="class")(generated_client))
168+
return cls
169+
170+
return _decorator
171+
172+
173+
def with_generated_code_import(import_path: str, alias: Optional[str] = None):
174+
"""Decorator to apply to a test class to create a fixture from a generated code import.
175+
176+
The 'generated_client' fixture must also be present.
177+
178+
If import_path is "a.b.c", then the fixture's value is equal to "from a.b import c", and
179+
its name is "c" unless you specify a different name with the alias parameter.
180+
"""
181+
parts = import_path.split(".")
182+
module_name = ".".join(parts[0:-1])
183+
import_name = parts[-1]
184+
185+
def _decorator(cls):
186+
nonlocal alias
187+
188+
def _func(self, generated_client):
189+
module = generated_client.import_module(module_name)
190+
return getattr(module, import_name)
191+
192+
alias = alias or import_name
193+
_func.__name__ = alias
194+
setattr(cls, alias, pytest.fixture(scope="class")(_func))
195+
return cls
196+
197+
return _decorator
198+
199+
200+
def assert_model_decode_encode(model_class: Any, json_data: dict, expected_instance: Any):
201+
instance = model_class.from_dict(json_data)
202+
assert instance == expected_instance
203+
assert instance.to_dict() == json_data
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
from typing import Any, List
2+
from end_to_end_tests.end_to_end_test_helpers import (
3+
with_generated_code_import,
4+
with_generated_client_fixture,
5+
)
6+
7+
8+
class DocstringParser:
9+
lines: List[str]
10+
11+
def __init__(self, item: Any):
12+
self.lines = [line.lstrip() for line in item.__doc__.split("\n")]
13+
14+
def get_section(self, header_line: str) -> List[str]:
15+
lines = self.lines[self.lines.index(header_line)+1:]
16+
return lines[0:lines.index("")]
17+
18+
19+
@with_generated_client_fixture(
20+
"""
21+
paths: {}
22+
components:
23+
schemas:
24+
MyModel:
25+
description: I like this type.
26+
type: object
27+
properties:
28+
reqStr:
29+
type: string
30+
description: This is necessary.
31+
optStr:
32+
type: string
33+
description: This isn't necessary.
34+
undescribedProp:
35+
type: string
36+
required: ["reqStr", "undescribedProp"]
37+
""")
38+
@with_generated_code_import(".models.MyModel")
39+
class TestSchemaDocstrings:
40+
def test_model_description(self, MyModel):
41+
assert DocstringParser(MyModel).lines[0] == "I like this type."
42+
43+
def test_model_properties(self, MyModel):
44+
assert set(DocstringParser(MyModel).get_section("Attributes:")) == {
45+
"req_str (str): This is necessary.",
46+
"opt_str (Union[Unset, str]): This isn't necessary.",
47+
"undescribed_prop (str):",
48+
}
49+
50+
51+
@with_generated_client_fixture(
52+
"""
53+
tags:
54+
- name: service1
55+
paths:
56+
"/simple":
57+
get:
58+
operationId: getSimpleThing
59+
description: Get a simple thing.
60+
responses:
61+
"200":
62+
description: Success!
63+
content:
64+
application/json:
65+
schema:
66+
$ref: "#/components/schemas/GoodResponse"
67+
tags:
68+
- service1
69+
post:
70+
operationId: postSimpleThing
71+
description: Post a simple thing.
72+
requestBody:
73+
content:
74+
application/json:
75+
schema:
76+
$ref: "#/components/schemas/Thing"
77+
responses:
78+
"200":
79+
description: Success!
80+
content:
81+
application/json:
82+
schema:
83+
$ref: "#/components/schemas/GoodResponse"
84+
"400":
85+
description: Failure!!
86+
content:
87+
application/json:
88+
schema:
89+
$ref: "#/components/schemas/ErrorResponse"
90+
tags:
91+
- service1
92+
"/simple/{id}/{index}":
93+
get:
94+
operationId: getAttributeByIndex
95+
description: Get a simple thing's attribute.
96+
parameters:
97+
- name: id
98+
in: path
99+
required: true
100+
schema:
101+
type: string
102+
description: Which one.
103+
- name: index
104+
in: path
105+
required: true
106+
schema:
107+
type: integer
108+
- name: fries
109+
in: query
110+
required: false
111+
schema:
112+
type: boolean
113+
description: Do you want fries with that?
114+
responses:
115+
"200":
116+
description: Success!
117+
content:
118+
application/json:
119+
schema:
120+
$ref: "#/components/schemas/GoodResponse"
121+
tags:
122+
- service1
123+
124+
components:
125+
schemas:
126+
GoodResponse:
127+
type: object
128+
ErrorResponse:
129+
type: object
130+
Thing:
131+
type: object
132+
description: The thing.
133+
""")
134+
@with_generated_code_import(".api.service1.get_simple_thing.sync", alias="get_simple_thing_sync")
135+
@with_generated_code_import(".api.service1.post_simple_thing.sync", alias="post_simple_thing_sync")
136+
@with_generated_code_import(".api.service1.get_attribute_by_index.sync", alias="get_attribute_by_index_sync")
137+
class TestEndpointDocstrings:
138+
def test_description(self, get_simple_thing_sync):
139+
assert DocstringParser(get_simple_thing_sync).lines[0] == "Get a simple thing."
140+
141+
def test_response_single_type(self, get_simple_thing_sync):
142+
assert DocstringParser(get_simple_thing_sync).get_section("Returns:") == [
143+
"GoodResponse",
144+
]
145+
146+
def test_response_union_type(self, post_simple_thing_sync):
147+
returns_line = DocstringParser(post_simple_thing_sync).get_section("Returns:")[0]
148+
assert returns_line in (
149+
"Union[GoodResponse, ErrorResponse]",
150+
"Union[ErrorResponse, GoodResponse]",
151+
)
152+
153+
def test_request_body(self, post_simple_thing_sync):
154+
assert DocstringParser(post_simple_thing_sync).get_section("Args:") == [
155+
"body (Thing): The thing."
156+
]
157+
158+
def test_params(self, get_attribute_by_index_sync):
159+
assert DocstringParser(get_attribute_by_index_sync).get_section("Args:") == [
160+
"id (str): Which one.",
161+
"index (int):",
162+
"fries (Union[Unset, bool]): Do you want fries with that?",
163+
]

0 commit comments

Comments
 (0)