Pytest의 픽스처 의존성 시스템
제가 만든 사내 테스트 시스템은 이렇습니다. 엔진은 session scope로 생성해주고, db_session은 함수당 scope로 지정했습니다.
@pytest.fixture(scope="session")
def test_engine():
"""세션 스코프의 엔진 생성"""
test_url = 'mysql+pymysql://root@127.0.0.1:3306/takemm'
test_engine = create_engine(test_url)
Base.metadata.create_all(test_engine)
yield test_engine
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture(scope="function")
def test_db_session(test_engine):
TestSession = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
session = TestSession()
yield session
session.rollback()
session.close()
그런 다음과 같이 begin_nested()
를 명시해주게 되면 savepoint가 생성됩니다. 이렇게 되면 test_db_session.commit()
에 의해 트랜잭션이 종료되지 않습니다.
@pytest.fixture
def sample_data(test_db_session):
"""테스트용 데이터 생성"""
test_db_session.begin_nested()
test_user = Users(
id=12345,
username="test_user",
email="test@example.com",
last_ip="127.0.0.1",
created=datetime.now(),
modified=datetime.now(),
adult=1,
verify=0,
fifteen=1
)
test_db_session.add(test_user)
test_db_session.commit()
또한 UoW를 TEST용으로 주입해서 실제로 로직 내에서 commit
이 호출되어도 외부 트랜잭션이 종료되지 않고 rollback이 가능합니다.
def make_slack_interactive_handler(test_db_session) -> SlackInteractiveHandler:
uow = UnitOfWorkForTest(test_db_session)
prod_repo = ProductRepository(test_db_session, test_db_session)
redis_client = RedisClient()
slack_interactive_processor = SlackInteractiveProcessor(prod_repo, uow, redis_client)
return SlackInteractiveHandler(slack_interactive_processor)
이렇게하면 메서드마다 데이터가 격리됩니다. 다른 테스트에 의해서 오염될 일이 없는거죠! 요약하자면 모든 commit
전에 begin_nested
를 통해서 save point를 생성해주는 겁니다.
여기서 중요한 것! pytest에서는 테스트 함수의 매개변수를 보고 픽스처 의존성 그래프를 만듭니다.
def test_change_prod_status_on_테스트(self, test_db_session, make_slack_interactive_handler, sample_data):
해당 예시에서는 test_db_session, make_slack_interactive_handler, sample_data 픽스처 순으로 실행합니다.
이를 pytest의 명시적 의존성이라고 하는데, sample_data
픽스처에서 데이터를 생성하고 session.commit()
를 한다고 하더라도 매개변수에 명시적으로 의존성을 추가해야지 데이터가 준비합니다.
즉, sample_data
를 매개 변수에 넣지 않으면 데이터 생성이 안돼요! 이것 때문에 삽질 좀 했습니다.
conftest에서 Base Import하기
conftest라는 것은 pytest의 설정 및 공유 픽스처 파일입니다.
해당 파일을 두는 위치는 test 관련 디렉토리의 밑에 conftest.py
라는 이름으로 두면 됩니다. pytest는 테스트 실행 시 모든 상위 디렉토리의 conftest.py를 자동으로 찾아서 로드해주기 때문이죠.
project/
├── conftest.py # 루트 레벨 conftest
├── tests/
│ ├── conftest.py # 테스트 디렉토리 conftest
│ ├── unit/
│ │ ├── conftest.py # 단위 테스트용 conftest
│ │ └── test_*.py
│ └── integration/
│ └── test_*.py
저는 보통 이 파일에 앞서 보여드렸던 DB 커넥션 설정이나 Base 클래스들을 선언합니다. Base 클래스는 쉽게 말해서 테이블과 연관관계를 맺은 파이썬 객체라고 보면 됩니다.
# conftest.py에서 모든 모델 임포트
from Database.d_users import Users
from Database.d_qna import QnA
from Database.d_procut import Product
# ... 기타 모든 모델들
# 이렇게 하면 Base.metadata에 모든 테이블 정보가 등록됨
굳이 테스트에 해당 모델들을 사용하지 않아도 이렇게 선언을 해줘야 합니다. 그렇지 않으면 생각치도 못한 에러들을 마주하기 때문입니다.
만약 Base에서 FK 연관관계를 지정했는데, 순환 참조를 피하기 위해 “모델명” 처럼 문자열로 지정해두었다고 가정해봅시다.
class Users(Base):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
# 순환 참조 방지를 위해 문자열로 참조
products = relationship("Product", back_populates="user")
# Database/d_product.py
class Product(Base):
__tablename__ = 'products'
prod_id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey('users.id'))
# 마찬가지로 문자열 참조
user = relationship("Users", back_populates="products")
테스트 픽스처에서 Base.metadata.create_all(test_engine)
를 통해서 모든 엔티티 객체들을 참조해 테이블이 만들어 집니다. 하지만 여기서 중요한 것이 모델 메타데이터 등록은 임포트 시점에 발생한다는 겁니다.
즉 Users
에서 “Product” 문자열을 참조를 하려고 할 때, 실제로 Product 클래스가 임포트 되지 않으면 SQLAlchemy는 “Product”라는 문자열을 실제 클래스로 해결할 수 없습니다.
그래서 다음과 같이 해당 모델은 정의도지 않았다는 에러가 발생합니다.
expression 'Product' failed to locate a name ("name 'Product' is not defined")
결론적으로 정리하자면 이렇습니다. SQLAlchemy는 문자열 참조를 해결하기 위해 내부적으로 “클래스 레지스트리”를 유지합니다. 하지만 임포트되지 않으면 클래스 정의 자체가 Python 인터프리터에 의해 실행되지 않기 때문에 이 레지스트리에 등록되지 않게 됩니다.
따라서 conftest에 모델들을 미리 import 해주면 이런 귀찮은 상황은 피할 수 있습니다. 아주 간편~