Search code examples
asynchronoussqlalchemypytestfastapihttpx

pytest with httpx.AsyncClient cannot find newly created database records


I am trying to setup pytest with httpx.AsyncClient and sqlalchemy AsyncSession with FastAPI. Everything practically mimics the tests in FastAPI Fullstack repo, except for async stuff.

No issues with CRUD unit tests. The issue arises when running API tests using AsyncClient from httpx lib.

The issue is, any request made by client only has access to the users (in my case) created before initializing (setting up) the client fixture.

My pytest conftest.py setup is like this:

from typing import Dict, Generator, Callable
import asyncio
from fastapi import FastAPI
import pytest
# from sqlalchemy.orm import Session
from sqlalchemy.ext.asyncio import AsyncSession
from httpx import AsyncClient
import os
import warnings
import sqlalchemy as sa
from alembic.config import Config
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import sessionmaker


async def get_test_session() -> Generator:
    test_engine = create_async_engine(
            settings.SQLALCHEMY_DATABASE_URI + '_test',
            echo=False,
        )
        
    # expire_on_commit=False will prevent attributes from being expired
    # after commit.
    async_sess = sessionmaker(
        test_engine, expire_on_commit=False, class_=AsyncSession
    )
    async with async_sess() as sess, sess.begin():    
        yield sess

@pytest.fixture(scope="session")
async def async_session() -> Generator:
    test_engine = create_async_engine(
            settings.SQLALCHEMY_DATABASE_URI + '_test',
            echo=False,
            pool_size=20, max_overflow=0
        )
        
    # expire_on_commit=False will prevent attributes from being expired
    # after commit.
    async_sess = sessionmaker(
        test_engine, expire_on_commit=False, class_=AsyncSession
    )
    yield async_sess

@pytest.fixture(scope="session")
async def insert_initial_data(async_session:Callable):
    async with async_session() as session, session.begin():
        # insert first superuser - basic CRUD ops to insert data in test db
        await insert_first_superuser(session)
        # insert test.superuser@example.com

        await insert_first_test_user(session)
        # inserts test.user@example.com

@pytest.fixture(scope='session')
def app(insert_initial_data) -> FastAPI:
    return  FastAPI()


@pytest.fixture(scope='session')
async def client(app: FastAPI) -> Generator:
    from app.api.deps import get_session
    
    app.dependency_overrides[get_session] = get_test_session
 
    async with AsyncClient(
                app=app, base_url="http://test", 
                ) as ac:
        yield ac

    # reset dependencies
    app.dependency_overrides = {}

So in this case, only the superuser test.superuser@example.com and normal user test.user@example.com are available during running API tests. e.g., code below is able to fetch the access token just fine:

async def authentication_token_from_email(
    client: AsyncClient,  session: AsyncSession,
) -> Dict[str, str]:
    """
    Return a valid token for the user with given email.

    
    """
    
    email = 'test.user@example.com'
    password = 'test.user.password'
    
    user = await crud.user.get_by_email(session, email=email)
    assert user is not None
    
    
    data = {"username": email, "password": password}

    response = await client.post(f"{settings.API_V1_STR}/auth/access-token", 
                                 data=data)
    auth_token = response.cookies.get('access_token')
    assert auth_token is not None

    return auth_token

but, the modified code below doesn't - here I try to insert new user, and then log in to get access token.

async def authentication_token_from_email(
    client: AsyncClient, session: AsyncSession,
) -> Dict[str, str]:
    """
    Return a valid token for the user with given email.
    If the user doesn't exist it is created first.

    """
    
    email = random_email()
    password = random_lower_string()

    
    user = await crud.user.get_by_email(session, email=email)
    if not user:
        user_in_create = UserCreate(email=email, 
                                    password=password)
        user = await crud.user.create(session, obj_in=user_in_create)

        
    else:
        user_in_update = UserUpdate(password=password)
        user = await crud.user.update(session, db_obj=user, obj_in=user_in_update)

    assert user is not None

    # works fine up to this point, user inserted successfully
    # now try to send http request to fetch token, and user is not found in the db
        
    data = {"username": email, "password": password}
    response = await client.post(f"{settings.API_V1_STR}/auth/access-token", 
                                   data=data)
    auth_token = response.cookies.get('access_token')
    # returns None. 

    return auth_token

What is going on here ? Appreciate any help!


Solution

  • Turns out all I needed to do is, for reason I do not understand, is to define the FastAPI dependency override function inside the client fixture:

    before

    
    async def get_test_session() -> Generator:
        test_engine = create_async_engine(
                settings.SQLALCHEMY_DATABASE_URI + '_test',
                echo=False,
            )
            
        # expire_on_commit=False will prevent attributes from being expired
        # after commit.
        async_sess = sessionmaker(
            test_engine, expire_on_commit=False, class_=AsyncSession
        )
        async with async_sess() as sess, sess.begin():    
            yield sess
    
    @pytest.fixture(scope='session')
    async def client(app: FastAPI) -> Generator:
        from app.api.deps import get_session
        
        app.dependency_overrides[get_session] = get_test_session
     
        async with AsyncClient(
                    app=app, base_url="http://test", 
                    ) as ac:
            yield ac
    
        # reset dependencies
        app.dependency_overrides = {}
    

    after

    
    @pytest.fixture(scope="function")
    async def session(async_session) -> Generator:
        async with async_session() as sess, sess.begin():
            yield sess
            
    
    
    @pytest.fixture
    async def client(app: FastAPI, session:AsyncSession) -> Generator:
        from app.api.deps import get_session
        
        # this needs to be defined inside this fixture
        # this is generate that yields session retrieved from `session` fixture
        
        def get_sess(): 
            yield session
        
        app.dependency_overrides[get_session] =  get_sess
            
        async with AsyncClient(
                 app=app, base_url="http://test", 
                 ) as ac:
            yield ac
    
        app.dependency_overrides = {}
    
    

    I'd appreciate any explanation of this behavior. Thanks!