오늘도 개발

[2차 프로젝트 내일의 집] SNS 로그인/회원가입 api에 Unit Test 해보기 본문

TIL & 프로젝트 회고

[2차 프로젝트 내일의 집] SNS 로그인/회원가입 api에 Unit Test 해보기

Sueeeeeee 2022. 8. 5. 16:47

카카오 로그인 기능에 유닛 테스트를 해보았다.
테스트 코드 작성은 어려웠지만, 한 번 작성해보고 나니 테스트 자동화가 얼마나 중요한지 알 것 같았다.

앞으로 계속 리팩토링을 해야 하고, 프론트와도 통신해야 하는데
이제 테스트 코드가 있기 때문에
코드가 바뀔 때 마다 이제 일일이 포스트맨으로 테스트 할 필요가 없다.

views.py

테스트 대상이 되는 파일이다.
LoginView의 로직은 다음과 같다.

1) 프론트가 요청 헤더에 인가 코드를 넣고 엔드포인트 users/login으로 요청하면 LoginView가 실행된다.
2) 인가 코드를 꺼내 저장한다.
3) 카카오에 액세스 토큰을 요청한다. (요청 바디에 인가코드 넣음)
4) 액세스 토큰을 응답받는다.
5) 카카오에 유저 정보를 요청한다. (요청 헤더에 액세스 토큰 넣음)
6) 유저 정보를 응답받는다.
7) 유저 정보가 DB에 있는지 확인한다.
8) 있으면 로그인하고 없으면 회원가입한다.

class LoginView(View):
    def get(self, request):
        try:
            # users/login으로 들어온 요청 메시지 헤더에서 인가코드 받기 
            code = request.META.get('HTTP_AUTHORIZATION')
            # 인가 코드 없으면 오류 반환
            if not code:
                return JsonResponse({"message" : "INVALID_AUTHORIZATION_CODE"}, status=401)
			
            # 액세스 토큰 요청 바디에 넣을 데이터 준비
            data = {
                'grant_type' : 'authorization_code',
                'client_id' : settings.KAKAO_REST_API_KEY,
                'redirect_uri' : settings.REDIRECT_URI,
                'code' : code
            }
            # 카카오에 액세스 토큰 요청
            token_response = requests.post("https://kauth.kakao.com/oauth/token", data=data).json()
            access_token   = token_response.get('access_token')
          	
            # 액세스 토큰 없으면 오류 반환
            if not access_token:
                return JsonResponse({"message" : "INVALID_ACCESS_TOKEN"}, status=401)
			
            # 유저 정보 요청 헤더에 넣을 데이터 준비
            headers = {
                'Authorization': f'Bearer {access_token}'
            }
            # 카카오에 유저 정보 요청
            user_data_response = requests.post("https://kapi.kakao.com/v2/user/me", headers=headers).json()
            kakao_id = user_data_response.get('id')
            
            # 유저 정보 없으면 오류 반환
            if not kakao_id:
                return JsonResponse({"message" : "INVALID_KAKAO_ID"}, status=401)
			
            # 유저 정보 가져오기
            kakao_account = user_data_response['kakao_account']
            email         = kakao_account.get('email')
            nickname      = None 
            profile_image = None
            profile       = kakao_account.get('profile')

            if profile:
                nickname      = profile.get('nickname')
                profile_image = profile.get('profile_image_url')
            
            # DB 확인해보고 존재하는 유저면 jwt 발급, 아니면 회원가입 후 jwt 발급
            user, is_created = User.objects.get_or_create(
                kakao_id = kakao_id,
                defaults = {
                    "email"        : email,
                    "nickname"     : nickname,
                    "profile_image": profile_image
                }
            )
            nhouse_token = jwt.encode({"user_id" : user.id}, settings.SECRET_KEY, settings.ALGORITHM)

            if not is_created:
                if not user.email == email:
                    user.email = email 
                    user.save()
                if not user.profile_image == profile_image:
                    user.profile_image = profile_image
                    user.save()
                if not user.nickname == nickname:
                    user.nickname = nickname
                return JsonResponse({"message" : "LOGIN_SUCCESS", "access_token" : nhouse_token}, status=200)

            return JsonResponse({"message" : "SIGNUP_SUCCESS", "access_token" : nhouse_token}, status=201)  

        except KeyError:
            return JsonResponse({"message":"KEY_ERROR"}, status=400)

test.py

LoginView는 카카오에 요청을 두 번 보낸다.
이 때 테스트를 할 때마다 카카오에 진짜 요청을 할 필요는 없다.
외부 요청을 매 테스트마다 보내면 속도도 느려지고
카카오에 문제가 생기면 테스트를 할 수 없는 등의 문제도 있다.

이렇게 외부 API를 사용하는 코드를 테스트 할 때는 Mock을 사용할 수 있다.
(아래 코드에서 사용한 MagicMock은 Mock의 확장판이라고 생각하면 된다.)

Mock은 가짜 응답이다. Mock은 patch 데코레이터와 함께 사용할 수 있다.
patch 데코레이터의 인자에 Mock을 사용할 곳을 적어주고 patch를 사용하면
테스트를 진행하다 Mock을 사용하기로 한 곳이 나오면 외부로 요청을 보내지 않는다.
요청을 보내지 않았기 때문에 받지 못한 응답은 미리 지정한 Mock으로 대체된다.

from django.test import TestCase, Client
from unittest.mock import patch, MagicMock

from users.models import User

# 파이썬에서 제공하는 TestCase 클래스를 상속받는다.
# TestCase를 상속받아야 assertEqual 등 메서드를 사용할 수 있다. 
class LoginViewTest(TestCase):
    # 각 테스트를 시작하기 전 User 오브젝트를 생성한다.
    # 테스트 시 생성하는 오브젝트는 DB에 저장되지 않는다.
    def setUp(self):
        User.objects.create(
            id = 1,
            kakao_id = 12345,
            email = 'snoopy@gmail.com',
            nickname = 'Snoopy',
            profile_image = 'http://snoopy.com'
        )
    # 각 테스트가 끝나면 생성한 User 오브젝트를 삭제한다.
    def tearDown(self):
        User.objects.all().delete()

    # patch는 해당 테스트 안에서만 mock을 사용할 수 있게 해주는 데코레이터
    # patch의 인자에는 mock할 대상이 있는 경로를 넣어준다.
    @patch('users.views.requests') # users 앱의 views.py 파일의 requests 모듈에 mock을 쓰겠다는 뜻 
        def test_success_test(self, mocked_request):
            # 가상의 클라이언트 준비 - 클라이언트 객체 생성
            client = Client()

            # mock 사용 준비 - mock 객체 생성
            mock = MagicMock()

            # 카카오에 액세스 토큰 요청했을 때 오는 응답을 대체할 가짜 응답
            class MockedAccessToken:
                def json(self):
                    return {'access_token' : '1234'}

            # 카카오에 유저 정보를 요청했을 때 오는 응답을 대체할 가짜 응답
            class MockedUserData:
                def json(self):
                    return {
                        'id': 12345, 
                        'kakao_account': {
                            'profile': {
                                'nickname': 'snoopy', 
                                'thumbnail_image_url': 'http://snoopy.com', 
                                'profile_image_url': 'http://snoopy.com'
                                }, 
                            'email': 'snoopy@gmail.com'
                            }
                        }
            
            # 첫번째 requests의 응답으로 MockedAccessToken()을 사용하고
            # 두번째 requests의 응답으로 MockedUserData()를 사용하겠다는 뜻
            mock.side_effect = [MockedAccessToken(), MockedUserData()]

            # LoginView 함수가 실행될 때 requests.post가 사용되는 곳이 있다면 위에서 설정한 mock으로 대체하겠다는 뜻
            mocked_request.post = mock
            
            # LoginView 함수 실행 준비
            # LoginView를 요청할 때 헤더에 넣을 정보
            header = {'HTTP_AUTHORIZATION' : '12345'}

            # 헤더 넣어서 가상의 클라이언트로 /users/login에 요청 보냄
            response = client.get('/users/login', content_type='applications/json', **header)
            
            # LoginView 실행됨

            # LoginView 실행으로 나온 응답의 status_code가 200이 맞는지 확인
            self.assertEqual(response.status_code, 200)
            # LoginView 실행으로 나온 응답의 message가 'LOGIN_SUCCESS'가 맞는지 확인
            self.assertEqual(response.json().get('message'), 'LOGIN_SUCCESS')


아쉬운 점

테스트를 하다보니 views.py의 LoginView 안에 너무 많은 기능이 들어있다는 생각이 들었다. 한 함수가 한가지 기능만 해야 테스트하기도 좋고 문제가 생겨도 파악하기 쉬울텐데 코드를 작성할 때는 그 생각을 못했다.

Loginview는 db에 유저 저장/jwt 발급만 하고 카카오로 보내는 요청 두 번은 클래스로 따로 빼서 메서드로 관리하는 게 나을 것 같다.

이렇게 하면 유닛 테스트에서는 patch 데코레이터를 두 번 사용해서 각각 카카오로 보내는 요청을 담당할 수 있게 만들 수 있을 것이다.

다음에 리팩토링 할 때는 유닛 테스트도 다시 만들어봐야겠다.