Django UnitTest 활용해보기

wodnr_P·2023년 7월 1일
0

Unit Test?

소프트웨어 개발에서 실제 기능 배포 이전에 해당 기능이 요구사항에 맞게 동작하는지 확인하는 것을 테스트라고 합니다. 테스트는 테스트의 범위에 따라 크게 다음과 같이 분류합니다.

  • Acceptance Test (인수 테스트)
  • Stress/load Test (부하 테스트)
  • Function Test (기능 테스트)
  • Integration Test (통합 테스트)
  • Unit Test (단위 테스트)

또한 UI Test / Integration Test / Unit Test 로도 분류할 수 있는데, 이 중 UnitTest는 작성한 코드의 가장 작은 단위인 Method 나 Function을 테스트하는 것입니다. UnitTest는 가장 기본이 되는 테스트로 전체 테스트의 70%를 차지하는 비중이 큰 테스트입니다.

WHY?

여태까지는 Postman을 활용하여 API의 기능들을 테스트 했었는데 이는 Integration 테스트에 해당됩니다.
UI Test에 비해서는 덜 까다롭지만 만약 테스트를 진행할 API가 많은 경우라면 시간 비용이 많이 소모되므로 비교적 자동화하기 가장 쉽고, 효과가 좋은 UnitTest를 진행해보려고 합니다.


Unit Test 시작해보기

Django에서는 python의 UnitTest를 활용할 수 있는 라이브러리가 존재합니다.
App을 만들 때, tests.py라는 파일이 생성되며, 이 곳에서 test 코드를 작성하여 UnitTest를 진행할 수 있습니다.

Python Unit Test 용어

  • TestCase : 테스트 케이스를 구분해주는 테스트 조직의 기본 단위입니다.
  • Fixture : 테스트 진행시 필요한 데이터 혹은 설정 등을 말하며, 테스트가 실행되기 전or후에 생깁니다.
  • Assertion : 테스트가 잘 되었는지 확인할 때 사용합니다.

테스트 코드 작성 원칙

  • 테스트 단위는 각 기능의 가장 작은 단위에 집중해야합니다.
  • 기능이 정확하게 동작하는지 증명해야합니다.
  • Fixture에 속하는 setUp()메서드를 활용하여 새로운 데이터를 생성하여 테스트를 실행해야 합니다.
  • Fixture에 속하는 tearDown()메서드를 활용하여 실행한 결과를 삭제합니다. (Clean up)
  • 테스트 함수 이름의 길이는 서술적으로 작성해야 하며, 테스트 함수 이름은 test_로 시작해야 합니다.
  • Class는 기능 단위 별로 생성해야 되고, 각각의 테스트 Class는 독립적이어야 합니다.

tests.py 구조 예시

# <app name>/tests.py 

from django.test import TestCase, Client
# Client : 서버를 실제로 실행하지 않고도 클라이언트 입장에서 request를 보낼 수 있습니다.

...

class SignupTest(TestCase):
	def setUp(self):
    
    	... # 테스트를 위한 DB 생성 및 데이터 추가
    
    def tearDown(self):
    
    	... # 테스트 DB 삭제
    
    def test_signup_post_success(self):
    
    	... 
        self.assertEqual() 
        # 테스트 하려는 views.py의 결과값과 기대값을 설정할 수 있습니다.
		# 주로 response의 status code, JsonResponse 객체를 기대값으로 설정합니다.    

프로젝트 Unit Test 적용

가장 기본적인 사용자 관련 API의 테스트를 진행해보고자, 이전 Swagger를 적용해보는 프로젝트를 clone하여 Unit Test를 진행 해보았습니다.

회원가입 API 테스트

tests.py의 실행은 python3 manage.py test로 실행할 수 있습니다.

실행결과response의 status_code가 201이 반환되어야 하지만 404가 반환되어 테스트가 실패한 것을 확인할 수 있습니다.

그 이유는 404 KeyNotFound 에러를 통해 힌트를 얻을 수 있습니다. url이 올바르지 않아 회원가입 API가 정상적으로 작동되지 않은 것입니다. 이러한 실수를 줄이기 위해 reverse() 함수를 사용하여 url을 올바르게 적용했습니다.

  • URL reverse()는 url이 변경되더라도 url name을 통해 변경된 url을 추적해주는 함수입니다.
from django.urls import reverse ... reverse('signup')

url을 올바르게 적용한 후, 다시 실행한 결과 다음과 같은 결과를 확인했습니다.
response를 통해 받은 status_code가 201이 아닌 200인것을 확인 할 수 있었습니다.

치명적인 에러는 아니지만 보통의 경우 로그인, 회원가입의 POST요청 결과 status_code는 201 Created가 반환되어야 합니다. 하지만 그렇지 않았던 것을 확인했고, views.py 로직에서 status_code 201을 반환하도록 변경할 수 있었습니다.
로직 변경 결과 test를 통과할 수 있었고, 최종 회원가입API의 테스트 코드는 다음과 같이 작성되었습니다.

#회원가입 기능 관련 테스트케이스
class SignupTest(TestCase):
    # 테스트에 필요한 객체를 미리 생성
    def setUp(self):
        pass

    # 테스트 과정 중에 생긴 데이터 제거 
    # 다른 테스트와 연관성이 있어도 정확한 테스트 결과를 위해 Clean UP을 진행
    def tearDown(self):
        User.objects.all().delete()
    
    # 회원가입 기능이 성공적으로 동작하는지 테스트
    def test_signup_post_success(self):
        client = Client()

        signup_info = {
            "username" : "signTestUser",
            "password" : "test1234",
            "nickname" : "테스트사용자"
        }
        # json.dumps()로 객체를 json화 시켜줌
        response = client.post(reverse('signup'), json.dumps(signup_info), content_type='application/json')
        
        # assertEqual()로 response 받은 status code가 정상적인지 확인
        self.assertEqual(response.status_code, 201)

Login API 테스트

로그인 API 테스트는 회원가입 API 테스트와 달리 사용자 모델이 생성되어 있어야 합니다.
따라서 setUp()을 다음과 같이 설정할 수 있습니다.

# 로그인 기능 관련 테스트케이스
class LoginTest(TestCase):
    def setUp(self):
        self.url = reverse('login')
        self.user = User.objects.create(
            username="loginTestUser",
            nickname="로그인테스트사용자"
        )
        self.user.set_password("test0987")
        self.user.save()

setUp()을 통해 username이 "loginTestUser"인 사용자를 생성하고, 닉네임과 비밀번호를 설정했습니다. 또한 비밀번호는 set_password를 이용해 평문이 아닌 암호화를 진행했습니다.

로그인 API의 전체 코드는 다음과 같이 작성했습니다.

# 로그인 기능 관련 테스트케이스
class LoginTest(TestCase):
    def setUp(self):
        self.url = reverse('login')
        self.user = User.objects.create(
            username="loginTestUser",
            nickname="로그인테스트사용자"
        )
        self.user.set_password("test0987")
        self.user.save()

    def tearDown(self):
        User.objects.all().delete()

    # 로그인 기능 테스트
    def test_login_post_sucess(self):
        login_info = {
            "username" : "loginTestUser",
            "password" : "test0987"
        }
        response = self.client.post(self.url, data=login_info, format='json')
        self.assertEqual(response.status_code, 201)
    
    # 로그인시 아이디가 틀렸을 경우 에러 테스트
    def test_login_post_invalid_username(self):
        login_info = {
            "username" : "logTestUser",
            "password" : "test0987"
        }
        response = self.client.post(self.url, data=login_info, format='json')
        self.assertEqual(response.status_code, 400)
    
    # 로그인시 비밀번호가 틀렸을 경우 에러 테스트
    def test_login_post_invalid_password(self):
        login_info = {
            "username" : "loginTestUser",
            "password" : "test1234"
        }
        response = self.client.post(self.url, data=login_info, format='json')
        self.assertEqual(response.status_code, 400)

setUp()에서 테스트 데이터를 생성했기 때문에 tearDown()을 통해 테스트가 끝나면 데이터가 삭제되도록 Clean up을 했습니다. 또한 테스트 함수는 다음과 같이 세분화하여 작성해보았습니다.

  • test_login_post_sucess : 로그인 기능이 제대로 동작하는지 확인하는 함수
  • test_login_post_invalid_username : username이 틀렸을 경우 로직의 동작을 확인하는 함수
  • test_login_post_invalid_password : password가 틀렸을 경우 로직의 동작을 확인하는 함수

tests.py를 전체 실행할 수 있지만, 각기 다른 테스트 함수도 개별적으로 실행가능합니다.

  • python3 manage.py test <App이름>.<테스트파일 이름>.<class이름>.<test함수이름>
    ex) python3 manage.py test practise.tests.LoginTest.test_login_post_sucess

사용자 정보 조회 API 테스트

사용자 정보 조회 API의 작동을 위해서는 DRF-JWT의 access_token이 header에 있어야 사용자의 로그인 정보가 인증되어 올바른 결과를 반환 할 수 있습니다.

따라서 DRF에서 작동하는 JWT 토큰 인증방식의 API를 테스트하기 위해, Django 기본 테스트 클라이언트 클래스인 Client()가 아닌 DRF 테스트 클라이언트 APIClient()를 적용했습니다.

from rest_framework.test import APIClient

또한 header에 access_token을 담아 인증하기 위해 JavaScript의 credentials()을 활용하여 HTTP_Authorization에 Bearer 인증 방식을 테스트함수에 추가해줬습니다.

credentials()은 사용자의 자격증명을 요청하고, 저장된 자격증명을 반환하는 등의 작업을 수행하는 메서드입니다. 주로 사용자 인증 및 로그인 프로세스에서 사용됩니다.

전체 코드는 다음과 같습니다.

class UserInfoGetTest(TestCase):
    """
    1. json 객체와 status=200 정상적으로 반환되는지 테스트
    2. 토큰이 없을 경우 TokenErrorException class 호출에 따른 json message와 status=401 반환 테스트
    """
    def setUp(self):
        self.client = APIClient(enforce_csrf_checks=True)
        self.maxDiff = None
        
        self.user = User.objects.create(
            id = 1,
            username = "infoTestUser",
            nickname = "GET테스트사용자",
            profile = None
        )
        self.user.set_password('asdf1234')

        self.user.save()
    def tearDown(self):
        User.objects.all().delete()
    
    # 사용자 정보 조회 테스트
    def test_userinfo_success(self):
        login_user = {
            "username" : "infoTestUser",
            "password" : "asdf1234",
        }
        # 사용자 로그인
        login_response = self.client.post(reverse('login'), 
        data=login_user, format='json')
        # 로그인한 사용자의 access_token을 header에 인증
        self.client.credentials(
            HTTP_AUTHORIZATION=f"Bearer {login_response.json()['access_token']}"
        )
        # 사용자 정보 조회 response
        response = self.client.get(reverse('user-info'), content_type='application/json')
        # response status code 200 검증 
        self.assertEqual(response.status_code, 200)
        # response json 객체 검증
        self.assertEqual(response.json(),{
            'id' : 1,
            'nickname' : 'GET테스트사용자',
            'profile' : None
        })
    
    # 토큰이 없는 경우
    def test_userinfo_nottoken(self):
        self.client.credentials(
            HTTP_AUTHORIZATION=f"Bearer {''}"
        )
        response = self.client.get(reverse('user-info'), content_type='application/json')
        self.assertEqual(response.status_code, 401)
        self.assertEqual(response.json(), { 'detail' : 'unauthenticated'})
  • APIClient(enforce_csrf_checks=True)를 통해 CSRF 보호기능을 활성화 해줍니다.
  • self.maxDiff 속성을 사용하여 테스트 케이스에서 예상 결과와 실제 결과의 비교를 보다 효율적으로 관리하고, 필요한 경우에는 제한된 차이만을 출력할 수 있습니다.

회고

글에서 작성한 API 테스트 이외에도 로그인 세션유지를 위한 API UnitTest 코드 등 전체 코드는 github에 올려 두었습니다.
https://github.com/wodnrP/UnitTest_practise

기본적인 UnitTest 코드를 작성해보며 익숙하진 않지만 기본 Postman 테스트에 비해 훨씬 빠르다는 것을 느꼈습니다. 그리고 테스트 코드 작성 중 로직의 오류를 발견할 수 있어서 다시 한번 테스트 코드 작성의 중요성을 깨닫게 되었습니다. 이전보다 테스트에 많은 관심을 가지게 되었고, 개발 전 테스트 코드 작성을 통해 개발을 진행하는 TDD에 대해서도 흥미를 느껴 찾아보게 되었습니다.

작성한 글에서 부족하거나 잘못된 점이 있다면 언제든지 댓글 달아주시면 감사하겠습니다.
질문, 의견 모두 언제나 환영입니다.

profile
발전하는 꿈나무 개발자 / 취준생

0개의 댓글