Pytest와 faker로 코드 작성

2024년 5월 24일 금요일

Today I Learned

날짜

2024년 5월 24일 금요일

내용

테스트 코드 작성을 위한 DB 모델링

우리 서비스의 테스트 코드는 pytest로 작성되어 있고, 데이터베이스는 SQLite다. SQLite는 파일 형식이라 만들고 뚝딱 삭제해버리면 금방이다. 우리 데이터베이스인 Postgres 와 마찬가지로 SQL이긴 하나 약간의 기능 차이는 존재한다. 하나 꼽자면 jsonb 타입의 필드가 없다. jsonb는 json과 달리 내부의 키로 검색할 수 있는 나름 유용한 타입이다.

쇼피파이에서 넘어오는 스토어 데이터는 Shop 테이블에 담긴다. 가공없이 그대로. 스토어와 관련해서 우리가 필요하고 자주쓰는 데이터는 이 자식테이블 격인 shop_detail에 담긴다. 여기에 apps_log라는 이름으로, 그 스토어가 활성화한 서비스가 어떤 것인지를 담는데 이 필드가 jsonb 타입이다.

이번 스프린트로 새로운 앱이 추가되면서, 사용중인 서비스 뿐만 아니라 설치한 앱에 관한 데이터 처리도 복잡하고 중요해졌다. 현재 테스트코드에 이 부분이 없어 꼭 추가하고 싶었다. 어떤 서비스를 설치할 때 필요한 것이 모두 제대로 생성되는지, 중복은 없는지 확인이 필요하다. 이번 QA 때 온갖 곳에서 문제가 터지는 걸 경험하고 꼭 만들어야 겠다 다짐했다.

본론으로 돌아와, SQLite에는 JSONB 타입이 없어서 기존에 작성되있는 데이터베이스 모델링을 그대로 쓸수가 없다. 따라서,

  1. 기존 데이터베이스 모델링대로 만들되
  2. 필드에 JSONB 타입을 사용하는 친구들은 제외시키고
  3. 이 친구들은 JSONB를 JSON으로 바꾸어 별도로 정의해서
  4. 추가해준다.

로 처리했다. 말이야 뭐 쉽지만.. 문제는 shop_detail이 shop과 연관이 있는 테이블이라는 것이고 별도로 정의해서 처리하다보니 이 관계가 제대로 생성되지 않아 오류처리됐다. 더 정확히는 Factory로 테스트용 가짜 데이터를 생성하는 과정에서 오류가 발생했다. shop에 달려있지 않은 shop_detail이 제대로 테스트 될리가 없으니 꼭 해결해야 하는 문제였다.

온갖짓을 다해봤지만 안됐다. 결국 이에 딸린 Shop 테이블도 테스트 파일에 따로 정의해야 했다. 그러고 난 후에는 shop 테이블에 딸린 또다른 테이블인 shop_service_email_history라는 테이블이 없어서 난리가 났다. 결국 애도 따로 정의했다. 테스트코드를 위해 따로 정의하는게 맞나…

사실 JSONB를 사용하게 된 가장 큰 이유는 키값으로 내부 데이터를 검색할 수 있기 떄문이다. JSONB 타입으로 선언된 필드에 “A”라는 키를 가진 데이터를 찾을 수 있다. 그 외의 연산도 가능하다. 하지만 왜인지 현재 코드 내에 작성하는 쿼리에선 안되고 직접 pgadmin4로 쿼리 입력할때만 된다. JSON보다 나은 장점은 쓰지도 못하고, JSONB라서 단점만 있으니 골칫거리다. 바꾸던가, 검색이 되게 하던가 해야할듯

Faker

대상혁. 사실 파이썬 라이브러리 중 하나로 테스트를 위한 가짜 데이터를 만들어준다. 임의의 문자열, 쉇자도 가능하고 임의의 주소나 휴대폰번호, 이메일, 회사주소도 가능하다. 이 값을 이용해서 임의의 데이터를 반복적으로 만들어주는게 Factory_boy다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 초기화
Faker = Factory.create
fake = Faker()
seed_value = random.randint(0, 10000)
fake.seed(seed_value)

# seed_value 가 같은 데이터는 같은 가짜데이터를 가진다. 예를 들어,
fake = Faker()
fake.seed(4321)
print(fake.name())  # '이상혁'
print(fake.nickname())  # 'GOAT'

fake.seed(4321)
print(fake.name())  # '이상혁'
print(fake.nickname())  # 'GOAT'

fake.seed(1111)
print(fake.name()) # '메시'
print(fake.nickname()) # '메갓'

아래는 샵테이블 모델링의 일부다.

1
2
3
4
5
6
7
8
9
10
11
12
class Shop(Base):
    __tablename__ = "shop"
    id = Column(Integer, primary_key=True, index=True)
    platform_id = Column(Integer, index=True)
    company_name = Column(String(125), index=True, unique=True)
    shop_url = Column(String(125), index=True, unique=True)
    shop_name = Column(Text, index=True)
    platform_shop_url = Column(String(125), index=True, unique=True)
    owner_email = Column(Text)
    currency = Column(String(3))
    iana_timezone = Column(String(31))

샵팩토리는 가짜 shop 데이터를 만들어준다. 정의는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
class ShopFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = Shop
        sqlalchemy_session = Session
    shop_name = factory.Faker("domain_word")
    company_name = factory.Faker("company")
    shop_url = factory.Faker("url")
    owner_email = factory.Faker("safe_email")
    currency = factory.Faker("currency_symbol")

    country_code = fake.country_code()
    iana_timezone = factory.Faker("timezone")

다양한 형식에 맞게 가짜 데이터를 만들어준다. 사용방법은

1
2
3
4
5
6
7
8
9
10
11
12
13
def set_shop_factory_session(db: Session):
    ShopFactory._meta.sqlalchemy_session = db
    
class TestShop:
    @pytest.mark.asyncio
    def test_get_shop(self):
        with TestingSessionWriteLocal() as db:
            set_shop_factory_session(db)
            shop = ShopFactory.create()
            db.add(shop)
            db.commit()
            db.refresh(shop)
            assert shop.id

이런식으로 처리하면 된다. 나는 Shop 테이블에 딸려있는 shop_detail 데이터를 추가로 만들었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ShopDetailFactory(factory.alchemy.SQLAlchemyModelFactory):
    class Meta:
        model = ShopDetail
        sqlalchemy_session = Session

    shop = factory.SubFactory(ShopFactory)
    store_name = factory.Faker("domain_word")
    company_name = factory.Faker("company")
    industry = factory.Faker("company")
    store_name = factory.Faker("company")
    store_url = factory.Faker("url")

    shop_owner = factory.Faker("name")
    email = factory.Faker("safe_email")
    phone = factory.Faker("phone_number")
    address = factory.Faker("address")
    customer_email = factory.Faker("safe_email")
    store_logo = factory.Faker("image_url")
    is_dropshipper = factory.Faker("pybool")
    apps_log = {}

shop = factory.SubFactory(ShopFactory) 가 이 shop_detail과 관련된 shop을 만들어주는 부분이다. 이때 관계된 shop 데이터도 자동으로 만들어진다. 따라서

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class TestShop:
    @pytest.mark.asyncio
    def test_get_shop(self):
        with TestingSessionWriteLocal() as db:
            set_shop_factory_session(db)
            shop = ShopFactory.create()
            shop_detail = ShopDetailFactory.create()
            db.add(shop)
            db.add(shop_detail
            db.commit()
            
            stmt = select(models.Shop).where(models.Shop.id > 0)
            shops = db.execute(stmt).scalars().all()
            assert len(shops) == 1

라고 작성하면 테스트가 실패한다. shop을 만들면서 1개, shop_detail을 만들면서 1개 총 2개가 만들어지기 떄문이다.

회고

별도로 테이블 정의하지 않는 방법, 결과를 개발자가 아니더라도 확인하는 방법에 대해 고민 필요..