From dada1d581f69f72b3cb6ffe8ad09de3700f6e01e Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 29 Jan 2026 20:58:53 +0100 Subject: [PATCH 01/20] Add tests for nullable query parameter with\without default --- .../test_query/test_nullable_and_defaults.py | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 tests/test_request_params/test_query/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py new file mode 100644 index 0000000000..bca69444d0 --- /dev/null +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -0,0 +1,480 @@ +from typing import Annotated, Any, Union +from unittest.mock import Mock, patch + +import pytest +from dirty_equals import IsList, IsOneOf +from fastapi import FastAPI, Query +from fastapi.testclient import TestClient +from pydantic import BaseModel, BeforeValidator, field_validator + +app = FastAPI() + + +def convert(v: Any) -> Any: + return v + + +# ===================================================================================== +# Nullable required + + +@app.get("/nullable-required") +async def read_nullable_required( + int_val: Annotated[ + Union[int, None], + BeforeValidator(lambda v: convert(v)), + ], + str_val: Annotated[ + Union[str, None], + BeforeValidator(lambda v: convert(v)), + ], + list_val: Annotated[ + Union[list[int], None], + Query(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + list_val: Union[list[int], None] + + @field_validator("*", mode="before") + @classmethod + def convert_all(cls, v: Any) -> Any: + return convert(v) + + +@app.get("/model-nullable-required") +async def read_model_nullable_required( + params: Annotated[ModelNullableRequired, Query()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int_val", + "in": "query", + }, + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str_val", + "in": "query", + }, + { + "in": "query", + "name": "list_val", + "required": True, + "schema": { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + }, + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["query", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["query", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with default=None + + +@app.get("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[ + Union[int, None], + BeforeValidator(lambda v: convert(v)), + ] = None, + str_val: Annotated[ + Union[str, None], + BeforeValidator(lambda v: convert(v)), + ] = None, + list_val: Annotated[ + Union[list[int], None], + Query(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + list_val: Union[list[int], None] = None + + @field_validator("*", mode="before") + @classmethod + def convert_all(cls, v: Any) -> Any: + return convert(v) + + +@app.get("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: Annotated[ModelNullableNonRequired, Query()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int_val", + "in": "query", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str_val", + "in": "query", + }, + { + "in": "query", + "name": "list_val", + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with not-None default + + +@app.get("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[ + Union[int, None], + BeforeValidator(lambda v: convert(v)), + ] = -1, + str_val: Annotated[ + Union[str, None], + BeforeValidator(lambda v: convert(v)), + ] = "default", + list_val: Annotated[ + Union[list[int], None], + Query(default_factory=lambda: [0]), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + list_val: Union[list[int], None] = [0] + + @field_validator("*", mode="before") + @classmethod + def convert_all(cls, v: Any) -> Any: + return convert(v) + + +@app.get("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: Annotated[ModelNullableWithNonNullDefault, Query()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int_val", + "in": "query", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str_val", + "in": "query", + }, + { + "in": "query", + "name": "list_val", + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + "default": [0], # `default_factory` is not reflected in OpenAPI schema + }, + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +@pytest.mark.xfail( + reason="Missing parameters are pre-populated with default values before validation" +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": -1, + "str_val": "default", + "list_val": [0], + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } From 22d795d8901917578056179ecd3987bbc727ee19 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 30 Jan 2026 18:07:42 +0100 Subject: [PATCH 02/20] Add tests for nullable Form parameter with\without default --- .../test_form/test_nullable_and_defaults.py | 594 ++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 tests/test_request_params/test_form/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py new file mode 100644 index 0000000000..644b058824 --- /dev/null +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -0,0 +1,594 @@ +from typing import Annotated, Any, Union +from unittest.mock import Mock, patch + +import pytest +from dirty_equals import IsList, IsOneOf +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient +from pydantic import BaseModel, BeforeValidator, field_validator + +from .utils import get_body_model_name + +app = FastAPI() + + +def convert(v: Any) -> Any: + return v + + +# ===================================================================================== +# Nullable required + + +@app.post("/nullable-required") +async def read_nullable_required( + int_val: Annotated[ + Union[int, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ], + str_val: Annotated[ + Union[str, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ], + list_val: Annotated[ + Union[list[int], None], + Form(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + list_val: Union[list[int], None] + + @field_validator("*", mode="before") + def convert_fields(cls, v): + return convert(v) + + +@app.post("/model-nullable-required") +async def read_model_nullable_required( + params: Annotated[ModelNullableRequired, Form()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + }, + "required": ["int_val", "str_val", "list_val"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="Empty str is replaced with None, but then None gets dropped" + ), + ), + pytest.param( + "/model-nullable-required", + marks=pytest.mark.xfail( + reason="Empty strings are not replaced with None for models" + ), + ), + ], +) +def test_nullable_required_pass_empty_str(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "", + "str_val": "", + "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + # TODO: Check call args ? + + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": [0], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with default=None + + +@app.post("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[ + Union[int, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ] = None, + str_val: Annotated[ + Union[str, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ] = None, + list_val: Annotated[ + Union[list[int], None], + Form(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + list_val: Union[list[int], None] = None + + @field_validator("*", mode="before") + def convert_fields(cls, v): + return convert(v) + + +@app.post("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: Annotated[ModelNullableNonRequired, Form()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-non-required", + marks=pytest.mark.xfail( + reason="Empty str is replaced with None, but then None gets dropped" + ), + ), + pytest.param( + "/model-nullable-non-required", + marks=pytest.mark.xfail( + reason="Empty strings are not replaced with None for models" + ), + ), + ], +) +def test_nullable_non_required_pass_empty_str(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "", + "str_val": "", + "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + # TODO: Check call args ? + + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": [0], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with not-None default + + +@app.post("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[ + Union[int, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ] = -1, + str_val: Annotated[ + Union[str, None], + Form(), + BeforeValidator(lambda v: convert(v)), + ] = "default", + list_val: Annotated[ + Union[list[int], None], + Form(default_factory=lambda: [0]), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + list_val: Union[list[int], None] = [0] + + @field_validator("*", mode="before") + def convert_fields(cls, v): + return convert(v) + + +@app.post("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: Annotated[ModelNullableWithNonNullDefault, Form()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "default": [0], # default_factory is not reflected in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +@pytest.mark.xfail( + reason="Missing parameters are pre-populated with default values before validation" +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": -1, + "str_val": "default", + "list_val": [0], + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Empty str is replaced with default value, not with None" # Is this correct ??? + ), + ), + pytest.param( + "/model-nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Empty strings are not replaced with None for models" + ), + ), + ], +) +def test_nullable_with_non_null_default_pass_empty_str(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "", + "str_val": "", + "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + # TODO: Check call args ? + + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": [0], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } From 0b5fea716b65ed1f402c3b1df7220a193f60ce8e Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 30 Jan 2026 18:08:30 +0100 Subject: [PATCH 03/20] Add notes about nullability and default for Path parameters --- .../test_request_params/test_path/test_nullable_and_defaults.py | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 tests/test_request_params/test_path/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_path/test_nullable_and_defaults.py b/tests/test_request_params/test_path/test_nullable_and_defaults.py new file mode 100644 index 0000000000..e065538222 --- /dev/null +++ b/tests/test_request_params/test_path/test_nullable_and_defaults.py @@ -0,0 +1,2 @@ +# Not appllicable for Path parameters +# Path parameters cannot have default values or be nullable From 2d4338262647c9f647dec37b9197e018c9993893 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 4 Feb 2026 17:12:24 +0100 Subject: [PATCH 04/20] Add tests for nullable Body parameter with\without default --- .../test_body/test_nullable_and_defaults.py | 927 ++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 tests/test_request_params/test_body/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py new file mode 100644 index 0000000000..0e032e8419 --- /dev/null +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -0,0 +1,927 @@ +from typing import Annotated, Any, Union + +import pytest +from dirty_equals import IsList, IsOneOf +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from .utils import get_body_model_name + +app = FastAPI() + + +# ===================================================================================== +# Nullable required + + +@app.post("/nullable-required") +async def read_nullable_required( + int_val: Annotated[Union[int, None], Body()], + str_val: Annotated[Union[str, None], Body()], + list_val: Union[list[int], None], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + list_val: Union[list[int], None] + + +@app.post("/model-nullable-required") +async def read_model_nullable_required(params: ModelNullableRequired): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-required-str") +async def read_nullable_required_no_embed_str( + str_val: Annotated[Union[str, None], Body()], +): + return {"val": str_val} + + +@app.post("/nullable-required-int") +async def read_nullable_required_no_embed_int( + int_val: Annotated[Union[int, None], Body()], +): + return {"val": int_val} + + +@app.post("/nullable-required-list") +async def read_nullable_required_no_embed_list( + list_val: Annotated[Union[list[int], None], Body()], +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + }, + "required": ["int_val", "str_val", "list_val"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-required-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + }, + ), + ( + "/nullable-required-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + }, + ), + ( + "/nullable-required-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + }, + ), + ], +) +def test_nullable_required_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert path_operation["requestBody"]["required"] is True + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, gives errors for each parameter separately" + ), + ), + "/model-nullable-required", + ], +) +def test_nullable_required_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-str", + "/nullable-required-int", + "/nullable-required-list", + ], +) +def test_nullable_required_no_embed_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + } + ] + } + + +@pytest.mark.parametrize( + ("path", "msg", "error_type"), + [ + ( + "/nullable-required-str", + "Input should be a valid string", + "string_type", + ), + ( + "/nullable-required-int", + "Input should be a valid integer", + "int_type", + ), + ( + "/nullable-required-list", + "Input should be a valid list", + "list_type", + ), + ], +) +def test_nullable_required_pass_empty_dict(path: str, msg: str, error_type: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "input": {}, + "loc": ["body"], + "msg": msg, + "type": error_type, + } + ] + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="Null values are treated as missing for non-model Body parameters" + ), + ), + pytest.param( + "/model-nullable-required", + ), + ], +) +def test_nullable_required_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-str", + "/nullable-required-int", + "/nullable-required-list", + ], +) +@pytest.mark.xfail(reason="Explicit null-body is treated as missing") +def test_nullable_required_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + # TODO: add test with BeforeValidator to ensure that it recieves `None` value + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-required-str", "test"), + ("/nullable-required-int", 1), + ("/nullable-required-list", [1, 2]), + ], +) +def test_nullable_required_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post( + path, + json=value, + ) + assert response.status_code == 200, response.text + assert response.json() == {"val": value} + + +# ===================================================================================== +# Nullable with default=None + + +@app.post("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[Union[int, None], Body()] = None, + str_val: Annotated[Union[str, None], Body()] = None, + list_val: Union[list[int], None] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + list_val: Union[list[int], None] = None + + +@app.post("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: ModelNullableNonRequired, +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-non-required-str") +async def read_nullable_non_required_no_embed_str( + str_val: Annotated[Union[str, None], Body()] = None, +): + return {"val": str_val} + + +@app.post("/nullable-non-required-int") +async def read_nullable_non_required_no_embed_int( + int_val: Annotated[Union[int, None], Body()] = None, +): + return {"val": int_val} + + +@app.post("/nullable-non-required-list") +async def read_nullable_non_required_no_embed_list( + list_val: Annotated[Union[list[int], None], Body()] = None, +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-non-required-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ( + "/nullable-non-required-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ( + "/nullable-non-required-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ], +) +def test_nullable_non_required_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert "required" not in path_operation["requestBody"] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-non-required", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, validates each parameter separately" + ), + ), + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-str", + "/nullable-non-required-int", + "/nullable-non-required-list", + ], +) +def test_nullable_non_required_no_embed_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"val": None} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-str", + "/nullable-non-required-int", + "/nullable-non-required-list", + ], +) +def test_nullable_non_required_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + # TODO: add test with BeforeValidator to ensure that it recieves `None` value + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": 1, "str_val": "test", "list_val": [1, 2]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-non-required-str", "test"), + ("/nullable-non-required-int", 1), + ("/nullable-non-required-list", [1, 2]), + ], +) +def test_nullable_non_required_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post(path, json=value) + assert response.status_code == 200, response.text + assert response.json() == {"val": value} + + +# ===================================================================================== +# Nullable with not-None default + + +@app.post("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[Union[int, None], Body()] = -1, + str_val: Annotated[Union[str, None], Body()] = "default", + list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + list_val: Union[list[int], None] = [0] + + +@app.post("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: ModelNullableWithNonNullDefault, +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-with-non-null-default-str") +async def read_nullable_with_non_null_default_no_embed_str( + str_val: Annotated[Union[str, None], Body()] = "default", +): + return {"val": str_val} + + +@app.post("/nullable-with-non-null-default-int") +async def read_nullable_with_non_null_default_no_embed_int( + int_val: Annotated[Union[int, None], Body()] = -1, +): + return {"val": int_val} + + +@app.post("/nullable-with-non-null-default-list") +async def read_nullable_with_non_null_default_no_embed_list( + list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "default": [0], # default_factory is not reflected in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-with-non-null-default-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + "default": "default", + }, + ), + ( + "/nullable-with-non-null-default-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + "default": -1, + }, + ), + pytest.param( + "/nullable-with-non-null-default-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + "default": [0], # default_factory is not reflected in OpenAPI schema + }, + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + ], +) +def test_nullable_with_non_null_default_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert "required" not in path_operation["requestBody"] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": -1, + "str_val": "default", + "list_val": [0], + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, validates each parameter separately" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/nullable-with-non-null-default-str", "default"), + ("/nullable-with-non-null-default-int", -1), + ("/nullable-with-non-null-default-list", [0]), + ], +) +def test_nullable_with_non_null_default_no_embed_missing(path: str, expected: Any): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200, response.text + assert response.json() == {"val": expected} + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Null values are treated as missing for non-model Body parameters" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default-str", + "/nullable-with-non-null-default-int", + "/nullable-with-non-null-default-list", + ], +) +@pytest.mark.xfail(reason="Explicit null-body is treated as missing") +def test_nullable_with_non_null_default_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-with-non-null-default-str", "test"), + ("/nullable-with-non-null-default-int", 1), + ("/nullable-with-non-null-default-list", [1, 2]), + ], +) +def test_nullable_with_non_null_default_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post(path, json=value) + assert response.status_code == 200, response.text + assert response.json() == {"val": value} From e6475e960a54a35cb5a2dfd568005ab163f78c5c Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 4 Feb 2026 21:42:47 +0100 Subject: [PATCH 05/20] Add tests for nullable Cookie parameters with\without default --- .../test_cookie/test_nullable_and_defaults.py | 401 ++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 tests/test_request_params/test_cookie/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py new file mode 100644 index 0000000000..468d167af0 --- /dev/null +++ b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py @@ -0,0 +1,401 @@ +from typing import Annotated, Any, Union +from unittest.mock import Mock, patch + +import pytest +from dirty_equals import IsList, IsOneOf +from fastapi import Cookie, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel, BeforeValidator, field_validator + +app = FastAPI() + + +def convert(v: Any) -> Any: + return v + + +# ===================================================================================== +# Nullable required + + +@app.get("/nullable-required") +async def read_nullable_required( + int_val: Annotated[ + Union[int, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ], + str_val: Annotated[ + Union[str, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-required") +async def read_model_nullable_required( + params: Annotated[ModelNullableRequired, Cookie()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int_val", + "in": "cookie", + }, + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str_val", + "in": "cookie", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["cookie", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + client.cookies.set("int_val", "1") + client.cookies.set("str_val", "test") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), + } + + +# ===================================================================================== +# Nullable with default=None + + +@app.get("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[ + Union[int, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ] = None, + str_val: Annotated[ + Union[str, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: Annotated[ModelNullableNonRequired, Cookie()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int_val", + "in": "cookie", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str_val", + "in": "cookie", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + client.cookies.set("int_val", "1") + client.cookies.set("str_val", "test") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), + } + + +# ===================================================================================== +# Nullable with not-None default + + +@app.get("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[ + Union[int, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ] = -1, + str_val: Annotated[ + Union[str, None], + Cookie(), + BeforeValidator(lambda v: convert(v)), + ] = "default", +): + return { + "int_val": int_val, + "str_val": str_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: Annotated[ModelNullableWithNonNullDefault, Cookie()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int_val", + "in": "cookie", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str_val", + "in": "cookie", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +@pytest.mark.xfail( + reason="Missing parameters are pre-populated with default values before validation" +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": -1, + "str_val": "default", + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + client.cookies.set("int_val", "1") + client.cookies.set("str_val", "test") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), + } From e1adc4a7398c318be1f16d7a73f8c390686c9469 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 08:47:14 +0100 Subject: [PATCH 06/20] Add tests for nullable Header parameter with\without default --- .../test_header/test_nullable_and_defaults.py | 518 ++++++++++++++++++ 1 file changed, 518 insertions(+) create mode 100644 tests/test_request_params/test_header/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py new file mode 100644 index 0000000000..c871bbaeea --- /dev/null +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -0,0 +1,518 @@ +from typing import Annotated, Any, Union +from unittest.mock import Mock, patch + +import pytest +from dirty_equals import AnyThing, IsList, IsOneOf +from fastapi import FastAPI, Header +from fastapi.testclient import TestClient +from pydantic import BaseModel, BeforeValidator, field_validator + +app = FastAPI() + + +def convert(v: Any) -> Any: + return v + + +# ===================================================================================== +# Nullable required + + +@app.get("/nullable-required") +async def read_nullable_required( + int_val: Annotated[ + Union[int, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ], + str_val: Annotated[ + Union[str, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ], + list_val: Annotated[ + Union[list[int], None], + Header(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + list_val: Union[list[int], None] + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-required") +async def read_model_nullable_required( + params: Annotated[ModelNullableRequired, Header()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="Title contains hyphens for single Header parameters" + ), + ), + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int-val", + "in": "header", + }, + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str-val", + "in": "header", + }, + { + "required": True, + "schema": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + "name": "list-val", + "in": "header", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + pytest.param( + "/model-nullable-required", + marks=pytest.mark.xfail( + reason="With Header model fields use underscores in error locs for headers" + ), + ), + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["header", "int-val"], + "msg": "Field required", + "input": AnyThing(), + }, + { + "type": "missing", + "loc": ["header", "str-val"], + "msg": "Field required", + "input": AnyThing(), + }, + { + "type": "missing", + "loc": ["header", "list-val"], + "msg": "Field required", + "input": AnyThing(), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", "test"), + ("list-val", "1"), + ("list-val", "2"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with default=None + + +@app.get("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[ + Union[int, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ] = None, + str_val: Annotated[ + Union[str, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ] = None, + list_val: Annotated[ + Union[list[int], None], + Header(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + list_val: Union[list[int], None] = None + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: Annotated[ModelNullableNonRequired, Header()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-non-required", + marks=pytest.mark.xfail( + reason="Title contains hyphens for single Header parameters" + ), + ), + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int-val", + "in": "header", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str-val", + "in": "header", + }, + { + "required": False, + "schema": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "list-val", + "in": "header", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", "test"), + ("list-val", "1"), + ("list-val", "2"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +# ===================================================================================== +# Nullable with not-None default + + +@app.get("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[ + Union[int, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ] = -1, + str_val: Annotated[ + Union[str, None], + Header(), + BeforeValidator(lambda v: convert(v)), + ] = "default", + list_val: Annotated[ + Union[list[int], None], + Header(default_factory=lambda: [0]), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + list_val: Union[list[int], None] = [0] + + @field_validator("*", mode="before") + @classmethod + def convert_fields(cls, v): + return convert(v) + + +@app.get("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: Annotated[ModelNullableWithNonNullDefault, Header()], +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Title contains hyphens for single Header parameters" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + assert app.openapi()["paths"][path]["get"]["parameters"] == [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int-val", + "in": "header", + }, + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str-val", + "in": "header", + }, + { + "required": False, + "schema": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "default": [0], + }, + "name": "list-val", + "in": "header", + }, + ] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +@pytest.mark.xfail( + reason="Missing parameters are pre-populated with default values before validation" +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "int_val": -1, + "str_val": "default", + "list_val": [0], + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", "test"), + ("list-val", "1"), + ("list-val", "2"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } From 3441e14197724b4f2ee8c08cbe0246c98498be50 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 09:08:03 +0100 Subject: [PATCH 07/20] Check call args for empty string with Form --- .../test_form/test_nullable_and_defaults.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 644b058824..2679da309a 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -175,8 +175,11 @@ def test_nullable_required_pass_empty_str(path: str): ) assert mock_convert.call_count == 3, "Validator should be called for each field" - # TODO: Check call args ? - + assert mock_convert.call_args_list == [ + (""), # int_val + (""), # str_val + (["0"]), # list_val + ] assert response.status_code == 200, response.text assert response.json() == { "int_val": None, @@ -360,8 +363,11 @@ def test_nullable_non_required_pass_empty_str(path: str): ) assert mock_convert.call_count == 3, "Validator should be called for each field" - # TODO: Check call args ? - + assert mock_convert.call_args_list == [ + (""), # int_val + (""), # str_val + (["0"]), # list_val + ] assert response.status_code == 200, response.text assert response.json() == { "int_val": None, @@ -554,8 +560,11 @@ def test_nullable_with_non_null_default_pass_empty_str(path: str): ) assert mock_convert.call_count == 3, "Validator should be called for each field" - # TODO: Check call args ? - + assert mock_convert.call_args_list == [ + (""), # int_val + (""), # str_val + (["0"]), # list_val + ] assert response.status_code == 200, response.text assert response.json() == { "int_val": None, From 9e85c19d3a0822662a5a1d658149242f693523d5 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 09:41:12 +0100 Subject: [PATCH 08/20] Add BeforeValidator to Body tests --- .../test_body/test_nullable_and_defaults.py | 280 +++++++++++++----- 1 file changed, 212 insertions(+), 68 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index 0e032e8419..7696f765fd 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -1,25 +1,34 @@ from typing import Annotated, Any, Union +from unittest.mock import Mock, patch import pytest from dirty_equals import IsList, IsOneOf from fastapi import Body, FastAPI from fastapi.testclient import TestClient -from pydantic import BaseModel +from pydantic import BaseModel, BeforeValidator, field_validator from .utils import get_body_model_name app = FastAPI() +def convert(v: Any) -> Any: + return v + + # ===================================================================================== # Nullable required @app.post("/nullable-required") async def read_nullable_required( - int_val: Annotated[Union[int, None], Body()], - str_val: Annotated[Union[str, None], Body()], - list_val: Union[list[int], None], + int_val: Annotated[Union[int, None], Body(), BeforeValidator(lambda v: convert(v))], + str_val: Annotated[Union[str, None], Body(), BeforeValidator(lambda v: convert(v))], + list_val: Annotated[ + Union[list[int], None], + Body(), + BeforeValidator(lambda v: convert(v)), + ], ): return { "int_val": int_val, @@ -34,6 +43,10 @@ class ModelNullableRequired(BaseModel): str_val: Union[str, None] list_val: Union[list[int], None] + @field_validator("*", mode="before") + def validate_all(cls, v): + return convert(v) + @app.post("/model-nullable-required") async def read_model_nullable_required(params: ModelNullableRequired): @@ -47,21 +60,23 @@ async def read_model_nullable_required(params: ModelNullableRequired): @app.post("/nullable-required-str") async def read_nullable_required_no_embed_str( - str_val: Annotated[Union[str, None], Body()], + str_val: Annotated[Union[str, None], Body(), BeforeValidator(lambda v: convert(v))], ): return {"val": str_val} @app.post("/nullable-required-int") async def read_nullable_required_no_embed_int( - int_val: Annotated[Union[int, None], Body()], + int_val: Annotated[Union[int, None], Body(), BeforeValidator(lambda v: convert(v))], ): return {"val": int_val} @app.post("/nullable-required-list") async def read_nullable_required_no_embed_list( - list_val: Annotated[Union[list[int], None], Body()], + list_val: Annotated[ + Union[list[int], None], Body(), BeforeValidator(lambda v: convert(v)) + ], ): return {"val": list_val} @@ -278,14 +293,18 @@ def test_nullable_required_pass_empty_dict(path: str, msg: str, error_type: str) ) def test_nullable_required_pass_null(path: str): client = TestClient(app) - response = client.post( - path, - json={ - "int_val": None, - "str_val": None, - "list_val": None, - }, - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": None, @@ -308,10 +327,13 @@ def test_nullable_required_pass_null(path: str): @pytest.mark.xfail(reason="Explicit null-body is treated as missing") def test_nullable_required_no_embed_pass_null(path: str): client = TestClient(app) - response = client.post(path, content="null") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, content="null") + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": None} - # TODO: add test with BeforeValidator to ensure that it recieves `None` value @pytest.mark.parametrize( @@ -323,9 +345,13 @@ def test_nullable_required_no_embed_pass_null(path: str): ) def test_nullable_required_pass_value(path: str): client = TestClient(app) - response = client.post( - path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": 1, @@ -347,10 +373,11 @@ def test_nullable_required_pass_value(path: str): ) def test_nullable_required_no_embed_pass_value(path: str, value: Any): client = TestClient(app) - response = client.post( - path, - json=value, - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, json=value) + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": value} @@ -361,9 +388,21 @@ def test_nullable_required_no_embed_pass_value(path: str, value: Any): @app.post("/nullable-non-required") async def read_nullable_non_required( - int_val: Annotated[Union[int, None], Body()] = None, - str_val: Annotated[Union[str, None], Body()] = None, - list_val: Union[list[int], None] = None, + int_val: Annotated[ + Union[int, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, + str_val: Annotated[ + Union[str, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, + list_val: Annotated[ + Union[list[int], None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, ): return { "int_val": int_val, @@ -378,6 +417,10 @@ class ModelNullableNonRequired(BaseModel): str_val: Union[str, None] = None list_val: Union[list[int], None] = None + @field_validator("*", mode="before") + def validate_all(cls, v): + return convert(v) + @app.post("/model-nullable-non-required") async def read_model_nullable_non_required( @@ -393,21 +436,33 @@ async def read_model_nullable_non_required( @app.post("/nullable-non-required-str") async def read_nullable_non_required_no_embed_str( - str_val: Annotated[Union[str, None], Body()] = None, + str_val: Annotated[ + Union[str, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, ): return {"val": str_val} @app.post("/nullable-non-required-int") async def read_nullable_non_required_no_embed_int( - int_val: Annotated[Union[int, None], Body()] = None, + int_val: Annotated[ + Union[int, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, ): return {"val": int_val} @app.post("/nullable-non-required-list") async def read_nullable_non_required_no_embed_list( - list_val: Annotated[Union[list[int], None], Body()] = None, + list_val: Annotated[ + Union[list[int], None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = None, ): return {"val": list_val} @@ -499,7 +554,13 @@ def test_nullable_non_required_no_embed_schema(path: str, schema: dict): ) def test_nullable_non_required_missing(path: str): client = TestClient(app) - response = client.post(path, json={}) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, json={}) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) assert response.status_code == 200 assert response.json() == { "int_val": None, @@ -547,7 +608,13 @@ def test_nullable_non_required_no_body(path: str): ) def test_nullable_non_required_no_embed_missing(path: str): client = TestClient(app) - response = client.post(path) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) assert response.status_code == 200 assert response.json() == {"val": None} @@ -555,20 +622,29 @@ def test_nullable_non_required_no_embed_missing(path: str): @pytest.mark.parametrize( "path", [ - "/nullable-non-required", + pytest.param( + "/nullable-non-required", + marks=pytest.mark.xfail( + reason="Null values are treated as missing for non-model Body parameters" + ), + ), "/model-nullable-non-required", ], ) def test_nullable_non_required_pass_null(path: str): client = TestClient(app) - response = client.post( - path, - json={ - "int_val": None, - "str_val": None, - "list_val": None, - }, - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": None, @@ -588,12 +664,16 @@ def test_nullable_non_required_pass_null(path: str): "/nullable-non-required-list", ], ) +@pytest.mark.xfail(reason="Explicit null-body is treated as missing") def test_nullable_non_required_no_embed_pass_null(path: str): client = TestClient(app) - response = client.post(path, content="null") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, content="null") + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": None} - # TODO: add test with BeforeValidator to ensure that it recieves `None` value @pytest.mark.parametrize( @@ -605,9 +685,13 @@ def test_nullable_non_required_no_embed_pass_null(path: str): ) def test_nullable_non_required_pass_value(path: str): client = TestClient(app) - response = client.post( - path, json={"int_val": 1, "str_val": "test", "list_val": [1, 2]} - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, json={"int_val": 1, "str_val": "test", "list_val": [1, 2]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": 1, @@ -629,7 +713,11 @@ def test_nullable_non_required_pass_value(path: str): ) def test_nullable_non_required_no_embed_pass_value(path: str, value: Any): client = TestClient(app) - response = client.post(path, json=value) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, json=value) + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": value} @@ -641,9 +729,21 @@ def test_nullable_non_required_no_embed_pass_value(path: str, value: Any): @app.post("/nullable-with-non-null-default") async def read_nullable_with_non_null_default( *, - int_val: Annotated[Union[int, None], Body()] = -1, - str_val: Annotated[Union[str, None], Body()] = "default", - list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], + int_val: Annotated[ + Union[int, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = -1, + str_val: Annotated[ + Union[str, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = "default", + list_val: Annotated[ + Union[list[int], None], + Body(default_factory=lambda: [0]), + BeforeValidator(lambda v: convert(v)), + ], ): return { "int_val": int_val, @@ -658,6 +758,10 @@ class ModelNullableWithNonNullDefault(BaseModel): str_val: Union[str, None] = "default" list_val: Union[list[int], None] = [0] + @field_validator("*", mode="before") + def validate_all(cls, v): + return convert(v) + @app.post("/model-nullable-with-non-null-default") async def read_model_nullable_with_non_null_default( @@ -673,21 +777,33 @@ async def read_model_nullable_with_non_null_default( @app.post("/nullable-with-non-null-default-str") async def read_nullable_with_non_null_default_no_embed_str( - str_val: Annotated[Union[str, None], Body()] = "default", + str_val: Annotated[ + Union[str, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = "default", ): return {"val": str_val} @app.post("/nullable-with-non-null-default-int") async def read_nullable_with_non_null_default_no_embed_int( - int_val: Annotated[Union[int, None], Body()] = -1, + int_val: Annotated[ + Union[int, None], + Body(), + BeforeValidator(lambda v: convert(v)), + ] = -1, ): return {"val": int_val} @app.post("/nullable-with-non-null-default-list") async def read_nullable_with_non_null_default_no_embed_list( - list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], + list_val: Annotated[ + Union[list[int], None], + Body(default_factory=lambda: [0]), + BeforeValidator(lambda v: convert(v)), + ], ): return {"val": list_val} @@ -787,7 +903,13 @@ def test_nullable_with_non_null_default_no_embed_schema(path: str, schema: dict) ) def test_nullable_with_non_null_default_missing(path: str): client = TestClient(app) - response = client.post(path, json={}) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, json={}) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) assert response.status_code == 200, response.text assert response.json() == { "int_val": -1, @@ -835,7 +957,13 @@ def test_nullable_with_non_null_default_no_body(path: str): ) def test_nullable_with_non_null_default_no_embed_missing(path: str, expected: Any): client = TestClient(app) - response = client.post(path) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) assert response.status_code == 200, response.text assert response.json() == {"val": expected} @@ -854,14 +982,18 @@ def test_nullable_with_non_null_default_no_embed_missing(path: str, expected: An ) def test_nullable_with_non_null_default_pass_null(path: str): client = TestClient(app) - response = client.post( - path, - json={ - "int_val": None, - "str_val": None, - "list_val": None, - }, - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": None, @@ -884,7 +1016,11 @@ def test_nullable_with_non_null_default_pass_null(path: str): @pytest.mark.xfail(reason="Explicit null-body is treated as missing") def test_nullable_with_non_null_default_no_embed_pass_null(path: str): client = TestClient(app) - response = client.post(path, content="null") + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, content="null") + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": None} @@ -898,9 +1034,13 @@ def test_nullable_with_non_null_default_no_embed_pass_null(path: str): ) def test_nullable_with_non_null_default_pass_value(path: str): client = TestClient(app) - response = client.post( - path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { "int_val": 1, @@ -922,6 +1062,10 @@ def test_nullable_with_non_null_default_pass_value(path: str): ) def test_nullable_with_non_null_default_no_embed_pass_value(path: str, value: Any): client = TestClient(app) - response = client.post(path, json=value) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path, json=value) + + assert mock_convert.call_count == 1, "Validator should be called once for the field" assert response.status_code == 200, response.text assert response.json() == {"val": value} From 27cc340880f1c2c62865ad3f379cd4fd1cfcb336 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 11:33:10 +0100 Subject: [PATCH 09/20] Add tests for nullable File parameter with\without default --- .../test_file/test_nullable_and_defaults.py | 480 ++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 tests/test_request_params/test_file/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py new file mode 100644 index 0000000000..5602aa6e53 --- /dev/null +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -0,0 +1,480 @@ +from typing import Annotated, Any, Union +from unittest.mock import Mock, patch + +import pytest +from dirty_equals import IsOneOf +from fastapi import FastAPI, File, UploadFile +from fastapi.testclient import TestClient +from pydantic import BeforeValidator +from starlette.datastructures import UploadFile as StarletteUploadFile + +from .utils import get_body_model_name + +app = FastAPI() + + +def convert(v: Any) -> Any: + return v + + +# ===================================================================================== +# Nullable required + + +@app.post("/nullable-required-bytes") +async def read_nullable_required_bytes( + file: Annotated[ + Union[bytes, None], + File(), + BeforeValidator(lambda v: convert(v)), + ], + files: Annotated[ + Union[list[bytes], None], + File(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "file": len(file) if file is not None else None, + "files": [len(f) for f in files] if files is not None else None, + } + + +@app.post("/nullable-required-uploadfile") +async def read_nullable_required_uploadfile( + file: Annotated[ + Union[UploadFile, None], + File(), + BeforeValidator(lambda v: convert(v)), + ], + files: Annotated[ + Union[list[UploadFile], None], + File(), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "file": file.size if file is not None else None, + "files": [f.size for f in files] if files is not None else None, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-bytes", + "/nullable-required-uploadfile", + ], +) +def test_nullable_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "file": { + "title": "File", + "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + }, + "files": { + "title": "Files", + "anyOf": [ + {"type": "array", "items": {"type": "string", "format": "binary"}}, + {"type": "null"}, + ], + }, + }, + "required": ["file", "files"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-bytes", + "/nullable-required-uploadfile", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-bytes", + "/nullable-required-uploadfile", + ], +) +def test_nullable_required_pass_empty_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[("file", b""), ("files", b""), ("files", b"")], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list] + file_call_arg_1 = call_args[0][0] + files_call_arg_1 = call_args[1][0] + + assert ( + (file_call_arg_1 == b"") # file as bytes + or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile + ) + assert ( + (files_call_arg_1 == [b"", b""]) # files as bytes + or all( # files as UploadFile + isinstance(f, StarletteUploadFile) for f in files_call_arg_1 + ) + ) + + assert response.status_code == 200, response.text + assert response.json() == { + "file": 0, + "files": [0, 0], + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-bytes", + "/nullable-required-uploadfile", + ], +) +def test_nullable_required_pass_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[ + ("file", b"test 1"), + ("files", b"test 2"), + ("files", b"test 3"), + ], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == {"file": 6, "files": [6, 6]} + + +# ===================================================================================== +# Nullable with default=None + + +@app.post("/nullable-non-required-bytes") +async def read_nullable_non_required_bytes( + file: Annotated[ + Union[bytes, None], + File(), + BeforeValidator(lambda v: convert(v)), + ] = None, + files: Annotated[ + Union[list[bytes], None], + File(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "file": len(file) if file is not None else None, + "files": [len(f) for f in files] if files is not None else None, + } + + +@app.post("/nullable-non-required-uploadfile") +async def read_nullable_non_required_uploadfile( + file: Annotated[ + Union[UploadFile, None], + File(), + BeforeValidator(lambda v: convert(v)), + ] = None, + files: Annotated[ + Union[list[UploadFile], None], + File(), + BeforeValidator(lambda v: convert(v)), + ] = None, +): + return { + "file": file.size if file is not None else None, + "files": [f.size for f in files] if files is not None else None, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-bytes", + "/nullable-non-required-uploadfile", + ], +) +def test_nullable_non_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "file": { + "title": "File", + "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "files": { + "title": "Files", + "anyOf": [ + {"type": "array", "items": {"type": "string", "format": "binary"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-bytes", + "/nullable-non-required-uploadfile", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == { + "file": None, + "files": None, + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-bytes", + "/nullable-non-required-uploadfile", + ], +) +def test_nullable_non_required_pass_empty_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[("file", b""), ("files", b""), ("files", b"")], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list] + file_call_arg_1 = call_args[0][0] + files_call_arg_1 = call_args[1][0] + + assert ( + (file_call_arg_1 == b"") # file as bytes + or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile + ) + assert ( + (files_call_arg_1 == [b"", b""]) # files as bytes + or all( # files as UploadFile + isinstance(f, StarletteUploadFile) for f in files_call_arg_1 + ) + ) + + assert response.status_code == 200, response.text + assert response.json() == {"file": 0, "files": [0, 0]} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-bytes", + "/nullable-non-required-uploadfile", + ], +) +def test_nullable_non_required_pass_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == {"file": 6, "files": [6, 6]} + + +# ===================================================================================== +# Nullable with not-None default + + +@app.post("/nullable-with-non-null-default-bytes") +async def read_nullable_with_non_null_default_bytes( + *, + file: Annotated[ + Union[bytes, None], + File(), + BeforeValidator(lambda v: convert(v)), + ] = b"default", + files: Annotated[ + Union[list[bytes], None], + File(default_factory=lambda: [b"default"]), + BeforeValidator(lambda v: convert(v)), + ], +): + return { + "file": len(file) if file is not None else None, + "files": [len(f) for f in files] if files is not None else None, + } + + +# Note: It seems to be not possible to create endpoint with UploadFile and non-None default + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default-bytes", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "file": { + "title": "File", + "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + "default": "default", # <= Default value for file looks strange to me + }, + "files": { + "title": "Files", + "anyOf": [ + {"type": "array", "items": {"type": "string", "format": "binary"}}, + {"type": "null"}, + ], + # "default": None, # default_factory is not reflected in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default-bytes", + marks=pytest.mark.xfail( + reason="AttributeError: 'bytes' object has no attribute 'read'", + ), + ), + ], +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post(path) + + assert mock_convert.call_count == 0, ( + "Validator should not be called if the value is missing" + ) + assert response.status_code == 200 + assert response.json() == {"file": None, "files": None} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default-bytes", + ], +) +def test_nullable_with_non_null_default_pass_empty_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[("file", b""), ("files", b""), ("files", b"")], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list] + file_call_arg_1 = call_args[0][0] + files_call_arg_1 = call_args[1][0] + + assert ( + (file_call_arg_1 == b"") # file as bytes + or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile + ) + assert ( + (files_call_arg_1 == [b"", b""]) # files as bytes + or all( # files as UploadFile + isinstance(f, StarletteUploadFile) for f in files_call_arg_1 + ) + ) + + assert response.status_code == 200, response.text + assert response.json() == {"file": 0, "files": [0, 0]} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default-bytes", + ], +) +def test_nullable_with_non_null_default_pass_file(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")], + ) + + assert mock_convert.call_count == 2, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == {"file": 6, "files": [6, 6]} From d90bcc8569bdcd94899de66520bdc509cdc24d9a Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 15:15:00 +0100 Subject: [PATCH 10/20] Remove xfail for defaul_factory in openapi --- .../test_body/test_nullable_and_defaults.py | 40 +++++++++---------- .../test_file/test_nullable_and_defaults.py | 1 - .../test_form/test_nullable_and_defaults.py | 34 ++++++++-------- .../test_header/test_nullable_and_defaults.py | 27 ++++++++----- .../test_query/test_nullable_and_defaults.py | 33 +++++++-------- 5 files changed, 71 insertions(+), 64 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index 7696f765fd..3f0afd5cad 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -2,7 +2,7 @@ from typing import Annotated, Any, Union from unittest.mock import Mock, patch import pytest -from dirty_equals import IsList, IsOneOf +from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import Body, FastAPI from fastapi.testclient import TestClient from pydantic import BaseModel, BeforeValidator, field_validator @@ -811,20 +811,16 @@ async def read_nullable_with_non_null_default_no_embed_list( @pytest.mark.parametrize( "path", [ - pytest.param( - "/nullable-with-non-null-default", - marks=pytest.mark.xfail( - reason="`default_factory` is not reflected in OpenAPI schema" - ), - ), + "/nullable-with-non-null-default", "/model-nullable-with-non-null-default", ], ) def test_nullable_with_non_null_default_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) + body_model = app.openapi()["components"]["schemas"][body_model_name] - assert app.openapi()["components"]["schemas"][body_model_name] == { + assert body_model == { "properties": { "int_val": { "title": "Int Val", @@ -836,19 +832,25 @@ def test_nullable_with_non_null_default_schema(path: str): "anyOf": [{"type": "string"}, {"type": "null"}], "default": "default", }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - "default": [0], # default_factory is not reflected in OpenAPI schema - }, + "list_val": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + ), }, "title": body_model_name, "type": "object", } + if path == "/model-nullable-with-non-null-default": + # Check default value for list_val param for model-based Body parameters only. + # default_factory is not reflected in OpenAPI schema + assert body_model["properties"]["list_val"]["default"] == [0] + @pytest.mark.parametrize( ("path", "schema"), @@ -869,7 +871,7 @@ def test_nullable_with_non_null_default_schema(path: str): "default": -1, }, ), - pytest.param( + ( "/nullable-with-non-null-default-list", { "anyOf": [ @@ -877,11 +879,7 @@ def test_nullable_with_non_null_default_schema(path: str): {"type": "null"}, ], "title": "List Val", - "default": [0], # default_factory is not reflected in OpenAPI schema }, - marks=pytest.mark.xfail( - reason="`default_factory` is not reflected in OpenAPI schema" - ), ), ], ) diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 5602aa6e53..ce862dc465 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -393,7 +393,6 @@ def test_nullable_with_non_null_default_schema(path: str): {"type": "array", "items": {"type": "string", "format": "binary"}}, {"type": "null"}, ], - # "default": None, # default_factory is not reflected in OpenAPI schema }, }, "title": body_model_name, diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 2679da309a..6b9ac4c300 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -2,7 +2,7 @@ from typing import Annotated, Any, Union from unittest.mock import Mock, patch import pytest -from dirty_equals import IsList, IsOneOf +from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Form from fastapi.testclient import TestClient from pydantic import BaseModel, BeforeValidator, field_validator @@ -462,20 +462,16 @@ async def read_model_nullable_with_non_null_default( @pytest.mark.parametrize( "path", [ - pytest.param( - "/nullable-with-non-null-default", - marks=pytest.mark.xfail( - reason="`default_factory` is not reflected in OpenAPI schema" - ), - ), + "/nullable-with-non-null-default", "/model-nullable-with-non-null-default", ], ) def test_nullable_with_non_null_default_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) + body_model = app.openapi()["components"]["schemas"][body_model_name] - assert app.openapi()["components"]["schemas"][body_model_name] == { + assert body_model == { "properties": { "int_val": { "title": "Int Val", @@ -487,19 +483,25 @@ def test_nullable_with_non_null_default_schema(path: str): "anyOf": [{"type": "string"}, {"type": "null"}], "default": "default", }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - "default": [0], # default_factory is not reflected in OpenAPI schema - }, + "list_val": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + } + ), }, "title": body_model_name, "type": "object", } + if path == "/model-nullable-with-non-null-default": + # Check default value for list_val param for model-based Body parameters only. + # default_factory is not reflected in OpenAPI schema + assert body_model["properties"]["list_val"]["default"] == [0] + @pytest.mark.parametrize( "path", diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index c871bbaeea..6cb555f374 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -2,7 +2,7 @@ from typing import Annotated, Any, Union from unittest.mock import Mock, patch import pytest -from dirty_equals import AnyThing, IsList, IsOneOf +from dirty_equals import AnyThing, IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Header from fastapi.testclient import TestClient from pydantic import BaseModel, BeforeValidator, field_validator @@ -420,7 +420,8 @@ async def read_model_nullable_with_non_null_default( ], ) def test_nullable_with_non_null_default_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ + parameters = app.openapi()["paths"][path]["get"]["parameters"] + assert parameters == [ { "required": False, "schema": { @@ -443,19 +444,25 @@ def test_nullable_with_non_null_default_schema(path: str): }, { "required": False, - "schema": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - "default": [0], - }, + "schema": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + } + ), "name": "list-val", "in": "header", }, ] + if path == "/model-nullable-with-non-null-default": + # Check default value for list_val param for model-based Body parameters only. + # default_factory is not reflected in OpenAPI schema + assert parameters[2]["schema"]["default"] == [0] + @pytest.mark.parametrize( "path", diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index bca69444d0..f329e08886 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -2,7 +2,7 @@ from typing import Annotated, Any, Union from unittest.mock import Mock, patch import pytest -from dirty_equals import IsList, IsOneOf +from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Query from fastapi.testclient import TestClient from pydantic import BaseModel, BeforeValidator, field_validator @@ -378,17 +378,13 @@ async def read_model_nullable_with_non_null_default( @pytest.mark.parametrize( "path", [ - pytest.param( - "/nullable-with-non-null-default", - marks=pytest.mark.xfail( - reason="`default_factory` is not reflected in OpenAPI schema" - ), - ), + "/nullable-with-non-null-default", "/model-nullable-with-non-null-default", ], ) def test_nullable_with_non_null_default_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ + parameters = app.openapi()["paths"][path]["get"]["parameters"] + assert parameters == [ { "required": False, "schema": { @@ -413,17 +409,22 @@ def test_nullable_with_non_null_default_schema(path: str): "in": "query", "name": "list_val", "required": False, - "schema": { - "anyOf": [ - {"items": {"type": "integer"}, "type": "array"}, - {"type": "null"}, - ], - "title": "List Val", - "default": [0], # `default_factory` is not reflected in OpenAPI schema - }, + "schema": IsPartialDict( + { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + } + ), }, ] + if path == "/model-nullable-with-non-null-default": + # Check default value for list_val param for model-based Body parameters only. + # default_factory is not reflected in OpenAPI schema + assert parameters[2]["schema"]["default"] == [0] @pytest.mark.parametrize( "path", From 7fed2671c4c8e3651911df05d36b4a93d3a4f294 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Thu, 5 Feb 2026 15:38:23 +0100 Subject: [PATCH 11/20] Add `pragma: no cover` to make coverage pass --- .../test_body/test_nullable_and_defaults.py | 15 ++++++++------ .../test_cookie/test_nullable_and_defaults.py | 5 +++-- .../test_file/test_nullable_and_defaults.py | 7 ++++--- .../test_form/test_nullable_and_defaults.py | 20 +++++++++++-------- .../test_header/test_nullable_and_defaults.py | 5 +++-- .../test_query/test_nullable_and_defaults.py | 6 ++++-- 6 files changed, 35 insertions(+), 23 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index 3f0afd5cad..13d2df4796 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -332,8 +332,9 @@ def test_nullable_required_no_embed_pass_null(path: str): response = client.post(path, content="null") assert mock_convert.call_count == 1, "Validator should be called once for the field" - assert response.status_code == 200, response.text - assert response.json() == {"val": None} + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == {"val": None} # pragma: no cover + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -672,8 +673,9 @@ def test_nullable_non_required_no_embed_pass_null(path: str): response = client.post(path, content="null") assert mock_convert.call_count == 1, "Validator should be called once for the field" - assert response.status_code == 200, response.text - assert response.json() == {"val": None} + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == {"val": None} # pragma: no cover + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -1019,8 +1021,9 @@ def test_nullable_with_non_null_default_no_embed_pass_null(path: str): response = client.post(path, content="null") assert mock_convert.call_count == 1, "Validator should be called once for the field" - assert response.status_code == 200, response.text - assert response.json() == {"val": None} + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == {"val": None} # pragma: no cover + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py index 468d167af0..88fa4f78d3 100644 --- a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py @@ -369,12 +369,13 @@ def test_nullable_with_non_null_default_missing(path: str): assert mock_convert.call_count == 0, ( "Validator should not be called if the value is missing" ) - assert response.status_code == 200 - assert response.json() == { + assert response.status_code == 200 # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": -1, "str_val": "default", "fields_set": IsOneOf(None, []), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index ce862dc465..4c34889e52 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -417,11 +417,12 @@ def test_nullable_with_non_null_default_missing(path: str): with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: response = client.post(path) - assert mock_convert.call_count == 0, ( + assert mock_convert.call_count == 0, ( # pragma: no cover "Validator should not be called if the value is missing" ) - assert response.status_code == 200 - assert response.json() == {"file": None, "files": None} + assert response.status_code == 200 # pragma: no cover + assert response.json() == {"file": None, "files": None} # pragma: no cover + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 6b9ac4c300..d8147ca790 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -180,8 +180,8 @@ def test_nullable_required_pass_empty_str(path: str): (""), # str_val (["0"]), # list_val ] - assert response.status_code == 200, response.text - assert response.json() == { + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": None, "str_val": None, "list_val": [0], @@ -189,6 +189,7 @@ def test_nullable_required_pass_empty_str(path: str): None, IsList("int_val", "str_val", "list_val", check_order=False) ), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -368,8 +369,8 @@ def test_nullable_non_required_pass_empty_str(path: str): (""), # str_val (["0"]), # list_val ] - assert response.status_code == 200, response.text - assert response.json() == { + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": None, "str_val": None, "list_val": [0], @@ -377,6 +378,7 @@ def test_nullable_non_required_pass_empty_str(path: str): None, IsList("int_val", "str_val", "list_val", check_order=False) ), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -522,13 +524,14 @@ def test_nullable_with_non_null_default_missing(path: str): assert mock_convert.call_count == 0, ( "Validator should not be called if the value is missing" ) - assert response.status_code == 200 - assert response.json() == { + assert response.status_code == 200 # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": -1, "str_val": "default", "list_val": [0], "fields_set": IsOneOf(None, []), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -567,8 +570,8 @@ def test_nullable_with_non_null_default_pass_empty_str(path: str): (""), # str_val (["0"]), # list_val ] - assert response.status_code == 200, response.text - assert response.json() == { + assert response.status_code == 200, response.text # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": None, "str_val": None, "list_val": [0], @@ -576,6 +579,7 @@ def test_nullable_with_non_null_default_pass_empty_str(path: str): None, IsList("int_val", "str_val", "list_val", check_order=False) ), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index 6cb555f374..5aaae8592a 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -483,13 +483,14 @@ def test_nullable_with_non_null_default_missing(path: str): assert mock_convert.call_count == 0, ( "Validator should not be called if the value is missing" ) - assert response.status_code == 200 - assert response.json() == { + assert response.status_code == 200 # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": -1, "str_val": "default", "list_val": [0], "fields_set": IsOneOf(None, []), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index f329e08886..e72bc2d629 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -426,6 +426,7 @@ def test_nullable_with_non_null_default_schema(path: str): # default_factory is not reflected in OpenAPI schema assert parameters[2]["schema"]["default"] == [0] + @pytest.mark.parametrize( "path", [ @@ -445,13 +446,14 @@ def test_nullable_with_non_null_default_missing(path: str): assert mock_convert.call_count == 0, ( "Validator should not be called if the value is missing" ) - assert response.status_code == 200 - assert response.json() == { + assert response.status_code == 200 # pragma: no cover + assert response.json() == { # pragma: no cover "int_val": -1, "str_val": "default", "list_val": [0], "fields_set": IsOneOf(None, []), } + # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( From cf0d31bd699f4b5b6ecf61cae2eeee1a89845fc0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 6 Feb 2026 14:47:26 +0100 Subject: [PATCH 12/20] Fix test name --- .../test_request_params/test_body/test_nullable_and_defaults.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index 13d2df4796..b4d3880eee 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -261,7 +261,7 @@ def test_nullable_required_no_embed_missing(path: str): ), ], ) -def test_nullable_required_pass_empty_dict(path: str, msg: str, error_type: str): +def test_nullable_required_no_embed_pass_empty_dict(path: str, msg: str, error_type: str): client = TestClient(app) response = client.post(path, json={}) assert response.status_code == 422 From 2a2aafa01ed7a72cbbd53213ff146891cbdd05cf Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 6 Feb 2026 16:27:25 +0100 Subject: [PATCH 13/20] Update tests for passing empty str to Form --- .../test_form/test_nullable_and_defaults.py | 230 ++++++++++++++---- 1 file changed, 176 insertions(+), 54 deletions(-) diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index d8147ca790..b2056e65ce 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -1,5 +1,5 @@ from typing import Annotated, Any, Union -from unittest.mock import Mock, patch +from unittest.mock import Mock, call, patch import pytest from dirty_equals import IsList, IsOneOf, IsPartialDict @@ -150,18 +150,55 @@ def test_nullable_required_missing(path: str): pytest.param( "/nullable-required", marks=pytest.mark.xfail( - reason="Empty str is replaced with None, but then None gets dropped" - ), - ), - pytest.param( - "/model-nullable-required", - marks=pytest.mark.xfail( - reason="Empty strings are not replaced with None for models" + reason="Empty str is replaced with None even for required parameters" ), ), + "/model-nullable-required", ], ) -def test_nullable_required_pass_empty_str(path: str): +def test_nullable_required_pass_empty_str_to_str_val(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "0", # Empty string would cause validation error (see below) + "str_val": "", + "list_val": "0", # Empty string would cause validation error (see below) + }, + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert mock_convert.call_args_list == [ + call("0"), # int_val + call(""), # str_val + call(["0"]), # list_val + ] + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 0, + "str_val": "", + "list_val": [0], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="Empty str is replaced with None even for required parameters" + ), + ), + "/model-nullable-required", + ], +) +def test_nullable_required_pass_empty_str_to_int_val_and_list_val(path: str): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: @@ -170,26 +207,33 @@ def test_nullable_required_pass_empty_str(path: str): data={ "int_val": "", "str_val": "", - "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + "list_val": "", }, ) assert mock_convert.call_count == 3, "Validator should be called for each field" assert mock_convert.call_args_list == [ - (""), # int_val - (""), # str_val - (["0"]), # list_val + call(""), # int_val + call(""), # str_val + call([""]), # list_val ] - assert response.status_code == 200, response.text # pragma: no cover - assert response.json() == { # pragma: no cover - "int_val": None, - "str_val": None, - "list_val": [0], - "fields_set": IsOneOf( - None, IsList("int_val", "str_val", "list_val", check_order=False) - ), + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "input": "", + "loc": ["body", "int_val"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] } - # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -336,21 +380,16 @@ def test_nullable_non_required_missing(path: str): @pytest.mark.parametrize( "path", [ - pytest.param( - "/nullable-non-required", - marks=pytest.mark.xfail( - reason="Empty str is replaced with None, but then None gets dropped" - ), - ), + "/nullable-non-required", pytest.param( "/model-nullable-non-required", marks=pytest.mark.xfail( - reason="Empty strings are not replaced with None for models" + reason="Empty strings are not replaced with None for parameters declared as model" ), ), ], ) -def test_nullable_non_required_pass_empty_str(path: str): +def test_nullable_non_required_pass_empty_str_to_str_val_and_int_val(path: str): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: @@ -359,26 +398,63 @@ def test_nullable_non_required_pass_empty_str(path: str): data={ "int_val": "", "str_val": "", - "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + "list_val": "0", # Empty string would cause validation error (see below) }, ) - assert mock_convert.call_count == 3, "Validator should be called for each field" + assert mock_convert.call_count == 1, "Validator should be called for list_val only" assert mock_convert.call_args_list == [ - (""), # int_val - (""), # str_val - (["0"]), # list_val + call(["0"]), # list_val ] - assert response.status_code == 200, response.text # pragma: no cover - assert response.json() == { # pragma: no cover + assert response.status_code == 200, response.text + assert response.json() == { "int_val": None, "str_val": None, "list_val": [0], - "fields_set": IsOneOf( - None, IsList("int_val", "str_val", "list_val", check_order=False) - ), + "fields_set": IsOneOf(None, IsList("list_val", check_order=False)), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + pytest.param( + "/model-nullable-non-required", + marks=pytest.mark.xfail( + reason="Empty strings are not replaced with None for parameters declared as model" + ), + ), + ], +) +def test_nullable_non_required_pass_empty_str_to_all(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "", + "str_val": "", + "list_val": "", + }, + ) + + assert mock_convert.call_count == 1, "Validator should be called for list_val only" + assert mock_convert.call_args_list == [ + call([""]), # list_val + ] + assert response.status_code == 422, response.text + assert response.json() == { + "detail": [ + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] } - # TODO: Remove 'no cover' when the issue is fixed @pytest.mark.parametrize( @@ -540,18 +616,20 @@ def test_nullable_with_non_null_default_missing(path: str): pytest.param( "/nullable-with-non-null-default", marks=pytest.mark.xfail( - reason="Empty str is replaced with default value, not with None" # Is this correct ??? + reason="Empty strings are replaced with default values before validation" ), ), pytest.param( "/model-nullable-with-non-null-default", marks=pytest.mark.xfail( - reason="Empty strings are not replaced with None for models" + reason="Empty strings are not replaced with None for parameters declared as model" ), ), ], ) -def test_nullable_with_non_null_default_pass_empty_str(path: str): +def test_nullable_with_non_null_default_pass_empty_str_to_str_val_and_int_val( + path: str, +): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: @@ -560,24 +638,68 @@ def test_nullable_with_non_null_default_pass_empty_str(path: str): data={ "int_val": "", "str_val": "", - "list_val": "0", # Empty strings are not treated as null for lists. It's Ok + "list_val": "0", # Empty string would cause validation error (see below) }, ) - assert mock_convert.call_count == 3, "Validator should be called for each field" - assert mock_convert.call_args_list == [ - (""), # int_val - (""), # str_val - (["0"]), # list_val + assert mock_convert.call_count == 1, "Validator should be called for list_val only" + assert mock_convert.call_args_list == [ # pragma: no cover + call(["0"]), # list_val ] assert response.status_code == 200, response.text # pragma: no cover assert response.json() == { # pragma: no cover - "int_val": None, - "str_val": None, + "int_val": -1, + "str_val": "default", "list_val": [0], - "fields_set": IsOneOf( - None, IsList("int_val", "str_val", "list_val", check_order=False) + "fields_set": IsOneOf(None, IsList("list_val", check_order=False)), + } + # TODO: Remove 'no cover' when the issue is fixed + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Empty strings are replaced with default values before validation" + ), ), + pytest.param( + "/model-nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Empty strings are not replaced with None for parameters declared as model" + ), + ), + ], +) +def test_nullable_with_non_null_default_pass_empty_str_to_all(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.post( + path, + data={ + "int_val": "", + "str_val": "", + "list_val": "", + }, + ) + + assert mock_convert.call_count == 1, "Validator should be called for list_val only" + assert mock_convert.call_args_list == [ # pragma: no cover + call([""]), # list_val + ] + assert response.status_code == 422, response.text # pragma: no cover + assert response.json() == { # pragma: no cover + "detail": [ + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] } # TODO: Remove 'no cover' when the issue is fixed From bfc09d944055c4abf870f939e836fee11e502298 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 6 Feb 2026 16:33:45 +0100 Subject: [PATCH 14/20] Update xfail reason msg --- .../test_header/test_nullable_and_defaults.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index 5aaae8592a..2196ec46b8 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -121,7 +121,10 @@ def test_nullable_required_schema(path: str): pytest.param( "/model-nullable-required", marks=pytest.mark.xfail( - reason="With Header model fields use underscores in error locs for headers" + reason=( + "For parameters declared as model, underscores are not replaced " + "with hyphens in error loc" + ) ), ), ], From c733bab8258f720f36333343ee25ec2c807403d0 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 6 Feb 2026 16:35:39 +0100 Subject: [PATCH 15/20] Fix comment for different parameter types --- .../test_request_params/test_body/test_nullable_and_defaults.py | 2 +- .../test_request_params/test_form/test_nullable_and_defaults.py | 2 +- .../test_header/test_nullable_and_defaults.py | 2 +- .../test_query/test_nullable_and_defaults.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index b4d3880eee..ba1a39b204 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -849,7 +849,7 @@ def test_nullable_with_non_null_default_schema(path: str): } if path == "/model-nullable-with-non-null-default": - # Check default value for list_val param for model-based Body parameters only. + # Check default value for list_val param for model-based parameters only. # default_factory is not reflected in OpenAPI schema assert body_model["properties"]["list_val"]["default"] == [0] diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index b2056e65ce..558b288333 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -576,7 +576,7 @@ def test_nullable_with_non_null_default_schema(path: str): } if path == "/model-nullable-with-non-null-default": - # Check default value for list_val param for model-based Body parameters only. + # Check default value for list_val param for model-based parameters only. # default_factory is not reflected in OpenAPI schema assert body_model["properties"]["list_val"]["default"] == [0] diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index 2196ec46b8..4236d5fa9e 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -462,7 +462,7 @@ def test_nullable_with_non_null_default_schema(path: str): ] if path == "/model-nullable-with-non-null-default": - # Check default value for list_val param for model-based Body parameters only. + # Check default value for list_val param for model-based parameters only. # default_factory is not reflected in OpenAPI schema assert parameters[2]["schema"]["default"] == [0] diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index e72bc2d629..577e969f37 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -422,7 +422,7 @@ def test_nullable_with_non_null_default_schema(path: str): ] if path == "/model-nullable-with-non-null-default": - # Check default value for list_val param for model-based Body parameters only. + # Check default value for list_val param for model-based parameters only. # default_factory is not reflected in OpenAPI schema assert parameters[2]["schema"]["default"] == [0] From f49f65aa1619efbfbdbb788a5ad97917c273e140 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Fri, 6 Feb 2026 16:59:18 +0100 Subject: [PATCH 16/20] Add tests with passing empty string value --- .../test_body/test_nullable_and_defaults.py | 64 ++++++++----- .../test_cookie/test_nullable_and_defaults.py | 52 ++++++---- .../test_header/test_nullable_and_defaults.py | 96 +++++++++++++++++++ .../test_query/test_nullable_and_defaults.py | 57 +++++++---- 4 files changed, 210 insertions(+), 59 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index ba1a39b204..e78811b222 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -261,7 +261,9 @@ def test_nullable_required_no_embed_missing(path: str): ), ], ) -def test_nullable_required_no_embed_pass_empty_dict(path: str, msg: str, error_type: str): +def test_nullable_required_no_embed_pass_empty_dict( + path: str, msg: str, error_type: str +): client = TestClient(app) response = client.post(path, json={}) assert response.status_code == 422 @@ -344,20 +346,25 @@ def test_nullable_required_no_embed_pass_null(path: str): "/model-nullable-required", ], ) -def test_nullable_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_required_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.post( - path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + response = client.post(path, json=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), @@ -368,6 +375,7 @@ def test_nullable_required_pass_value(path: str): ("path", "value"), [ ("/nullable-required-str", "test"), + ("/nullable-required-str", ""), ("/nullable-required-int", 1), ("/nullable-required-list", [1, 2]), ], @@ -685,20 +693,25 @@ def test_nullable_non_required_no_embed_pass_null(path: str): "/model-nullable-non-required", ], ) -def test_nullable_non_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_non_required_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.post( - path, json={"int_val": 1, "str_val": "test", "list_val": [1, 2]} - ) + response = client.post(path, json=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), @@ -709,6 +722,7 @@ def test_nullable_non_required_pass_value(path: str): ("path", "value"), [ ("/nullable-non-required-str", "test"), + ("/nullable-non-required-str", ""), ("/nullable-non-required-int", 1), ("/nullable-non-required-list", [1, 2]), ], @@ -1033,20 +1047,25 @@ def test_nullable_with_non_null_default_no_embed_pass_null(path: str): "/model-nullable-with-non-null-default", ], ) -def test_nullable_with_non_null_default_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.post( - path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + response = client.post(path, json=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), @@ -1057,6 +1076,7 @@ def test_nullable_with_non_null_default_pass_value(path: str): ("path", "value"), [ ("/nullable-with-non-null-default-str", "test"), + ("/nullable-with-non-null-default-str", ""), ("/nullable-with-non-null-default-int", 1), ("/nullable-with-non-null-default-list", [1, 2]), ], diff --git a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py index 88fa4f78d3..142df0e407 100644 --- a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py @@ -130,19 +130,25 @@ def test_nullable_required_missing(path: str): "/model-nullable-required", ], ) -def test_nullable_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test"}, + {"int_val": "0", "str_val": ""}, + ], +) +def test_nullable_required_pass_value(path: str, values: dict[str, str]): client = TestClient(app) - client.cookies.set("int_val", "1") - client.cookies.set("str_val", "test") - + client.cookies.set("int_val", values["int_val"]) + client.cookies.set("str_val", values["str_val"]) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: response = client.get(path) assert mock_convert.call_count == 2, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", + "int_val": int(values["int_val"]), + "str_val": values["str_val"], "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), } @@ -255,10 +261,17 @@ def test_nullable_non_required_missing(path: str): "/model-nullable-non-required", ], ) -def test_nullable_non_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test"}, + {"int_val": "0", "str_val": ""}, + ], +) +def test_nullable_non_required_pass_value(path: str, values: dict[str, str]): client = TestClient(app) - client.cookies.set("int_val", "1") - client.cookies.set("str_val", "test") + client.cookies.set("int_val", values["int_val"]) + client.cookies.set("str_val", values["str_val"]) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: response = client.get(path) @@ -266,8 +279,8 @@ def test_nullable_non_required_pass_value(path: str): assert mock_convert.call_count == 2, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", + "int_val": int(values["int_val"]), + "str_val": values["str_val"], "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), } @@ -385,10 +398,17 @@ def test_nullable_with_non_null_default_missing(path: str): "/model-nullable-with-non-null-default", ], ) -def test_nullable_with_non_null_default_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test"}, + {"int_val": "0", "str_val": ""}, + ], +) +def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, str]): client = TestClient(app) - client.cookies.set("int_val", "1") - client.cookies.set("str_val", "test") + client.cookies.set("int_val", values["int_val"]) + client.cookies.set("str_val", values["str_val"]) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: response = client.get(path) @@ -396,7 +416,7 @@ def test_nullable_with_non_null_default_pass_value(path: str): assert mock_convert.call_count == 2, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", + "int_val": int(values["int_val"]), + "str_val": values["str_val"], "fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)), } diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index 4236d5fa9e..a7bfbb6c2d 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -195,6 +195,38 @@ def test_nullable_required_pass_value(path: str): } +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_empty_str_to_str_val(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", ""), + ("list-val", "1"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "", + "list_val": [1], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + # ===================================================================================== # Nullable with default=None @@ -356,6 +388,38 @@ def test_nullable_non_required_pass_value(path: str): } +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_empty_str_to_str_val(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", ""), + ("list-val", "1"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "", + "list_val": [1], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + # ===================================================================================== # Nullable with not-None default @@ -527,3 +591,35 @@ def test_nullable_with_non_null_default_pass_value(path: str): None, IsList("int_val", "str_val", "list_val", check_order=False) ), } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_empty_str_to_str_val(path: str): + client = TestClient(app) + + with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: + response = client.get( + path, + headers=[ + ("int-val", "1"), + ("str-val", ""), + ("list-val", "1"), + ], + ) + + assert mock_convert.call_count == 3, "Validator should be called for each field" + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "", + "list_val": [1], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index 577e969f37..47cb67cb4d 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -155,20 +155,25 @@ def test_nullable_required_missing(path: str): "/model-nullable-required", ], ) -def test_nullable_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_required_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.get( - path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + response = client.get(path, params=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), @@ -303,20 +308,25 @@ def test_nullable_non_required_missing(path: str): "/model-nullable-non-required", ], ) -def test_nullable_non_required_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_non_required_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.get( - path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + response = client.get(path, params=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), @@ -463,20 +473,25 @@ def test_nullable_with_non_null_default_missing(path: str): "/model-nullable-with-non-null-default", ], ) -def test_nullable_with_non_null_default_pass_value(path: str): +@pytest.mark.parametrize( + "values", + [ + {"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}, + {"int_val": "0", "str_val": "", "list_val": ["0"]}, + ], +) +def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, Any]): client = TestClient(app) with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert: - response = client.get( - path, params={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} - ) + response = client.get(path, params=values) assert mock_convert.call_count == 3, "Validator should be called for each field" assert response.status_code == 200, response.text assert response.json() == { - "int_val": 1, - "str_val": "test", - "list_val": [1, 2], + "int_val": int(values["int_val"]), + "str_val": values["str_val"], + "list_val": [int(v) for v in values["list_val"]], "fields_set": IsOneOf( None, IsList("int_val", "str_val", "list_val", check_order=False) ), From d10fa5df1139afe00416f3d8cb4ce6ca6021fab4 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 25 Feb 2026 17:27:52 +0100 Subject: [PATCH 17/20] Update to Python 3.10 syntax --- .../test_body/test_nullable_and_defaults.py | 56 +++++++++---------- .../test_cookie/test_nullable_and_defaults.py | 26 ++++----- .../test_file/test_nullable_and_defaults.py | 22 ++++---- .../test_form/test_nullable_and_defaults.py | 38 ++++++------- .../test_header/test_nullable_and_defaults.py | 38 ++++++------- .../test_query/test_nullable_and_defaults.py | 38 ++++++------- 6 files changed, 109 insertions(+), 109 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index e78811b222..e3d658ce7e 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, patch import pytest @@ -22,10 +22,10 @@ def convert(v: Any) -> Any: @app.post("/nullable-required") async def read_nullable_required( - int_val: Annotated[Union[int, None], Body(), BeforeValidator(lambda v: convert(v))], - str_val: Annotated[Union[str, None], Body(), BeforeValidator(lambda v: convert(v))], + int_val: Annotated[int | None, Body(), BeforeValidator(lambda v: convert(v))], + str_val: Annotated[str | None, Body(), BeforeValidator(lambda v: convert(v))], list_val: Annotated[ - Union[list[int], None], + list[int] | None, Body(), BeforeValidator(lambda v: convert(v)), ], @@ -39,9 +39,9 @@ async def read_nullable_required( class ModelNullableRequired(BaseModel): - int_val: Union[int, None] - str_val: Union[str, None] - list_val: Union[list[int], None] + int_val: int | None + str_val: str | None + list_val: list[int] | None @field_validator("*", mode="before") def validate_all(cls, v): @@ -60,14 +60,14 @@ async def read_model_nullable_required(params: ModelNullableRequired): @app.post("/nullable-required-str") async def read_nullable_required_no_embed_str( - str_val: Annotated[Union[str, None], Body(), BeforeValidator(lambda v: convert(v))], + str_val: Annotated[str | None, Body(), BeforeValidator(lambda v: convert(v))], ): return {"val": str_val} @app.post("/nullable-required-int") async def read_nullable_required_no_embed_int( - int_val: Annotated[Union[int, None], Body(), BeforeValidator(lambda v: convert(v))], + int_val: Annotated[int | None, Body(), BeforeValidator(lambda v: convert(v))], ): return {"val": int_val} @@ -75,7 +75,7 @@ async def read_nullable_required_no_embed_int( @app.post("/nullable-required-list") async def read_nullable_required_no_embed_list( list_val: Annotated[ - Union[list[int], None], Body(), BeforeValidator(lambda v: convert(v)) + list[int] | None, Body(), BeforeValidator(lambda v: convert(v)) ], ): return {"val": list_val} @@ -398,17 +398,17 @@ def test_nullable_required_no_embed_pass_value(path: str, value: Any): @app.post("/nullable-non-required") async def read_nullable_non_required( int_val: Annotated[ - Union[int, None], + int | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, str_val: Annotated[ - Union[str, None], + str | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, list_val: Annotated[ - Union[list[int], None], + list[int] | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -422,9 +422,9 @@ async def read_nullable_non_required( class ModelNullableNonRequired(BaseModel): - int_val: Union[int, None] = None - str_val: Union[str, None] = None - list_val: Union[list[int], None] = None + int_val: int | None = None + str_val: str | None = None + list_val: list[int] | None = None @field_validator("*", mode="before") def validate_all(cls, v): @@ -446,7 +446,7 @@ async def read_model_nullable_non_required( @app.post("/nullable-non-required-str") async def read_nullable_non_required_no_embed_str( str_val: Annotated[ - Union[str, None], + str | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -457,7 +457,7 @@ async def read_nullable_non_required_no_embed_str( @app.post("/nullable-non-required-int") async def read_nullable_non_required_no_embed_int( int_val: Annotated[ - Union[int, None], + int | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -468,7 +468,7 @@ async def read_nullable_non_required_no_embed_int( @app.post("/nullable-non-required-list") async def read_nullable_non_required_no_embed_list( list_val: Annotated[ - Union[list[int], None], + list[int] | None, Body(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -746,17 +746,17 @@ def test_nullable_non_required_no_embed_pass_value(path: str, value: Any): async def read_nullable_with_non_null_default( *, int_val: Annotated[ - Union[int, None], + int | None, Body(), BeforeValidator(lambda v: convert(v)), ] = -1, str_val: Annotated[ - Union[str, None], + str | None, Body(), BeforeValidator(lambda v: convert(v)), ] = "default", list_val: Annotated[ - Union[list[int], None], + list[int] | None, Body(default_factory=lambda: [0]), BeforeValidator(lambda v: convert(v)), ], @@ -770,9 +770,9 @@ async def read_nullable_with_non_null_default( class ModelNullableWithNonNullDefault(BaseModel): - int_val: Union[int, None] = -1 - str_val: Union[str, None] = "default" - list_val: Union[list[int], None] = [0] + int_val: int | None = -1 + str_val: str | None = "default" + list_val: list[int] | None = [0] @field_validator("*", mode="before") def validate_all(cls, v): @@ -794,7 +794,7 @@ async def read_model_nullable_with_non_null_default( @app.post("/nullable-with-non-null-default-str") async def read_nullable_with_non_null_default_no_embed_str( str_val: Annotated[ - Union[str, None], + str | None, Body(), BeforeValidator(lambda v: convert(v)), ] = "default", @@ -805,7 +805,7 @@ async def read_nullable_with_non_null_default_no_embed_str( @app.post("/nullable-with-non-null-default-int") async def read_nullable_with_non_null_default_no_embed_int( int_val: Annotated[ - Union[int, None], + int | None, Body(), BeforeValidator(lambda v: convert(v)), ] = -1, @@ -816,7 +816,7 @@ async def read_nullable_with_non_null_default_no_embed_int( @app.post("/nullable-with-non-null-default-list") async def read_nullable_with_non_null_default_no_embed_list( list_val: Annotated[ - Union[list[int], None], + list[int] | None, Body(default_factory=lambda: [0]), BeforeValidator(lambda v: convert(v)), ], diff --git a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py index 142df0e407..f757997ddb 100644 --- a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, patch import pytest @@ -21,12 +21,12 @@ def convert(v: Any) -> Any: @app.get("/nullable-required") async def read_nullable_required( int_val: Annotated[ - Union[int, None], + int | None, Cookie(), BeforeValidator(lambda v: convert(v)), ], str_val: Annotated[ - Union[str, None], + str | None, Cookie(), BeforeValidator(lambda v: convert(v)), ], @@ -39,8 +39,8 @@ async def read_nullable_required( class ModelNullableRequired(BaseModel): - int_val: Union[int, None] - str_val: Union[str, None] + int_val: int | None + str_val: str | None @field_validator("*", mode="before") @classmethod @@ -160,12 +160,12 @@ def test_nullable_required_pass_value(path: str, values: dict[str, str]): @app.get("/nullable-non-required") async def read_nullable_non_required( int_val: Annotated[ - Union[int, None], + int | None, Cookie(), BeforeValidator(lambda v: convert(v)), ] = None, str_val: Annotated[ - Union[str, None], + str | None, Cookie(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -178,8 +178,8 @@ async def read_nullable_non_required( class ModelNullableNonRequired(BaseModel): - int_val: Union[int, None] = None - str_val: Union[str, None] = None + int_val: int | None = None + str_val: str | None = None @field_validator("*", mode="before") @classmethod @@ -293,12 +293,12 @@ def test_nullable_non_required_pass_value(path: str, values: dict[str, str]): async def read_nullable_with_non_null_default( *, int_val: Annotated[ - Union[int, None], + int | None, Cookie(), BeforeValidator(lambda v: convert(v)), ] = -1, str_val: Annotated[ - Union[str, None], + str | None, Cookie(), BeforeValidator(lambda v: convert(v)), ] = "default", @@ -311,8 +311,8 @@ async def read_nullable_with_non_null_default( class ModelNullableWithNonNullDefault(BaseModel): - int_val: Union[int, None] = -1 - str_val: Union[str, None] = "default" + int_val: int | None = -1 + str_val: str | None = "default" @field_validator("*", mode="before") @classmethod diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 4c34889e52..80af58dedd 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, patch import pytest @@ -24,12 +24,12 @@ def convert(v: Any) -> Any: @app.post("/nullable-required-bytes") async def read_nullable_required_bytes( file: Annotated[ - Union[bytes, None], + bytes | None, File(), BeforeValidator(lambda v: convert(v)), ], files: Annotated[ - Union[list[bytes], None], + list[bytes] | None, File(), BeforeValidator(lambda v: convert(v)), ], @@ -43,12 +43,12 @@ async def read_nullable_required_bytes( @app.post("/nullable-required-uploadfile") async def read_nullable_required_uploadfile( file: Annotated[ - Union[UploadFile, None], + UploadFile | None, File(), BeforeValidator(lambda v: convert(v)), ], files: Annotated[ - Union[list[UploadFile], None], + list[UploadFile] | None, File(), BeforeValidator(lambda v: convert(v)), ], @@ -196,12 +196,12 @@ def test_nullable_required_pass_file(path: str): @app.post("/nullable-non-required-bytes") async def read_nullable_non_required_bytes( file: Annotated[ - Union[bytes, None], + bytes | None, File(), BeforeValidator(lambda v: convert(v)), ] = None, files: Annotated[ - Union[list[bytes], None], + list[bytes] | None, File(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -215,12 +215,12 @@ async def read_nullable_non_required_bytes( @app.post("/nullable-non-required-uploadfile") async def read_nullable_non_required_uploadfile( file: Annotated[ - Union[UploadFile, None], + UploadFile | None, File(), BeforeValidator(lambda v: convert(v)), ] = None, files: Annotated[ - Union[list[UploadFile], None], + list[UploadFile] | None, File(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -351,12 +351,12 @@ def test_nullable_non_required_pass_file(path: str): async def read_nullable_with_non_null_default_bytes( *, file: Annotated[ - Union[bytes, None], + bytes | None, File(), BeforeValidator(lambda v: convert(v)), ] = b"default", files: Annotated[ - Union[list[bytes], None], + list[bytes] | None, File(default_factory=lambda: [b"default"]), BeforeValidator(lambda v: convert(v)), ], diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 558b288333..0e1d82c1f3 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, call, patch import pytest @@ -23,17 +23,17 @@ def convert(v: Any) -> Any: @app.post("/nullable-required") async def read_nullable_required( int_val: Annotated[ - Union[int, None], + int | None, Form(), BeforeValidator(lambda v: convert(v)), ], str_val: Annotated[ - Union[str, None], + str | None, Form(), BeforeValidator(lambda v: convert(v)), ], list_val: Annotated[ - Union[list[int], None], + list[int] | None, Form(), BeforeValidator(lambda v: convert(v)), ], @@ -47,9 +47,9 @@ async def read_nullable_required( class ModelNullableRequired(BaseModel): - int_val: Union[int, None] - str_val: Union[str, None] - list_val: Union[list[int], None] + int_val: int | None + str_val: str | None + list_val: list[int] | None @field_validator("*", mode="before") def convert_fields(cls, v): @@ -270,17 +270,17 @@ def test_nullable_required_pass_value(path: str): @app.post("/nullable-non-required") async def read_nullable_non_required( int_val: Annotated[ - Union[int, None], + int | None, Form(), BeforeValidator(lambda v: convert(v)), ] = None, str_val: Annotated[ - Union[str, None], + str | None, Form(), BeforeValidator(lambda v: convert(v)), ] = None, list_val: Annotated[ - Union[list[int], None], + list[int] | None, Form(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -294,9 +294,9 @@ async def read_nullable_non_required( class ModelNullableNonRequired(BaseModel): - int_val: Union[int, None] = None - str_val: Union[str, None] = None - list_val: Union[list[int], None] = None + int_val: int | None = None + str_val: str | None = None + list_val: list[int] | None = None @field_validator("*", mode="before") def convert_fields(cls, v): @@ -492,17 +492,17 @@ def test_nullable_non_required_pass_value(path: str): async def read_nullable_with_non_null_default( *, int_val: Annotated[ - Union[int, None], + int | None, Form(), BeforeValidator(lambda v: convert(v)), ] = -1, str_val: Annotated[ - Union[str, None], + str | None, Form(), BeforeValidator(lambda v: convert(v)), ] = "default", list_val: Annotated[ - Union[list[int], None], + list[int] | None, Form(default_factory=lambda: [0]), BeforeValidator(lambda v: convert(v)), ], @@ -516,9 +516,9 @@ async def read_nullable_with_non_null_default( class ModelNullableWithNonNullDefault(BaseModel): - int_val: Union[int, None] = -1 - str_val: Union[str, None] = "default" - list_val: Union[list[int], None] = [0] + int_val: int | None = -1 + str_val: str | None = "default" + list_val: list[int] | None = [0] @field_validator("*", mode="before") def convert_fields(cls, v): diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index a7bfbb6c2d..474fb0ef47 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, patch import pytest @@ -21,17 +21,17 @@ def convert(v: Any) -> Any: @app.get("/nullable-required") async def read_nullable_required( int_val: Annotated[ - Union[int, None], + int | None, Header(), BeforeValidator(lambda v: convert(v)), ], str_val: Annotated[ - Union[str, None], + str | None, Header(), BeforeValidator(lambda v: convert(v)), ], list_val: Annotated[ - Union[list[int], None], + list[int] | None, Header(), BeforeValidator(lambda v: convert(v)), ], @@ -45,9 +45,9 @@ async def read_nullable_required( class ModelNullableRequired(BaseModel): - int_val: Union[int, None] - str_val: Union[str, None] - list_val: Union[list[int], None] + int_val: int | None + str_val: str | None + list_val: list[int] | None @field_validator("*", mode="before") @classmethod @@ -234,17 +234,17 @@ def test_nullable_required_pass_empty_str_to_str_val(path: str): @app.get("/nullable-non-required") async def read_nullable_non_required( int_val: Annotated[ - Union[int, None], + int | None, Header(), BeforeValidator(lambda v: convert(v)), ] = None, str_val: Annotated[ - Union[str, None], + str | None, Header(), BeforeValidator(lambda v: convert(v)), ] = None, list_val: Annotated[ - Union[list[int], None], + list[int] | None, Header(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -258,9 +258,9 @@ async def read_nullable_non_required( class ModelNullableNonRequired(BaseModel): - int_val: Union[int, None] = None - str_val: Union[str, None] = None - list_val: Union[list[int], None] = None + int_val: int | None = None + str_val: str | None = None + list_val: list[int] | None = None @field_validator("*", mode="before") @classmethod @@ -428,17 +428,17 @@ def test_nullable_non_required_pass_empty_str_to_str_val(path: str): async def read_nullable_with_non_null_default( *, int_val: Annotated[ - Union[int, None], + int | None, Header(), BeforeValidator(lambda v: convert(v)), ] = -1, str_val: Annotated[ - Union[str, None], + str | None, Header(), BeforeValidator(lambda v: convert(v)), ] = "default", list_val: Annotated[ - Union[list[int], None], + list[int] | None, Header(default_factory=lambda: [0]), BeforeValidator(lambda v: convert(v)), ], @@ -452,9 +452,9 @@ async def read_nullable_with_non_null_default( class ModelNullableWithNonNullDefault(BaseModel): - int_val: Union[int, None] = -1 - str_val: Union[str, None] = "default" - list_val: Union[list[int], None] = [0] + int_val: int | None = -1 + str_val: str | None = "default" + list_val: list[int] | None = [0] @field_validator("*", mode="before") @classmethod diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index 47cb67cb4d..648f265802 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Union +from typing import Annotated, Any from unittest.mock import Mock, patch import pytest @@ -21,15 +21,15 @@ def convert(v: Any) -> Any: @app.get("/nullable-required") async def read_nullable_required( int_val: Annotated[ - Union[int, None], + int | None, BeforeValidator(lambda v: convert(v)), ], str_val: Annotated[ - Union[str, None], + str | None, BeforeValidator(lambda v: convert(v)), ], list_val: Annotated[ - Union[list[int], None], + list[int] | None, Query(), BeforeValidator(lambda v: convert(v)), ], @@ -43,9 +43,9 @@ async def read_nullable_required( class ModelNullableRequired(BaseModel): - int_val: Union[int, None] - str_val: Union[str, None] - list_val: Union[list[int], None] + int_val: int | None + str_val: str | None + list_val: list[int] | None @field_validator("*", mode="before") @classmethod @@ -187,15 +187,15 @@ def test_nullable_required_pass_value(path: str, values: dict[str, Any]): @app.get("/nullable-non-required") async def read_nullable_non_required( int_val: Annotated[ - Union[int, None], + int | None, BeforeValidator(lambda v: convert(v)), ] = None, str_val: Annotated[ - Union[str, None], + str | None, BeforeValidator(lambda v: convert(v)), ] = None, list_val: Annotated[ - Union[list[int], None], + list[int] | None, Query(), BeforeValidator(lambda v: convert(v)), ] = None, @@ -209,9 +209,9 @@ async def read_nullable_non_required( class ModelNullableNonRequired(BaseModel): - int_val: Union[int, None] = None - str_val: Union[str, None] = None - list_val: Union[list[int], None] = None + int_val: int | None = None + str_val: str | None = None + list_val: list[int] | None = None @field_validator("*", mode="before") @classmethod @@ -341,15 +341,15 @@ def test_nullable_non_required_pass_value(path: str, values: dict[str, Any]): async def read_nullable_with_non_null_default( *, int_val: Annotated[ - Union[int, None], + int | None, BeforeValidator(lambda v: convert(v)), ] = -1, str_val: Annotated[ - Union[str, None], + str | None, BeforeValidator(lambda v: convert(v)), ] = "default", list_val: Annotated[ - Union[list[int], None], + list[int] | None, Query(default_factory=lambda: [0]), BeforeValidator(lambda v: convert(v)), ], @@ -363,9 +363,9 @@ async def read_nullable_with_non_null_default( class ModelNullableWithNonNullDefault(BaseModel): - int_val: Union[int, None] = -1 - str_val: Union[str, None] = "default" - list_val: Union[list[int], None] = [0] + int_val: int | None = -1 + str_val: str | None = "default" + list_val: list[int] | None = [0] @field_validator("*", mode="before") @classmethod From 1a251c63c245240f17e0c6408fbab5235c70fbaa Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 25 Feb 2026 18:36:46 +0100 Subject: [PATCH 18/20] Use inline snapshots in assertions --- .../test_body/test_nullable_and_defaults.py | 299 ++++++++++-------- .../test_cookie/test_nullable_and_defaults.py | 157 ++++----- .../test_file/test_nullable_and_defaults.py | 157 +++++---- .../test_form/test_nullable_and_defaults.py | 269 ++++++++-------- .../test_header/test_nullable_and_defaults.py | 243 +++++++------- .../test_query/test_nullable_and_defaults.py | 243 +++++++------- 6 files changed, 725 insertions(+), 643 deletions(-) diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py index e3d658ce7e..d0fe6fff96 100644 --- a/tests/test_request_params/test_body/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import Body, FastAPI from fastapi.testclient import TestClient +from inline_snapshot import Is, snapshot from pydantic import BaseModel, BeforeValidator, field_validator from .utils import get_body_model_name @@ -92,28 +93,30 @@ def test_nullable_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - }, - }, - "required": ["int_val", "str_val", "list_val"], - "title": body_model_name, - "type": "object", - } + "required": ["int_val", "str_val", "list_val"], + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -165,28 +168,30 @@ def test_nullable_required_missing(path: str): client = TestClient(app) response = client.post(path, json={}) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body", "int_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["body", "str_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["body", "list_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + ) @pytest.mark.parametrize( @@ -205,16 +210,18 @@ def test_nullable_required_no_body(path: str): client = TestClient(app) response = client.post(path) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body"], - "msg": "Field required", - "input": None, - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + ) @pytest.mark.parametrize( @@ -229,16 +236,18 @@ def test_nullable_required_no_embed_missing(path: str): client = TestClient(app) response = client.post(path) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "input": None, - "loc": ["body"], - "msg": "Field required", - "type": "missing", - } - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + } + ] + } + ) @pytest.mark.parametrize( @@ -267,16 +276,18 @@ def test_nullable_required_no_embed_pass_empty_dict( client = TestClient(app) response = client.post(path, json={}) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "input": {}, - "loc": ["body"], - "msg": msg, - "type": error_type, - } - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "input": {}, + "loc": ["body"], + "msg": Is(msg), + "type": Is(error_type), + } + ] + } + ) @pytest.mark.parametrize( @@ -487,30 +498,32 @@ def test_nullable_non_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema - }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - # "default": None, # `None` values are omitted in OpenAPI schema - }, - }, - "title": body_model_name, - "type": "object", - } + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -595,16 +608,18 @@ def test_nullable_non_required_no_body(path: str): client = TestClient(app) response = client.post(path) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body"], - "msg": "Field required", - "input": None, - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + ) @pytest.mark.parametrize( @@ -836,31 +851,33 @@ def test_nullable_with_non_null_default_schema(path: str): body_model_name = get_body_model_name(openapi, path) body_model = app.openapi()["components"]["schemas"][body_model_name] - assert body_model == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": -1, - }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": "default", - }, - "list_val": IsPartialDict( - { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], + assert body_model == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, }, - ), - }, - "title": body_model_name, - "type": "object", - } + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "list_val": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + ), + }, + "title": Is(body_model_name), + "type": "object", + } + ) if path == "/model-nullable-with-non-null-default": # Check default value for list_val param for model-based parameters only. @@ -949,16 +966,18 @@ def test_nullable_with_non_null_default_no_body(path: str): client = TestClient(app) response = client.post(path) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body"], - "msg": "Field required", - "input": None, - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + ) @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py index f757997ddb..fdc9aeeaa9 100644 --- a/tests/test_request_params/test_cookie/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_cookie/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import IsList, IsOneOf from fastapi import Cookie, FastAPI from fastapi.testclient import TestClient +from inline_snapshot import snapshot from pydantic import BaseModel, BeforeValidator, field_validator app = FastAPI() @@ -67,26 +68,28 @@ async def read_model_nullable_required( ], ) def test_nullable_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": True, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int_val", + "in": "cookie", }, - "name": "int_val", - "in": "cookie", - }, - { - "required": True, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str_val", + "in": "cookie", }, - "name": "str_val", - "in": "cookie", - }, - ] + ] + ) @pytest.mark.parametrize( @@ -105,22 +108,24 @@ def test_nullable_required_missing(path: str): "Validator should not be called if the value is missing" ) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["cookie", "int_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["cookie", "str_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["cookie", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["cookie", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + ) @pytest.mark.parametrize( @@ -206,28 +211,30 @@ async def read_model_nullable_non_required( ], ) def test_nullable_non_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int_val", + "in": "cookie", }, - "name": "int_val", - "in": "cookie", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str_val", + "in": "cookie", }, - "name": "str_val", - "in": "cookie", - }, - ] + ] + ) @pytest.mark.parametrize( @@ -339,28 +346,30 @@ async def read_model_nullable_with_non_null_default( ], ) def test_nullable_with_non_null_default_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": -1, + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int_val", + "in": "cookie", }, - "name": "int_val", - "in": "cookie", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": "default", + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str_val", + "in": "cookie", }, - "name": "str_val", - "in": "cookie", - }, - ] + ] + ) @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 80af58dedd..7fb3f0edc2 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import IsOneOf from fastapi import FastAPI, File, UploadFile from fastapi.testclient import TestClient +from inline_snapshot import Is, snapshot from pydantic import BeforeValidator from starlette.datastructures import UploadFile as StarletteUploadFile @@ -70,24 +71,29 @@ def test_nullable_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "file": { - "title": "File", - "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "file": { + "title": "File", + "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + }, + "files": { + "title": "Files", + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + }, }, - "files": { - "title": "Files", - "anyOf": [ - {"type": "array", "items": {"type": "string", "format": "binary"}}, - {"type": "null"}, - ], - }, - }, - "required": ["file", "files"], - "title": body_model_name, - "type": "object", - } + "required": ["file", "files"], + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -107,22 +113,24 @@ def test_nullable_required_missing(path: str): "Validator should not be called if the value is missing" ) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body", "file"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["body", "files"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "file"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "files"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + ) @pytest.mark.parametrize( @@ -242,25 +250,30 @@ def test_nullable_non_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "file": { - "title": "File", - "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "file": { + "title": "File", + "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "files": { + "title": "Files", + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, }, - "files": { - "title": "Files", - "anyOf": [ - {"type": "array", "items": {"type": "string", "format": "binary"}}, - {"type": "null"}, - ], - # "default": None, # `None` values are omitted in OpenAPI schema - }, - }, - "title": body_model_name, - "type": "object", - } + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -380,24 +393,32 @@ def test_nullable_with_non_null_default_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "file": { - "title": "File", - "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], - "default": "default", # <= Default value for file looks strange to me + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "file": { + "title": "File", + "anyOf": [ + {"type": "string", "format": "binary"}, + {"type": "null"}, + ], + "default": "default", # <= Default value here looks strange to me + }, + "files": { + "title": "Files", + "anyOf": [ + { + "type": "array", + "items": {"type": "string", "format": "binary"}, + }, + {"type": "null"}, + ], + }, }, - "files": { - "title": "Files", - "anyOf": [ - {"type": "array", "items": {"type": "string", "format": "binary"}}, - {"type": "null"}, - ], - }, - }, - "title": body_model_name, - "type": "object", - } + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( diff --git a/tests/test_request_params/test_form/test_nullable_and_defaults.py b/tests/test_request_params/test_form/test_nullable_and_defaults.py index 0e1d82c1f3..58532e202c 100644 --- a/tests/test_request_params/test_form/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_form/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Form from fastapi.testclient import TestClient +from inline_snapshot import Is, snapshot from pydantic import BaseModel, BeforeValidator, field_validator from .utils import get_body_model_name @@ -79,28 +80,30 @@ def test_nullable_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - }, - }, - "required": ["int_val", "str_val", "list_val"], - "title": body_model_name, - "type": "object", - } + "required": ["int_val", "str_val", "list_val"], + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -120,28 +123,30 @@ def test_nullable_required_missing(path: str): "Validator should not be called if the value is missing" ) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["body", "int_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["body", "str_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["body", "list_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["body", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + ) @pytest.mark.parametrize( @@ -218,22 +223,24 @@ def test_nullable_required_pass_empty_str_to_int_val_and_list_val(path: str): call([""]), # list_val ] assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "input": "", - "loc": ["body", "int_val"], - "msg": "Input should be a valid integer, unable to parse string as an integer", - "type": "int_parsing", - }, - { - "input": "", - "loc": ["body", "list_val", 0], - "msg": "Input should be a valid integer, unable to parse string as an integer", - "type": "int_parsing", - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "input": "", + "loc": ["body", "int_val"], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] + } + ) @pytest.mark.parametrize( @@ -326,30 +333,32 @@ def test_nullable_non_required_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - assert app.openapi()["components"]["schemas"][body_model_name] == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert openapi["components"]["schemas"][body_model_name] == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema - }, - "list_val": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - # "default": None, # `None` values are omitted in OpenAPI schema - }, - }, - "title": body_model_name, - "type": "object", - } + "title": Is(body_model_name), + "type": "object", + } + ) @pytest.mark.parametrize( @@ -445,16 +454,18 @@ def test_nullable_non_required_pass_empty_str_to_all(path: str): call([""]), # list_val ] assert response.status_code == 422, response.text - assert response.json() == { - "detail": [ - { - "input": "", - "loc": ["body", "list_val", 0], - "msg": "Input should be a valid integer, unable to parse string as an integer", - "type": "int_parsing", - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] + } + ) @pytest.mark.parametrize( @@ -547,33 +558,35 @@ async def read_model_nullable_with_non_null_default( def test_nullable_with_non_null_default_schema(path: str): openapi = app.openapi() body_model_name = get_body_model_name(openapi, path) - body_model = app.openapi()["components"]["schemas"][body_model_name] + body_model = openapi["components"]["schemas"][body_model_name] - assert body_model == { - "properties": { - "int_val": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": -1, + assert body_model == snapshot( + { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "list_val": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + } + ), }, - "str_val": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": "default", - }, - "list_val": IsPartialDict( - { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - } - ), - }, - "title": body_model_name, - "type": "object", - } + "title": Is(body_model_name), + "type": "object", + } + ) if path == "/model-nullable-with-non-null-default": # Check default value for list_val param for model-based parameters only. @@ -691,16 +704,18 @@ def test_nullable_with_non_null_default_pass_empty_str_to_all(path: str): call([""]), # list_val ] assert response.status_code == 422, response.text # pragma: no cover - assert response.json() == { # pragma: no cover - "detail": [ - { - "input": "", - "loc": ["body", "list_val", 0], - "msg": "Input should be a valid integer, unable to parse string as an integer", - "type": "int_parsing", - }, - ] - } + assert response.json() == snapshot( # pragma: no cover + { + "detail": [ + { + "input": "", + "loc": ["body", "list_val", 0], + "msg": "Input should be a valid integer, unable to parse string as an integer", + "type": "int_parsing", + }, + ] + } + ) # TODO: Remove 'no cover' when the issue is fixed diff --git a/tests/test_request_params/test_header/test_nullable_and_defaults.py b/tests/test_request_params/test_header/test_nullable_and_defaults.py index 474fb0ef47..4680973fe5 100644 --- a/tests/test_request_params/test_header/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_header/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import AnyThing, IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Header from fastapi.testclient import TestClient +from inline_snapshot import snapshot from pydantic import BaseModel, BeforeValidator, field_validator app = FastAPI() @@ -80,38 +81,40 @@ async def read_model_nullable_required( ], ) def test_nullable_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": True, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int-val", + "in": "header", }, - "name": "int-val", - "in": "header", - }, - { - "required": True, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str-val", + "in": "header", }, - "name": "str-val", - "in": "header", - }, - { - "required": True, - "schema": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], + { + "required": True, + "schema": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + "name": "list-val", + "in": "header", }, - "name": "list-val", - "in": "header", - }, - ] + ] + ) @pytest.mark.parametrize( @@ -138,28 +141,30 @@ def test_nullable_required_missing(path: str): "Validator should not be called if the value is missing" ) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["header", "int-val"], - "msg": "Field required", - "input": AnyThing(), - }, - { - "type": "missing", - "loc": ["header", "str-val"], - "msg": "Field required", - "input": AnyThing(), - }, - { - "type": "missing", - "loc": ["header", "list-val"], - "msg": "Field required", - "input": AnyThing(), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["header", "int-val"], + "msg": "Field required", + "input": AnyThing(), + }, + { + "type": "missing", + "loc": ["header", "str-val"], + "msg": "Field required", + "input": AnyThing(), + }, + { + "type": "missing", + "loc": ["header", "list-val"], + "msg": "Field required", + "input": AnyThing(), + }, + ] + } + ) @pytest.mark.parametrize( @@ -293,41 +298,43 @@ async def read_model_nullable_non_required( ], ) def test_nullable_non_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int-val", + "in": "header", }, - "name": "int-val", - "in": "header", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str-val", + "in": "header", }, - "name": "str-val", - "in": "header", - }, - { - "required": False, - "schema": { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - # "default": None, # `None` values are omitted in OpenAPI schema + { + "required": False, + "schema": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "list-val", + "in": "header", }, - "name": "list-val", - "in": "header", - }, - ] + ] + ) @pytest.mark.parametrize( @@ -488,42 +495,44 @@ async def read_model_nullable_with_non_null_default( ) def test_nullable_with_non_null_default_schema(path: str): parameters = app.openapi()["paths"][path]["get"]["parameters"] - assert parameters == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": -1, + assert parameters == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int-val", + "in": "header", }, - "name": "int-val", - "in": "header", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": "default", + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str-val", + "in": "header", }, - "name": "str-val", - "in": "header", - }, - { - "required": False, - "schema": IsPartialDict( - { - "title": "List Val", - "anyOf": [ - {"type": "array", "items": {"type": "integer"}}, - {"type": "null"}, - ], - } - ), - "name": "list-val", - "in": "header", - }, - ] + { + "required": False, + "schema": IsPartialDict( + { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + } + ), + "name": "list-val", + "in": "header", + }, + ] + ) if path == "/model-nullable-with-non-null-default": # Check default value for list_val param for model-based parameters only. diff --git a/tests/test_request_params/test_query/test_nullable_and_defaults.py b/tests/test_request_params/test_query/test_nullable_and_defaults.py index 648f265802..f018d7e0a3 100644 --- a/tests/test_request_params/test_query/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_query/test_nullable_and_defaults.py @@ -5,6 +5,7 @@ import pytest from dirty_equals import IsList, IsOneOf, IsPartialDict from fastapi import FastAPI, Query from fastapi.testclient import TestClient +from inline_snapshot import snapshot from pydantic import BaseModel, BeforeValidator, field_validator app = FastAPI() @@ -73,38 +74,40 @@ async def read_model_nullable_required( ], ) def test_nullable_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": True, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": True, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "name": "int_val", + "in": "query", }, - "name": "int_val", - "in": "query", - }, - { - "required": True, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], + { + "required": True, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "name": "str_val", + "in": "query", }, - "name": "str_val", - "in": "query", - }, - { - "in": "query", - "name": "list_val", - "required": True, - "schema": { - "anyOf": [ - {"items": {"type": "integer"}, "type": "array"}, - {"type": "null"}, - ], - "title": "List Val", + { + "in": "query", + "name": "list_val", + "required": True, + "schema": { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + }, }, - }, - ] + ] + ) @pytest.mark.parametrize( @@ -124,28 +127,30 @@ def test_nullable_required_missing(path: str): "Validator should not be called if the value is missing" ) assert response.status_code == 422 - assert response.json() == { - "detail": [ - { - "type": "missing", - "loc": ["query", "int_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["query", "str_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - { - "type": "missing", - "loc": ["query", "list_val"], - "msg": "Field required", - "input": IsOneOf(None, {}), - }, - ] - } + assert response.json() == snapshot( + { + "detail": [ + { + "type": "missing", + "loc": ["query", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["query", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["query", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + ) @pytest.mark.parametrize( @@ -239,41 +244,43 @@ async def read_model_nullable_non_required( ], ) def test_nullable_non_required_schema(path: str): - assert app.openapi()["paths"][path]["get"]["parameters"] == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "int_val", + "in": "query", }, - "name": "int_val", - "in": "query", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - # "default": None, # `None` values are omitted in OpenAPI schema + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "name": "str_val", + "in": "query", }, - "name": "str_val", - "in": "query", - }, - { - "in": "query", - "name": "list_val", - "required": False, - "schema": { - "anyOf": [ - {"items": {"type": "integer"}, "type": "array"}, - {"type": "null"}, - ], - "title": "List Val", - # "default": None, # `None` values are omitted in OpenAPI schema + { + "in": "query", + "name": "list_val", + "required": False, + "schema": { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, }, - }, - ] + ] + ) @pytest.mark.parametrize( @@ -394,42 +401,44 @@ async def read_model_nullable_with_non_null_default( ) def test_nullable_with_non_null_default_schema(path: str): parameters = app.openapi()["paths"][path]["get"]["parameters"] - assert parameters == [ - { - "required": False, - "schema": { - "title": "Int Val", - "anyOf": [{"type": "integer"}, {"type": "null"}], - "default": -1, + assert parameters == snapshot( + [ + { + "required": False, + "schema": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "name": "int_val", + "in": "query", }, - "name": "int_val", - "in": "query", - }, - { - "required": False, - "schema": { - "title": "Str Val", - "anyOf": [{"type": "string"}, {"type": "null"}], - "default": "default", + { + "required": False, + "schema": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "name": "str_val", + "in": "query", }, - "name": "str_val", - "in": "query", - }, - { - "in": "query", - "name": "list_val", - "required": False, - "schema": IsPartialDict( - { - "anyOf": [ - {"items": {"type": "integer"}, "type": "array"}, - {"type": "null"}, - ], - "title": "List Val", - } - ), - }, - ] + { + "in": "query", + "name": "list_val", + "required": False, + "schema": IsPartialDict( + { + "anyOf": [ + {"items": {"type": "integer"}, "type": "array"}, + {"type": "null"}, + ], + "title": "List Val", + } + ), + }, + ] + ) if path == "/model-nullable-with-non-null-default": # Check default value for list_val param for model-based parameters only. From 7e96b8b8fa1973248e8f2044678490ece88626a3 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 25 Feb 2026 18:38:54 +0100 Subject: [PATCH 19/20] Update snapshots to reflect latest changes in file param schema --- .../test_file/test_nullable_and_defaults.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 7fb3f0edc2..7be333de2d 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -76,14 +76,14 @@ def test_nullable_required_schema(path: str): "properties": { "file": { "title": "File", - "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + "anyOf": [{"type": "string", "contentMediaType": "application/octet-stream"}, {"type": "null"}], }, "files": { "title": "Files", "anyOf": [ { "type": "array", - "items": {"type": "string", "format": "binary"}, + "items": {"type": "string", "contentMediaType": "application/octet-stream"}, }, {"type": "null"}, ], @@ -255,7 +255,7 @@ def test_nullable_non_required_schema(path: str): "properties": { "file": { "title": "File", - "anyOf": [{"type": "string", "format": "binary"}, {"type": "null"}], + "anyOf": [{"type": "string", "contentMediaType": "application/octet-stream"}, {"type": "null"}], # "default": None, # `None` values are omitted in OpenAPI schema }, "files": { @@ -263,7 +263,7 @@ def test_nullable_non_required_schema(path: str): "anyOf": [ { "type": "array", - "items": {"type": "string", "format": "binary"}, + "items": {"type": "string", "contentMediaType": "application/octet-stream"}, }, {"type": "null"}, ], @@ -399,7 +399,7 @@ def test_nullable_with_non_null_default_schema(path: str): "file": { "title": "File", "anyOf": [ - {"type": "string", "format": "binary"}, + {"type": "string", "contentMediaType": "application/octet-stream"}, {"type": "null"}, ], "default": "default", # <= Default value here looks strange to me @@ -409,7 +409,7 @@ def test_nullable_with_non_null_default_schema(path: str): "anyOf": [ { "type": "array", - "items": {"type": "string", "format": "binary"}, + "items": {"type": "string", "contentMediaType": "application/octet-stream"}, }, {"type": "null"}, ], From 90ebf74f68b01cc2bed01f12c55e142779340158 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 25 Feb 2026 17:41:37 +0000 Subject: [PATCH 20/20] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test_file/test_nullable_and_defaults.py | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/tests/test_request_params/test_file/test_nullable_and_defaults.py b/tests/test_request_params/test_file/test_nullable_and_defaults.py index 7be333de2d..4134fae1ac 100644 --- a/tests/test_request_params/test_file/test_nullable_and_defaults.py +++ b/tests/test_request_params/test_file/test_nullable_and_defaults.py @@ -76,14 +76,23 @@ def test_nullable_required_schema(path: str): "properties": { "file": { "title": "File", - "anyOf": [{"type": "string", "contentMediaType": "application/octet-stream"}, {"type": "null"}], + "anyOf": [ + { + "type": "string", + "contentMediaType": "application/octet-stream", + }, + {"type": "null"}, + ], }, "files": { "title": "Files", "anyOf": [ { "type": "array", - "items": {"type": "string", "contentMediaType": "application/octet-stream"}, + "items": { + "type": "string", + "contentMediaType": "application/octet-stream", + }, }, {"type": "null"}, ], @@ -255,7 +264,13 @@ def test_nullable_non_required_schema(path: str): "properties": { "file": { "title": "File", - "anyOf": [{"type": "string", "contentMediaType": "application/octet-stream"}, {"type": "null"}], + "anyOf": [ + { + "type": "string", + "contentMediaType": "application/octet-stream", + }, + {"type": "null"}, + ], # "default": None, # `None` values are omitted in OpenAPI schema }, "files": { @@ -263,7 +278,10 @@ def test_nullable_non_required_schema(path: str): "anyOf": [ { "type": "array", - "items": {"type": "string", "contentMediaType": "application/octet-stream"}, + "items": { + "type": "string", + "contentMediaType": "application/octet-stream", + }, }, {"type": "null"}, ], @@ -399,7 +417,10 @@ def test_nullable_with_non_null_default_schema(path: str): "file": { "title": "File", "anyOf": [ - {"type": "string", "contentMediaType": "application/octet-stream"}, + { + "type": "string", + "contentMediaType": "application/octet-stream", + }, {"type": "null"}, ], "default": "default", # <= Default value here looks strange to me @@ -409,7 +430,10 @@ def test_nullable_with_non_null_default_schema(path: str): "anyOf": [ { "type": "array", - "items": {"type": "string", "contentMediaType": "application/octet-stream"}, + "items": { + "type": "string", + "contentMediaType": "application/octet-stream", + }, }, {"type": "null"}, ],