forked from openapi-generators/openapi-python-client
-
Notifications
You must be signed in to change notification settings - Fork 1
add tests that verify actual behavior of generated code #216
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from 1 commit
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
0928032
add tests that verify actual behavior of generated code
eli-bl 90df669
readme
eli-bl c9a0909
Merge branch '2.x' into discriminator-live-tests
eli-bl 1aa1b51
Merge branch '2.x' into live-generated-code-tests-bl
eli-bl 60505ce
add generated code test for discriminators
eli-bl 73ed951
Merge branch 'live-generated-code-tests-bl' into discriminator-live-t…
eli-bl 69b8600
more tests
eli-bl 7a3a128
more tests + remove some old unit tests
eli-bl File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,203 @@ | ||
import importlib | ||
import os | ||
import shutil | ||
from filecmp import cmpfiles, dircmp | ||
from pathlib import Path | ||
import sys | ||
import tempfile | ||
from typing import Any, Callable, Dict, Generator, List, Optional, Set, Tuple | ||
|
||
from attrs import define | ||
import pytest | ||
from click.testing import Result | ||
from typer.testing import CliRunner | ||
|
||
from openapi_python_client.cli import app | ||
from openapi_python_client.utils import snake_case | ||
|
||
|
||
@define | ||
class GeneratedClientContext: | ||
"""A context manager with helpers for tests that run against generated client code. | ||
|
||
On entering this context, sys.path is changed to include the root directory of the | ||
generated code, so its modules can be imported. On exit, the original sys.path is | ||
restored, and any modules that were loaded within the context are removed. | ||
""" | ||
|
||
output_path: Path | ||
generator_result: Result | ||
base_module: str | ||
monkeypatch: pytest.MonkeyPatch | ||
old_modules: Optional[Set[str]] = None | ||
|
||
def __enter__(self) -> "GeneratedClientContext": | ||
self.monkeypatch.syspath_prepend(self.output_path) | ||
self.old_modules = set(sys.modules.keys()) | ||
return self | ||
|
||
def __exit__(self, exc_type, exc_value, traceback): | ||
self.monkeypatch.undo() | ||
for module_name in set(sys.modules.keys()) - self.old_modules: | ||
del sys.modules[module_name] | ||
shutil.rmtree(self.output_path, ignore_errors=True) | ||
|
||
def import_module(self, module_path: str) -> Any: | ||
"""Attempt to import a module from the generated code.""" | ||
return importlib.import_module(f"{self.base_module}{module_path}") | ||
|
||
|
||
def _run_command( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Moved from |
||
command: str, | ||
extra_args: Optional[List[str]] = None, | ||
openapi_document: Optional[str] = None, | ||
url: Optional[str] = None, | ||
config_path: Optional[Path] = None, | ||
raise_on_error: bool = True, | ||
) -> Result: | ||
"""Generate a client from an OpenAPI document and return the result of the command.""" | ||
runner = CliRunner() | ||
if openapi_document is not None: | ||
openapi_path = Path(__file__).parent / openapi_document | ||
source_arg = f"--path={openapi_path}" | ||
else: | ||
source_arg = f"--url={url}" | ||
config_path = config_path or (Path(__file__).parent / "config.yml") | ||
args = [command, f"--config={config_path}", source_arg] | ||
if extra_args: | ||
args.extend(extra_args) | ||
result = runner.invoke(app, args) | ||
if result.exit_code != 0 and raise_on_error: | ||
raise Exception(result.stdout) | ||
return result | ||
|
||
|
||
def generate_client( | ||
openapi_document: str, | ||
extra_args: List[str] = [], | ||
output_path: str = "my-test-api-client", | ||
base_module: str = "my_test_api_client", | ||
overwrite: bool = True, | ||
raise_on_error: bool = True, | ||
) -> GeneratedClientContext: | ||
"""Run the generator and return a GeneratedClientContext for accessing the generated code.""" | ||
full_output_path = Path.cwd() / output_path | ||
if not overwrite: | ||
shutil.rmtree(full_output_path, ignore_errors=True) | ||
args = [ | ||
*extra_args, | ||
"--output-path", | ||
str(full_output_path), | ||
] | ||
if overwrite: | ||
args = [*args, "--overwrite"] | ||
generator_result = _run_command("generate", args, openapi_document, raise_on_error=raise_on_error) | ||
print(generator_result.stdout) | ||
return GeneratedClientContext( | ||
full_output_path, | ||
generator_result, | ||
base_module, | ||
pytest.MonkeyPatch(), | ||
) | ||
|
||
|
||
def generate_client_from_inline_spec( | ||
openapi_spec: str, | ||
extra_args: List[str] = [], | ||
filename_suffix: Optional[str] = None, | ||
config: str = "", | ||
base_module: str = "testapi_client", | ||
add_openapi_info = True, | ||
raise_on_error: bool = True, | ||
) -> GeneratedClientContext: | ||
"""Run the generator on a temporary file created with the specified contents. | ||
|
||
You can also optionally tell it to create a temporary config file. | ||
""" | ||
if add_openapi_info and not openapi_spec.lstrip().startswith("openapi:"): | ||
openapi_spec += """ | ||
openapi: "3.1.0" | ||
info: | ||
title: "testapi" | ||
description: "my test api" | ||
version: "0.0.1" | ||
""" | ||
|
||
output_path = tempfile.mkdtemp() | ||
file = tempfile.NamedTemporaryFile(suffix=filename_suffix, delete=False) | ||
file.write(openapi_spec.encode('utf-8')) | ||
file.close() | ||
|
||
if config: | ||
config_file = tempfile.NamedTemporaryFile(delete=False) | ||
config_file.write(config.encode('utf-8')) | ||
config_file.close() | ||
extra_args = [*extra_args, "--config", config_file.name] | ||
|
||
generated_client = generate_client( | ||
file.name, | ||
extra_args, | ||
output_path, | ||
base_module, | ||
raise_on_error=raise_on_error, | ||
) | ||
os.unlink(file.name) | ||
if config: | ||
os.unlink(config_file.name) | ||
|
||
return generated_client | ||
|
||
|
||
def with_generated_client_fixture( | ||
openapi_spec: str, | ||
name: str="generated_client", | ||
config: str="", | ||
extra_args: List[str] = [], | ||
): | ||
"""Decorator to apply to a test class to create a fixture inside it called 'generated_client'. | ||
|
||
The fixture value will be a GeneratedClientContext created by calling | ||
generate_client_from_inline_spec(). | ||
""" | ||
def _decorator(cls): | ||
def generated_client(self): | ||
with generate_client_from_inline_spec(openapi_spec, extra_args=extra_args, config=config) as g: | ||
yield g | ||
|
||
setattr(cls, name, pytest.fixture(scope="class")(generated_client)) | ||
return cls | ||
|
||
return _decorator | ||
|
||
|
||
def with_generated_code_import(import_path: str, alias: Optional[str] = None): | ||
"""Decorator to apply to a test class to create a fixture from a generated code import. | ||
|
||
The 'generated_client' fixture must also be present. | ||
|
||
If import_path is "a.b.c", then the fixture's value is equal to "from a.b import c", and | ||
its name is "c" unless you specify a different name with the alias parameter. | ||
""" | ||
parts = import_path.split(".") | ||
module_name = ".".join(parts[0:-1]) | ||
import_name = parts[-1] | ||
|
||
def _decorator(cls): | ||
nonlocal alias | ||
|
||
def _func(self, generated_client): | ||
module = generated_client.import_module(module_name) | ||
return getattr(module, import_name) | ||
|
||
alias = alias or import_name | ||
_func.__name__ = alias | ||
setattr(cls, alias, pytest.fixture(scope="class")(_func)) | ||
return cls | ||
|
||
return _decorator | ||
|
||
|
||
def assert_model_decode_encode(model_class: Any, json_data: dict, expected_instance: Any): | ||
instance = model_class.from_dict(json_data) | ||
assert instance == expected_instance | ||
assert instance.to_dict() == json_data |
163 changes: 163 additions & 0 deletions
163
end_to_end_tests/generated_code_live_tests/test_docstrings.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,163 @@ | ||
from typing import Any, List | ||
from end_to_end_tests.end_to_end_test_helpers import ( | ||
with_generated_code_import, | ||
with_generated_client_fixture, | ||
) | ||
|
||
|
||
class DocstringParser: | ||
lines: List[str] | ||
|
||
def __init__(self, item: Any): | ||
self.lines = [line.lstrip() for line in item.__doc__.split("\n")] | ||
|
||
def get_section(self, header_line: str) -> List[str]: | ||
lines = self.lines[self.lines.index(header_line)+1:] | ||
return lines[0:lines.index("")] | ||
|
||
|
||
@with_generated_client_fixture( | ||
""" | ||
paths: {} | ||
components: | ||
schemas: | ||
MyModel: | ||
description: I like this type. | ||
type: object | ||
properties: | ||
reqStr: | ||
type: string | ||
description: This is necessary. | ||
optStr: | ||
type: string | ||
description: This isn't necessary. | ||
undescribedProp: | ||
type: string | ||
required: ["reqStr", "undescribedProp"] | ||
""") | ||
@with_generated_code_import(".models.MyModel") | ||
class TestSchemaDocstrings: | ||
def test_model_description(self, MyModel): | ||
assert DocstringParser(MyModel).lines[0] == "I like this type." | ||
|
||
def test_model_properties(self, MyModel): | ||
assert set(DocstringParser(MyModel).get_section("Attributes:")) == { | ||
"req_str (str): This is necessary.", | ||
"opt_str (Union[Unset, str]): This isn't necessary.", | ||
"undescribed_prop (str):", | ||
} | ||
|
||
|
||
@with_generated_client_fixture( | ||
""" | ||
tags: | ||
- name: service1 | ||
paths: | ||
"/simple": | ||
get: | ||
operationId: getSimpleThing | ||
description: Get a simple thing. | ||
responses: | ||
"200": | ||
description: Success! | ||
content: | ||
application/json: | ||
schema: | ||
$ref: "#/components/schemas/GoodResponse" | ||
tags: | ||
- service1 | ||
post: | ||
operationId: postSimpleThing | ||
description: Post a simple thing. | ||
requestBody: | ||
content: | ||
application/json: | ||
schema: | ||
$ref: "#/components/schemas/Thing" | ||
responses: | ||
"200": | ||
description: Success! | ||
content: | ||
application/json: | ||
schema: | ||
$ref: "#/components/schemas/GoodResponse" | ||
"400": | ||
description: Failure!! | ||
content: | ||
application/json: | ||
schema: | ||
$ref: "#/components/schemas/ErrorResponse" | ||
tags: | ||
- service1 | ||
"/simple/{id}/{index}": | ||
get: | ||
operationId: getAttributeByIndex | ||
description: Get a simple thing's attribute. | ||
parameters: | ||
- name: id | ||
in: path | ||
required: true | ||
schema: | ||
type: string | ||
description: Which one. | ||
- name: index | ||
in: path | ||
required: true | ||
schema: | ||
type: integer | ||
- name: fries | ||
in: query | ||
required: false | ||
schema: | ||
type: boolean | ||
description: Do you want fries with that? | ||
responses: | ||
"200": | ||
description: Success! | ||
content: | ||
application/json: | ||
schema: | ||
$ref: "#/components/schemas/GoodResponse" | ||
tags: | ||
- service1 | ||
|
||
components: | ||
schemas: | ||
GoodResponse: | ||
type: object | ||
ErrorResponse: | ||
type: object | ||
Thing: | ||
type: object | ||
description: The thing. | ||
""") | ||
@with_generated_code_import(".api.service1.get_simple_thing.sync", alias="get_simple_thing_sync") | ||
@with_generated_code_import(".api.service1.post_simple_thing.sync", alias="post_simple_thing_sync") | ||
@with_generated_code_import(".api.service1.get_attribute_by_index.sync", alias="get_attribute_by_index_sync") | ||
class TestEndpointDocstrings: | ||
def test_description(self, get_simple_thing_sync): | ||
assert DocstringParser(get_simple_thing_sync).lines[0] == "Get a simple thing." | ||
|
||
def test_response_single_type(self, get_simple_thing_sync): | ||
assert DocstringParser(get_simple_thing_sync).get_section("Returns:") == [ | ||
"GoodResponse", | ||
] | ||
|
||
def test_response_union_type(self, post_simple_thing_sync): | ||
returns_line = DocstringParser(post_simple_thing_sync).get_section("Returns:")[0] | ||
assert returns_line in ( | ||
"Union[GoodResponse, ErrorResponse]", | ||
"Union[ErrorResponse, GoodResponse]", | ||
) | ||
|
||
def test_request_body(self, post_simple_thing_sync): | ||
assert DocstringParser(post_simple_thing_sync).get_section("Args:") == [ | ||
"body (Thing): The thing." | ||
] | ||
|
||
def test_params(self, get_attribute_by_index_sync): | ||
assert DocstringParser(get_attribute_by_index_sync).get_section("Args:") == [ | ||
"id (str): Which one.", | ||
"index (int):", | ||
"fries (Union[Unset, bool]): Do you want fries with that?", | ||
] |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Btw, the proof that this works is simply that running all the tests in
generated_code_live_tests
in a single pytest run passes. Since many of the test classes reuse the same generated modules and class names for different things, if the sandboxing didn't work then the tests would inevitably pollute each other and fail.