Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Question: How to override a dependency using create_test_client #2597

Open
1 of 4 tasks
dybi opened this issue Nov 2, 2023 · 5 comments
Open
1 of 4 tasks

Question: How to override a dependency using create_test_client #2597

dybi opened this issue Nov 2, 2023 · 5 comments
Labels
Question This is a question and further information is requested Testing This is related to our end-user Testing suite feature

Comments

@dybi
Copy link
Contributor

dybi commented Nov 2, 2023

Description

I have been playing with https://docs.litestar.dev/2/tutorials/todo-app/3-assembling-the-app.html#final-application and wanted to create some tests for it.

Namely, I wanted a test that after I create a TodoItem I am able to get it by name.

To do that, I have created a test client using create_test_client and provided route_handlers from the app. Also, I have changed the "todo_list" dependency so it uses a local function that provides a test global ITEMS = [] (I had been also trying with fixture local itesms = [] with the same results).

At some point during injecting dependecies global ITEMS gets copied (the id() of the object changes) and my route handle get_item get different todo_list then add_item, so the item that has been appened in add_item is not visible in get_item.

URL to code causing the issue

No response

MCVE

`app.py`

from dataclasses import dataclass
from typing import Annotated

import msgspec
from litestar import Litestar, get, post
from litestar.di import Provide
from litestar.exceptions import NotFoundException
from litestar.params import Dependency


@dataclass
class TodoItem:
    title: str
    done: bool
    deadline: Annotated[int, msgspec.Meta(ge=1)] | None = None

    def __post_init__(self):
        if self.done and self.deadline is not None:
            raise ValueError("Done tasks cannot have a deadline")


TODO_LIST: list[TodoItem] = [
    TodoItem(title="Start writing TODO list", done=True),
    TodoItem(title="???", done=False),
    TodoItem(title="Profit", done=False),
]


def get_todo_list():
    return TODO_LIST


@post("/")
async def add_item(todo_list: Annotated[list[TodoItem], Dependency], data: TodoItem) -> list[TodoItem]:
    todo_list.append(data)
    return todo_list


def get_todo_by_title(todo_name, todo_list) -> TodoItem:
    for item in todo_list:
        if item.title == todo_name:
            return item
    raise NotFoundException(detail=f"TODO {todo_name!r} not found")


@get("/{item_title:str}")
async def get_item(todo_list: Annotated[list[TodoItem], Dependency], item_title: str) -> TodoItem:
    return get_todo_by_title(item_title, todo_list)


app = Litestar([get_item, add_item], dependencies={"todo_list": Provide(get_todo_list)})



`test_app.py`
```py
import pytest
from litestar.di import Provide
from litestar.testing import create_test_client

import app

ITEMS =[]


@pytest.fixture
def test_client():
    items = []
    def get_todo_list_mock():
        return ITEMS

    # the above seems to not work - no idea why

    try:
        # with TestClient(app=app.app) as client:
        with create_test_client(route_handlers=[app.get_list, app.get_item, app.add_item, app.update_item],
                                dependencies={"todo_list": Provide(get_todo_list_mock)}) as client:
            yield client
    finally:
        ITEMS.clear()


def test_create_and_get_item(test_client):
    task_name = 'new'
    item = {'title': task_name, 'done': False, 'deadline': 4}
    r = test_client.post("/", json=item)
    assert r.status_code == 201

    r2 = test_client.get(f"/{task_name}")
    assert r2.status_code == 200
    assert r2.json() == item


### Steps to reproduce

```bash
just use the code snippets provided

Screenshots

No response

Logs

No response

Litestar Version

2.2.1

Platform

  • Linux
  • Mac
  • Windows
  • Other (Please specify in the description above)

Note

While we are open for sponsoring on GitHub Sponsors and
OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh Litestar dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.
Fund with Polar
@dybi dybi added Bug 🐛 This is something that is not working as expected Triage Required 🏥 This requires triage labels Nov 2, 2023
@dybi
Copy link
Contributor Author

dybi commented Nov 3, 2023

I have changed my tests using suggestions from: https://github.com/orgs/litestar-org/discussions/1789#discussioncomment-6110017

dependecies.py

from models import TodoItem

TODO_LIST: list[TodoItem] = [
    TodoItem(title="Start writing TODO list", done=True),
    TodoItem(title="???", done=False),
    TodoItem(title="Profit", done=False),
]


def get_todo_list():
    return TODO_LIST

app.py

@post("/")
async def add_item(todo_list: Annotated[list[TodoItem], Dependency], data: TodoItem) -> list[TodoItem]:
    todo_list.append(data)
    return todo_list


def get_todo_by_title(todo_name, todo_list) -> TodoItem:
    for item in todo_list:
        if item.title == todo_name:
            return item
    raise NotFoundException(detail=f"TODO {todo_name!r} not found")


@get("/{item_title:str}")
async def get_item(todo_list: Annotated[list[TodoItem], Dependency], item_title: str) -> TodoItem:
    return get_todo_by_title(item_title, todo_list)


app = Litestar([get_item, add_item], dependencies={"todo_list": Provide(get_todo_list)})

test_app.py

from litestar.testing import TestClient


def test_create_and_get_item(mocker):
    items = []

    def get_todo_list_mock():
        return items

    mocker.patch('dependencies.get_todo_list', new=get_todo_list_mock)
    import app

    with TestClient(app=app.app) as test_client:
        task_name = 'new'
        item = {'title': task_name, 'done': False, 'deadline': 4}
        r = test_client.post("/", json=item)
        assert r.status_code == 201

        r2 = test_client.get(f"/{task_name}")
        assert r2.status_code == 200
        assert r2.json() == item

The result is the same.
It seems that during DI the underlying value of the dependency provider is copied.
It happens in: https://github.com/litestar-org/litestar/blob/0bd5feb84ac475cdaaa53ce232b084d0c5105cc8/litestar/routes/http.py#L187C3-L189

@peterschutt
Copy link
Contributor

Hey @dybi - sorry about the delayed response here.

Did you read this: https://github.com/orgs/litestar-org/discussions/1789#discussioncomment-6110017

I think an appropriately used application factory pattern is what you need.

@dybi
Copy link
Contributor Author

dybi commented Nov 29, 2023

hey @peterschutt , I have rewritten the code to use application factory

dependencies.py

from dataclasses import dataclass
from typing import Annotated

import msgspec


@dataclass
class TodoItem:
    title: str
    done: bool
    deadline: Annotated[int, msgspec.Meta(ge=1)] | None = None

    def __post_init__(self):
        if self.done and self.deadline is not None:
            raise ValueError("Done tasks cannot have a deadline")


TODO_LIST: list[TodoItem] = list([
    TodoItem(title="Start writing TODO list", done=True),
    TodoItem(title="???", done=False),
    TodoItem(title="Profit", done=False),
])


async def get_todo_list():
    return TODO_LIST

app.py

from litestar import Litestar, get, post
from litestar.di import Provide
from litestar.exceptions import NotFoundException

from dependencies import get_todo_list, TodoItem


@post("/")
async def add_item( data: TodoItem, todo_list: list[TodoItem]) -> list[TodoItem]:
    todo_list.append(data)
    return todo_list


def get_todo_by_title(todo_name, todo_list) -> TodoItem:
    for item in todo_list:
        if item.title == todo_name:
            return item
    raise NotFoundException(detail=f"TODO {todo_name!r} not found")


@get("/{item_title:str}")
async def get_item( item_title: str, todo_list: list[TodoItem]) -> TodoItem:
    return get_todo_by_title(item_title, todo_list)


def create_app():
    app = Litestar([get_item, add_item], dependencies={"todo_list": Provide(get_todo_list)})
    return app

test_app_with_factory.py

import pytest
from litestar.testing import TestClient

from app import create_app

items = []


def get_todo_list_mock():
    return items


@pytest.fixture(autouse=True)
def mock_list(mocker):
    mocker.patch('app.get_todo_list', new=get_todo_list_mock)

@pytest.fixture
def app():
    return create_app()

def test_create_and_get_item(app):
    with TestClient(app) as test_client:
        task_name = 'new'
        item = {'title': task_name, 'done': False, 'deadline': 4}
        r = test_client.post("/", json=item)
        assert r.status_code == 201

        r2 = test_client.get(f"/{task_name}")
        assert r2.status_code == 200
        assert r2.json() == item

The result is the same as the underlying problem remains the same - in https://github.com/litestar-org/litestar/blob/0bd5feb84ac475cdaaa53ce232b084d0c5105cc8/litestar/routes/http.py#L187C3-L189
the value of mock's return value gets copied (compare id() of items from kwargs and parsed_kwargs)

@peterschutt
Copy link
Contributor

peterschutt commented Nov 30, 2023

OK thanks for persisting with us, I have reproduced.

This is a recurring theme with our dependencies. The value that is returned from the dependency provider is validated according to the annotation on the handlers. This prevents a class of user error where the provider for a dependency doesn't return the type that the user has declared at the point where the dependency is used. We use msgspec.convert() for this which behaves differently, conditional upon the types it is converting to. Here, it creates a new list. If your dependency didn't return a list, but some sort of arbitrary plain python repository class, that wrapped the list, this wouldn't happen and msgspec would just do an isinstance check on the value it receives in convert(). Behavior is different again if the type is an attrs class or a dataclass b/c msgspec has built in handling to validate those types.

Anyway, for now you can get your example to work by telling litestar to explicitly not validate the todo_list dependency:

from typing import Annotated
from litestar.params import Dependency

...

@post("/")
async def add_item( data: TodoItem, todo_list: Annotated[list[TodoItem], Dependency(skip_validation=True)]) -> list[TodoItem]:
    todo_list.append(data)
    return todo_list

...

@get("/{item_title:str}")
async def get_item( item_title: str, todo_list: Annotated[list[TodoItem], Dependency(skip_validation=True)]) -> TodoItem:
    return get_todo_by_title(item_title, todo_list)

Edit: both deps should skip validation

@dybi
Copy link
Contributor Author

dybi commented Nov 30, 2023

Thanks for the answers. The above works (with small changes: either flip the validation requirements, i.e. add_item has skip_validation=True and get_item has skip_validation=False or skip the validation for both handlers).

Out of curiosity I have prepared another version of app with intermediate Repository object that gets injected into handlers (it gets a list internally) and this approach works (Repository objects doesn't get copied - the original is injected).

Thanks for your help :)

If I may suggest something - from user's perspective it would be good to have the above behaviour somehow documented ;)
The simplest solution (providing callable that gets a plain list) doesn't work as is and needs quite specific tweaks - setting Dependency(skip_validation=True) (that for sure are neither obvious for the new users nor are something users would possibly want to keep in production code).

Keep up the good work! 💪

@JacobCoffee JacobCoffee added Testing This is related to our end-user Testing suite feature and removed Triage Required 🏥 This requires triage labels Dec 7, 2023
@provinzkraut provinzkraut added Enhancement This is a new feature or request Question This is a question and further information is requested and removed Bug 🐛 This is something that is not working as expected Enhancement This is a new feature or request labels Feb 14, 2024
@provinzkraut provinzkraut changed the title Bug: Unable to correctly override a dependency using create_test_client Question: How to override a dependency using create_test_client Feb 14, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Question This is a question and further information is requested Testing This is related to our end-user Testing suite feature
Projects
Status: Triage
Development

No branches or pull requests

4 participants