본문 바로가기
Web 개발/FAST API (인프런 강의 내용)

5 실습6 회원가입 API 테스트

by yororing 2024. 11. 16.

앞 단계 참조 링크:

 

00 개요

  • 회원가입 API의 테스트 코드 작성하는 실습 진행

01 회원가입 API 테스트 코드 작성

1. test_users_api.py에 추가

  • 경로: todos/src/tests/test_users_api.py
  • 변경 사항:
    • body = {"username": "test", "password": "plain"} 추가
    • response = client.post("users/sign-up")에 json=body 추가
      • response = client.post("users/sign-up", json=body )
    • 상태 코드 200을 201로 수정
      •   assert response.status_code == 201

1) UserService.hash_password 메소드에 Mocking 적용 

  • mocking 적용 이유:
    • 회원가입 API를 보면, 전달받은 plain_password를 해시해주는 과정이 포함됨
    • 바로 다음과 같은 부분임 (api/user.py)
# /c/Users/관리자/Desktop/projects/todos/src/api/user.py 내용

...

@router.post("/sign-up", status_code=201)
def user_sign_up_handler(
    request: SignUpRequest,
    user_service: UserService = Depends(),
    user_repo: UserRepository = Depends()
):
    # 해싱된 비밀번호 생성					# 여기
    hashed_password: str = user_service.hash_password(
        plain_password = request.password
    )

...
# /c/Users/관리자/Desktop/projects/todos/src/service/user.py 내용
import bcrypt

class UserService:
    encoding: str = "UTF-8"
    def hash_password(self, plain_password: str) -> str:
        '''
        input:
            - plain_password: string
        output:
            - hashed_password: string
        function: receives plain-text password and returns hashed password
        '''
        hashed_password: bytes = bcrypt.hashpw(
            plain_password.encode(self.encoding),
            salt=bcrypt.gensalt()
        )
        return hashed_password.decode(self.encoding)
  • api/user.py의 '해싱된 비밀번호 생성' 부분은 salt로 인해 약간의 random성 동작을 함 (service/user.py의 UserService 참조)
  • 이 부분(UserService.hash_password)을 항상 일정하게 검증하기 어려워서 일단은 mocking 적용하는 것
  • 원래 이런 개별 메소드에 대해 동작 검증 시 unit test로 별도의 검증 방법을 사용하지만
  • 우리가 할 API 검증은, API의 flow만 검증하는 것이 목적이기에 
  • 우리는 일단 이 메소드 (hash_password)가 정상적으로 동작한다는 가정 하에 API 검증을 진행하는 것
  • → 그리하여 다음과 같이 적용!
# /c/Users/관리자/Desktop/projects/todos/src/tests/test_users_api.py 내용
from src.service.user import UserService         # import

def test_user_sign_up(client, mocker):           # mocker는 fixture
    hashed_password = mocker.patch.object(       # UserService.hash_password 메소드에 mocking 적용
        UserService,
        "hash_password",
        return_value="hashed"
    )
    
    body = {                                    # API에 넘겨줄 body 추가
        "username": "test",
        "password": "plain"
    }
    response = client.post("users/sign-up", json=body)  # json 형식으로 body 넘겨줘서 POST 요청 보내기 추가
    
    hashed_password.assert_called_once_with(      # UserService.hash_password 검증
        plain_password="plain"
    )
    
    assert response.status_code == 201          # 201로 변경
    assert response.json() is True
  • 변경 사항:
    • mocking 적용하기 위해 def test_user_signup(client)에 mocker라는 fixture 추가 
      • → def test_user_signup(client, mocker)
    • mocker의 patch의 object를 사용해 UserService에 있는 hash_password 메소드를 모킹해주기, 이것을 hash_password 변수에 담아주기
      • →hash_password = mocker.patch.object(UserService, "hash_password", return_value="hasehd")
        • 만약에 hash_password 메소드가 정상적으로 동작을 했다면 우리가 return_value로 "plain"이 아니라 해시된 값을 받아야 되어서 "hashed"라는 return_value를 지정 
    • 방금 만든 hash_password가 assert_called_once_with를 사용하여 우리가 전달한 plain_password를 통해 호출되었는지 검증할 것
      • → hash_password.assert_called_once_with(plain_password="plain") 
      • 전에는 해당 mocker가 호출됐는지 여부만 확인하는 assert_called_once만 사용했었는데, assert_called_once_with는 구체적으로 어떤 인자를 가지고 호출됐는지 여부 확인이 가능
      • api/user.py에 보면, plain_password에 request에 전달한 password가 잘 담겨서 호출되었는지 확인해보는 것 
      • plain_password 키로 password ("plain")가 잘 전달 되었는지 확인해보는 것 

2) User.create 메소드에 Mocking 적용

# /c/Users/관리자/Desktop/projects/todos/src/api/user.py 내용

...

@router.post("/sign-up", status_code=201)
def user_sign_up_handler(
    request: SignUpRequest,
    user_service: UserService = Depends(),
    user_repo: UserRepository = Depends()
):

...

    # 사용자 생성				# 여기
    user: User = User.create(
        username = request.username,
        hashed_password = hashed_password
    )

...
  • user_create 라는 변수에 mocker.patch.object를 사용해서 User 클래스의 create 메소드 모킹해줘서 담기 
    • → user_create = mocker.patch.pbject(User, "create", return_value=User(id=None, username="test", password="hased"))
    • 반환값은 User 모델인데 이 시점에는 아직 id가 None, username은 밑에 지정된대로 "test", password도 밑에 지정된대로 "hashed"가 될 것 
  • 방금 만든 user_create 가 assert_called_once_with를 사용하여 username은 우리가 request로 전달한 "test"가 넘어가야 되고 hashed_password는 우리가 방금 hash_password의 return_value="hashed"값이 잘 넘어오는 것을 검증할 것 
    • → user_create.assert_called_once_with(username="test", hased_password="hashed")

→ 그리하여 다음과 같이 적용!

# /c/Users/관리자/Desktop/projects/todos/src/tests/test_users_api.py 내용
from src.service.user import UserService
from src.database.orm import User              # import

def test_user_sign_up(client, mocker):          # mocker는 fixture
    hashed_password = mocker.patch.object(       # UserService.hash_password 메소드에 mocking 적용
        UserService,
        "hash_password",
        return_value="hashed"
    )
    
    user_create = mocker.patch.object(       # User.create 메소드에 mocking 적용
        User,
        "create",
        return_value=User(id=None, username="test", password="hashed")
    )
    
    body = {                                    # API에 넘겨줄 body 추가
        "username": "test",
        "password": "plain"
    }
    response = client.post("users/sign-up", json=body)  # json 형식으로 body 넘겨줘서 POST 요청 보내기 추가
    
    hashed_password.assert_called_once_with(      # UserService.hash_password 검증
        plain_password="plain"
    )
    
    user_create.assert_called_once_with(        # User.create 검증
        username="test", hashed_password="hashed"
    )
    
    assert response.status_code == 201          # 201로 변경
    assert response.json() is True

 

3) UserRepository.save_user 메소드에 Mocking 적용

 

# /c/Users/관리자/Desktop/projects/todos/src/api/user.py 내용

...

@router.post("/sign-up", status_code=201)
def user_sign_up_handler(
    request: SignUpRequest,
    user_service: UserService = Depends(),
    user_repo: UserRepository = Depends()
):

...

    # 사용자를 DB에 저장			# 여기
    user: User = user_repo.save_user(user=user) # 이 시점에서 사용자의 id 값 = int로 들어감, 실제 DB에 사용자가 생성됨

...
  • user_save 라는 변수에 mocker.patch.object를 사용해서 UserRepository 클래스의 save_user 메소드 모킹해줘서 담기
    • user_save  = mocker.patch.pbject(UserRepository , "save_user ", return_value=User(id=1, username="test", password="hased"))
    • 반환값은 User.create 메소드와 같이 User 모델인데 이 시점에는 id에 값이 할당 되어 1이 될 것, username은 밑에 지정된대로 "test", password도 밑에 지정된대로 "hashed"가 될 것
  • 최종 반환값을 "id": 1, "username": "test", "password": "hashed"가 되도록 확인하기
    • assert response.json() == {"id": 1, "username": "test"}

→ 그리하여 다음과 같이 적용!

# /c/Users/관리자/Desktop/projects/todos/src/tests/test_users_api.py 내용
from src.service.user import UserService
from src.database.orm import User
from src.database.repository import UserRepository		# import

def test_user_sign_up(client, mocker):                  # mocker는 fixture
    hashed_password = mocker.patch.object(              # UserService.hash_password 메소드에 mocking 적용
        UserService,
        "hash_password",
        return_value="hashed"
    )
    
    user_create = mocker.patch.object(                  # User.create 메소드에 mocking 적용
        User,
        "create",
        return_value=User(id=None, username="test", password="hashed")
    )
    
    mocker.patch.object(                                # UserRepository.save_user 메소드에 mocking 적용
        UserRepository,
        "save_user",
        return_value=User(id=1, username="test", password="hashed")
    )
    
    body = {                                            # API에 넘겨줄 body 추가
        "username": "test",
        "password": "plain"
    }
    response = client.post("users/sign-up", json=body)  # json 형식으로 body 넘겨줘서 POST 요청 보내기 추가
    
    hashed_password.assert_called_once_with(            # UserService.hash_password 검증
        plain_password="plain"
    )
    
    user_create.assert_called_once_with(                # User.create 검증
        username="test", hashed_password="hashed"
    )
    
    assert response.status_code == 201          # 201로 변경
    assert response.json() == {"id": 1, "username": "test"}     # 반환값으로 UserRepository.save_user 검증

 

2. test_users_api.py 실행

  • pytest로 검증 실행
    • -v 옵션: verbose, 더 많은 정보를 출력함 (실패 또는 에러에 관한 정보 출력)
# pytest test_users_api.py -v
  • 실패 및 에러남!

  • 오류 설명:
    • sqlalchemy.exc.IntegrityError: 데이터베이스에 중복 데이터가 삽입되려고 할 때 발생하는 오류
      • 이 오류는 SQLAlchemy에서 발생한 IntegrityError로, username 필드에 대해 중복된 값('test')이 삽입되려고 했기 때문에 발생한 것
    • Duplicate entry 'test' for key 'user.username': 'test'라는 값이 user.username 필드에 이미 존재하여, 해당 값을 다시 삽입할 수 없다는 오류
      • MySQL 데이터베이스에서 username 필드에 대해 유일성 제약 조건이 설정되어 있기 때문에, 중복된 값이 삽입되면 오류가 발행 
  • 해결 방법
    • 중복된 사용자명 확인: 테스트 데이터베이스에서 'test'라는 사용자명이 이미 존재하는지 확인 후 존재 시 다른 사용자명을 사용하도록 수정
  • 또 다른 실패 및 에러 남!

 

  • 오류 설명
    • AssertionError: Expected 'hash_password' to be called once. Called 0 times: hash_password 메서드가 예상대로 호출되지 않았다는 것을 의미
      • 이 문제는 mocker.patch로 모킹된 메서드가 테스트 실행 중에 호출되지 않았기 때문에 발생
      • 즉, UserService.hash_password가 호출되지 않음
      • hash_password 메서드는 사용자의 비밀번호를 해싱하는 역할을 함
      • 만약 UserService.hash_password가 호출되지 않는다면, 테스트 코드에서 실제로 hash_password 메서드를 호출하지 않거나 호출되지 않는 로직이 실행된 것
  • 해결 방법 - 실패
    • 1. hash_password 호출이 제대로 이루어지는지 확인: 
      • hash_password가 호출되는 코드 흐름을 다시 점검
      • 예를 들어, UserService.hash_password를 호출하는 로직이 test_user_sign_up 함수 내에서 잘 동작하는지 확인
      • hash_password가 호출되는 코드가 빠져 있거나 다른 곳에서 호출되지 않는 경우가 있을 수 있음 
    • 2. 로깅 추가하여 호출 여부 확인:
      • hash_password 메서드 내에 로그를 추가하여 호출 여부를 실시간으로 확인 가능
      • 예를 들어:
import logging

class UserService:
    encoding: str = "UTF-8" 
    def hash_password(self, plain_password: str) -> str:
        logging.info("hash_password called with: %s", plain_password)
        hashed_password: bytes = bcrypt.hashpw(
            plain_password.encode(self.encoding),
            salt=bcrypt.gensalt()
        )
        return hashed_password.decode(self.encoding)
  • 해결 방법 - 실패
    • 3. mocker.patch 위치 확인:
      • mocker.patch가 제대로 적용되는지 확인
      • hash_password 메서드가 호출되기 전에 mocker.patch가 적용되어야 함
      • test_user_sign_up 내에서 mocker.patch가 올바르게 실행되고 있는지 확인
    • 4. User.create 메서드가 hash_password를 호출하는지 확인:
      • hash_password 메서드는 User.create나 다른 로직 내에서 호출될 가능성이 있음
      • 이 부분이 제대로 연결되지 않으면 hash_password가 호출되지 않을 수 있음
      • 예를 들어, User.create가 hash_password를 호출하는지 확인
  • 못 고침..인줄 알았으나!!!
  • 해결 방법 - main.py에서 import들 경로가 바뀌어있었음!
    • 3. Swagger Ui에서 확인 - 참조
      • 해결 못할 줄 알고 Swagger Ui에서도 한 번 확인해보자 했었는데
      • sign-up API에서 Internal Server Error가 뜸, 이것은 보통 파일 자체가 안 될 때 나는 에러라서 해당 함수가 호출되는 main.py으로 가보니 import 경로에서 다  src.가 빠져있던 것..! src.를 다 추가해주고 Swagger Ui에서 작동시켜보니 되고, pytest 도 원활하게 passed! 가 뜸!!!
# cd todos/src/tests
# pytest test_users_api.py -v

  • 검증이 잘 통과된 것을 확인!

3. Swagger UI에서 확인

  • uvicorn 실행!
# uvicorn main:app --reload --port 8003

 

 

  • /docs로 들어가서 관리자 화면에서 /users/sign-up 클릭 하여 sign-up 요청 실습해보기

 

  • /users/sign-up > Try it out > Request Body에 {"username": "test2", "password": "plain_password"} 입력 > Execute 

  • 실패... 왜 이럴까
  • 코드를 다시 보아하니... main.py에서 import들을 할 때 경로가 잘 참조되지 않았었음..!
    • 앞에 src.가 안 붙어있었음!!! (언제 이걸 뺐었는지 기억도 안 남,,)
    • src.붙여주고 다시 Swagger UI 에서 실행해보니 됨!

  • 위를 아래와 같이 변경 후 저장

  • Swagger UI에서 잘 됨!

  • pytest도 잘 됨..

  • 내가 못살아 진짜... 원인을 몰라서 몇 시간동안 고민했는지 몰라 진짜....