본문 바로가기
DEV/Web 개발

Web 개발 :: Django DRF Test Code 활용하기

by 올커 2022. 11. 9.

Django DRF Test Code 활용하기

01. 테스트 코드

- Django에서 startapp을 통해 만들어진 앱에는 test.py라는 파일이 자동으로 생성된다. 이 파일에 임의의 테스트코드를 적고 터미널에서 아래와 같이 입력하면 테스트 결과를 출력해준다.

python manage.py test

- 임의로 아래와 같이 test.py에 작성하고 위의 명령어를 통해 test하면 결과를 출력해주는 것을 확인할 수 있다.

# test.py

from django.test import TestCase

# Create your tests here.
class TestView(TestCase):
    def test_two_is_three(self):
        self.assertEqual(2,3)

    def test_two_is_two(self):
        self.assertEqual(2,2)
        
        
"""
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F.
======================================================================
FAIL: test_two_is_three (users.tests.TestView)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...", line 6, in test_two_is_three
    self.assertEqual(2,3)
AssertionError: 2 != 3

----------------------------------------------------------------------
Ran 2 tests in 0.002s

FAILED (failures=1)
Destroying test database for alias 'default'...
"""

02. 실습(1)_회원가입에 대한 테스트

- 아래처럼 test.py에 user_data를 url과 함께 client.post를 보내고, status_code가 어떻게 출력되는지 테스트 코드를 작성하여 확인할 수 있다.

# users/test.py

from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status


class UserRegistrationAPIViewTestCase(APITestCase):
    def test_registration(self):
        url = reverse("user_view")      # reverse를 통해 "user_view"에 해당되는 name의 url을 가져온다.
        user_data = {
            "username" : "testuser",            
            "fullname" : "테스터",            
            "email" : "test@testuser.com",            
            "password" : "password",                
        }
        response = self.client.post(url, user_data)     # url, user_data를 담아 client.post를 보내고 response를 받아온다.
        self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인
        
        
"""
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.165s

OK
Destroying test database for alias 'default'...
"""

- 에러를 발생시켰을 때도 마찬가지로 확인해볼 수 있다. 이 때 print(response.data)를 아래처럼 추가해주었다.

# users/test.py

from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status


class UserRegistrationAPIViewTestCase(APITestCase):
    def test_registration(self):
        url = reverse("user_view")      # reverse를 통해 "user_view"에 해당되는 name의 url을 가져온다.
        user_data = {
            "username" : "testuser",            
            # "fullname" : "테스터",            
            "email" : "test@testuser.com",            
            "password" : "password",                
        }
        response = self.client.post(url, user_data)     # url, user_data를 담아 client.post를 보내고 response를 받아온다.
        print(response.data)
        self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인
        
        
"""
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
{'message': "${'fullname': [ErrorDetail(string='This field is required.', code='required')]}"}
F
======================================================================
FAIL: test_registration (users.tests.UserRegistrationAPIViewTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "...", line 17, in test_registration
    self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인
AssertionError: 400 != 200

----------------------------------------------------------------------
Ran 1 test in 0.019s

FAILED (failures=1)
Destroying test database for alias 'default'...
"""

03. 실습(2)_로그인에 대한 테스트

- 위에서 회원가입 테스트 코드를 짰으니, 바로 아래에 로그인에 대한 테스트 코드를 짜서 오류가 발생하는지 확인해본다. url만 사용하고 있는 "token_obtain_pair"로 바꾸어주면 된다.

# users/test.py

from django.urls import reverse
from rest_framework.test import APITestCase
from rest_framework import status


class UserRegistrationAPIViewTestCase(APITestCase):
    def test_registration(self):
        url = reverse("user_view")      # reverse를 통해 "user_view"에 해당되는 name의 url을 가져온다.
        user_data = {
            "username" : "testuser",            
            "fullname" : "테스터",            
            "email" : "test@testuser.com",            
            "password" : "password",                
        }
        response = self.client.post(url, user_data)     # url, user_data를 담아 client.post를 보내고 response를 받아온다.
        print(response.data)
        self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인


    def test_login(self):
        url = reverse("token_obtain_pair")
        user_data = {
            "username" : "testuser",            
            "fullname" : "테스터",            
            "email" : "test@testuser.com",            
            "password" : "password",                
        }
        response = self.client.post(url, user_data)
        print(response.data)
        self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인
        
"""
Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
{'detail': ErrorDetail(string='No active account found with the given credentials', code='no_active_account')}
F{'message': '가입 완료!!'}
.
======================================================================
FAIL: test_login (users.tests.UserRegistrationAPIViewTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "D:\JAY\sp_prjs\ai2_back\users\tests.py", line 30, in test_login
    self.assertEqual(response.status_code, 200)     # status_code가 200인지 확인
AssertionError: 401 != 200

----------------------------------------------------------------------
Ran 2 tests in 0.327s

FAILED (failures=1)
Destroying test database for alias 'default'...
"""

- 그런데 이상한 점은 분명 위에서 회원가입을 했는데 login 부분에서 에러가 발생한다. 이는 테스트할 때에는 테스트 db를 사용하고, 하나의 테스트가 끝나면 테스트 db가 초기화 되기 때문이다. (*각 test끼리는 independent이며 stateless하다)
- 이를 방지하는 방법은 setUp이라는 메서드를 사용하는 것이다.(*참고 링크)
아래 샘플 코드에서 볼 수 있듯이 setUp이라는 메서드에서 정의한 것들을 아래 test_animals_can_speak에서 그대로 사용하고 있다. 이렇듯 setUp이라는 이름을 사용하는 메서드에서 사전에 정의해준다면 다른 테스트에서도 사용이 가능한 것이다.

from django.test import TestCase
from myapp.models import Animal

class AnimalTestCase(TestCase):
    def setUp(self):
        Animal.objects.create(name="lion", sound="roar")
        Animal.objects.create(name="cat", sound="meow")

    def test_animals_can_speak(self):
        """Animals that can speak are correctly identified"""
        lion = Animal.objects.get(name="lion")
        cat = Animal.objects.get(name="cat")
        self.assertEqual(lion.speak(), 'The lion says "roar"')
        self.assertEqual(cat.speak(), 'The cat says "meow"')

- 또 다른 문서에서 보면 setUp, 그리고 테스트가 마무리 되었을 때에는 tearDown을 사용할 수 있다고 나와있다. (*참고 링크)

class YourTestClass(TestCase):
    def setUp(self):
        # Setup run before every test method.
        pass

    def tearDown(self):
        # Clean up run after every test method.
        pass

    def test_something_that_will_pass(self):
        self.assertFalse(False)

    def test_something_that_will_fail(self):
        self.assertTrue(False)

- 위에서 확인했던 setUp 메서드를 이용해서 로그인 테스트 코드를 다시 작성해보면 정상적으로 작동됨을 확인할 수 있다.

# users/test.py

class LoginUserTest(APITestCase):
    def setUp(self):
        self.data = {'username':'john', 'password':'johnpassword'}
        self.user = User.objects.create_user('john', 'johnpassword')        # create_user는 models.py에서 정의해준 함수

    def test_login(self):
        response = self.client.post(reverse('token_obtain_pair'), self.data)
        print(response.data["access"])
        self.assertEqual(response.status_code, 200)
        
"""
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
가입
eyJ0eXAiOiJKV1QiLCJ...			# 토큰값
.
----------------------------------------------------------------------
Ran 1 test in 0.323s

OK
Destroying test database for alias 'default'...
"""

04. 실습(3)_사용자 정보 가져오기 테스트

- 회원가입, 그리고 로그인이 되면, 로그인이 된 사용자의 정보를 가져오려한다.
- 아래 코드를 보면 access_token이라느 곳에 'token_obtain_pair'의 url에서 post를 한 결과를 .data로 가져오고 'access'라는 키를 통해 접근하여 토큰값을 가져오도록 코드를 작성하였다.
- response는 get의 파라미터로 path, HTTP_AUTHORIZATION가 있으며, path에는 "user_view"라는 이름(urls.py에서 name 확인)으로 가져오고, HTTP_AUTHORIZATION은 헤더에 들어갈 내용을 f-string으로 작성하여 넣어 assertEqual을 통해 잘 가져오고 있는지 확인한다.
- 결과 확인은 아래 세 가지로 원하는 유형에 따라 변경하여 사용할 수 있다.

# users/test.py

class LoginUserTest(APITestCase):
    ...
    def test_get_user_data(self):
        access_token = self.client.post(reverse('token_obtain_pair'), self.data).data['access']
        response = self.client.get(
            path=reverse("user_view"),
            HTTP_AUTHORIZATION=f"Bearer {access_token}"
        )
        # print(response.data)
        # self.assertEqual(response.status_code, 200)
        self.assertEqual(response.data['username'], self.data['username'])

05. setUpTestData

- 그런데 setUp은 테스트 할 때마다 매번 실행되어야 하므로 비효율적이다. 몇 가지 항목은 처음 한번 셋업시 실행되고 계속 값을 가지고 있어도 되는 경우가 있다. 아래를 보면 setUp은 run once for every test mothod이지만, setUpTestData는 Run once to set up non-modified data이다. (*참고 링크)

class YourTestClass(TestCase):
    @classmethod
    def setUpTestData(cls):
        print("setUpTestData: Run once to set up non-modified data for all class methods.")
        pass

    def setUp(self):
        print("setUp: Run once for every test method to setup clean data.")
        pass

- 그런데 상단에 @classmethod라는 것이 보인다.


06. @classmethod, @staticmethod

- @classmethod : @classmethod가 상단에 붙은 함수는 클래스로 별도의 인스턴스를 생성하지 않아도 외부에서 해당 함수를 호출하여 사용이 가능하게 한다.
- @staticmethod : 클래스 내부에 정의된 함수를 클래스 밖에서 정의한 것과 동일하게 동작되도록 하는 것으로, 함수 밖에 정의하여도 상관없지만 특정 클래스 내부에 해당 함수를 정의하여 코드를 정리하고 싶을 경우에 사용한다.

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age
        
    @classmethod
    def fromBirthYear(cls, name, year):
        return cls(name, date.today().year-year)
        
    @staticmethod
    def isAdult(age):
        return age > 18
        
person1 = Person('mayank', 21)
person2 = Person.fromBirthYear('mayank', 1996)

print(person1.age)
print(person2.age)


# print the result
print(Person.isAdult(22))

07. setUp을 @classmethod로 변환하기

class ArticleCreateTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user_data = {'username':'john', 'password':'johnpassword'}
        cls.article_data = {"title":"some title", "content":"some content"}
        cls.user = User.objects.create_user('john', 'johnpassword')

    def setUp(self):
        self.access_token = self.client.post(reverse('token_obtain_pair'), self.user_data).data['access']
        

    # def setUp(self):
    #     self.user_data = {'username':'john', 'password':'johnpassword'}
    #     self.article_data = {"title":"some title", "content":"some content"}
    #     self.user = User.objects.create_user('john', 'johnpassword')
    #     self.access_token = self.client.post(reverse('token_obtain_pair'), self.user_data).data['access']
                                # 여기에선 self.client가 classmethod가 아니기 때문에 별도의 setUp 함수를 생성해야 한다.

08. 로그인 안한 상태에서 게시글 작성 테스트

class ArticleCreateTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.user_data = {'username':'john', 'password':'johnpassword'}
        cls.article_data = {"title":"some title", "content":"some content"}
        cls.user = User.objects.create_user('john', 'johnpassword')

    def setUp(self):
        self.access_token = self.client.post(reverse('token_obtain_pair'), self.user_data).data['access']
        

    # def setUp(self):
    #     self.user_data = {'username':'john', 'password':'johnpassword'}
    #     self.article_data = {"title":"some title", "content":"some content"}
    #     self.user = User.objects.create_user('john', 'johnpassword')
    #     self.access_token = self.client.post(reverse('token_obtain_pair'), self.user_data).data['access']
                                # 여기에선 self.client가 classmethod가 아니기 때문에 별도의 setUp 함수를 생성해야 한다.

    def test_fail_if_not_logged_in(self):
        url = reverse("article_view")
        response = self.client.post(url, self.article_data)
        self.assertEqual(response.status_code, 401)

09. 이미지가 없는 게시글 작성 테스트

# articles/test.py

from django.test import TestCase
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase

from users.models import User


...
class ArticleCreateTest(APITestCase):
    ...
    # 게시글 작성
    def test_create_article(self):
        response = self.client.post(
            path=reverse("article_view"),
            data=self.article_data,
            HTTP_AUTHORIZATION=f"Bearer {self.access_token}"
        )
        self.assertEqual(response.data["message"], "글 작성 완료!")
        # self.assertEqual(response.status_code, 200)

10. 이미지를 포함한 게시글 작성 테스트

- get_temporary_image에서는 임시 이미지 파일을 만드는 메서드이다.

# 이미지 업로드(임시파일을 만들어 활용하기)
from django.test.client import MULTIPART_CONTENT, encode_multipart, BOUNDARY
from PIL import Image
import tempfile

def get_temporary_image(temp_file):
    size = (200,200)
    color = (255, 0, 0, 0)
    image = Image.new("RGBA", size, color)
    image.save(temp_file, 'png')
    return temp_file
    
...
    
class ArticleCreateTest(APITestCase):
    ...
    # 이미지를 포함한 게시글 작성 테스트
    def test_create_article_with_image(self):
        # 임시 이미지 파일 생성
        temp_file = tempfile.NamedTemporaryFile()       # 임시 파일 만들기
        temp_file_name = "image.png"                    # 이름을 image.png로 지정
        image_file = get_temporary_image(temp_file)     # 이미지 파일 받아오기
        image_file.seek(0)                              # 이미지의 첫 번째 프레임을 받아오기
        self.article_data["image"] = image_file         # article_data에 이미지 파일을 넣기

        # 전송
        response = self.client.post(
            path=reverse("article_viiew"),
            data=encode_multipart(data = self.article_data, boundary=BOUNDARY),
            content_type=MULTIPART_CONTENT,
            HTTP_AUTHORIZATION = f"Berer {self.access_token}"
        )
        self.assertEqual(response.data["message"], "글 작성 완료!")

11. 더미데이터를 위한 Faker 사용

- 더미데이터를 처리하기 위해 Faker라는 라이브러리를 설치해서 사용할 수 있다.(*참고 링크)

pip install Faker

- 참고 1

from faker import Faker
fake = Faker()

fake.name()
# 'Lucy Cechtelar'

fake.address()
# '426 Jordy Lodge
#  Cartwrightshire, SC 88120-6700'

fake.text()
# 'Sint velit eveniet. Rerum atque repellat voluptatem quia rerum. Numquam excepturi
#  beatae sint laudantium consequatur. Magni occaecati itaque sint et sit tempore. Nesciunt
#  amet quidem. Iusto deleniti cum autem ad quia aperiam.
#  A consectetur quos aliquam. In iste aliquid et aut similique suscipit. Consequatur qui
#  quaerat iste minus hic expedita. Consequuntur error magni et laboriosam. Aut aspernatur
#  voluptatem sit aliquam. Dolores voluptatum est.
#  Aut molestias et maxime. Fugit autem facilis quos vero. Eius quibusdam possimus est.
#  Ea quaerat et quisquam. Deleniti sunt quam. Adipisci consequatur id in occaecati.
#  Et sint et. Ut ducimus quod nemo ab voluptatum.'

- 참고 2

from faker import Faker
fake = Faker()

my_word_list = [
'danish','cheesecake','sugar',
'Lollipop','wafer','Gummies',
'sesame','Jelly','beans',
'pie','bar','Ice','oat' ]

fake.sentence()
# 'Expedita at beatae voluptatibus nulla omnis.'

fake.sentence(ext_word_list=my_word_list)
# 'Oat beans oat Lollipop bar cheesecake.'

- 아래와 같이 작성 후 실행하면 파일을 매번 실행할 때 마다 가상 이름, 단어, 문장, 텍스트를 생성할 수 있다.

from faker import Faker

faker = Faker()

# 한국어로 사용할 경우
# faker = Faker("ko_KR")

# 랜덤한 이름 생성
print(faker.name())
print(faker.first_name())
print(faker.last_name())

# 랜덤한 단어 -> 비밀번호에 활용
print(faker.word())

# 랜덤한 한 문장
print(faker.sentence())

# 랜덤한 문장모음
print(faker.text())


12. Faker 사용하여 새로운 유저 만들기

# articles/test.py

class ArticleReadTest(APITestCase):
    @classmethod
    def setUpTestData(cls):
        cls.faker = Faker()
        cls.articles = []       # 비어있는 리스트를 만들고 article을 append해 줄 예정
        for i in range(10):
            cls.user = User.objects.create_user(cls.faker.name(), cls.faker.word())           # 새로운 유저 생성
            cls.articles.append(Article.objects.create(title=cls.faker.sentence(), content=cls.faker.text(), user=cls.user))


13. url 연결하기

- 먼저 ArticleDetailView는 아래와 같이 정의해준다.

# articles/views.py

...
class ArticleDetailView(APIView):
    def get(self, request, pk):
        article = ArticleModel.objects.get(pk=pk)
        serializer = ArticleSerializer(article)
        return Response(serializer.data)

- article.get_absolute_url을 사용하여 url을 가져오고 있다. get_absolute_url은 models.py에서 지정해준 메서드이다.

# articles/test.py

class ArticleReadTest(APITestCase):
    ...
    def test_get_article(self):
        for article in self.articles:
            url = article.get_absolute_url()
            response = self.client.get(url)
            serializer = ArticleSerializer(article).data
            for key, value in serializer.items():
                self.assertEqual(response.data[key]. value)
                # print(key, value)

- article.get_absolute_url은 본인의 pk를 가진 값을 kwargs에 넣어서 article_detail_view에 보낸 후 reverse를 통해 url을 출력할 수 있게 된다.

# articles/models.py

from django.db import models

class Article(models.Model):
    ...

    def get_absolute_url(self):
        return reverse('article_detail_view', kwargs={"pk":self.pk})

- urls.py에 path는 아래와 같이 적혀있어야 한다.

# articles/urls.py

urlpatterns = [
    # user/
    path('', views.ArticleView.as_view(), name="article_view"),
    path('<int:pk>/', views.ArticleDetailView.as_view(), name='article_detail_view'),
 
]

14. serializer method field로 아티클에서 유저네임 받아오기

- article에서 유저id가 아닌 username으로 나타나게 하려 한다. user는 SerializerMethodField를 사용할 때 get_user라는 메서드를 지정하면 get_user를 통해 SerializerMethodField에 값을 전달하게 된다.

# articles/serializers.py

class ArticleSerializer(serializers.ModelSerializer):
    user = serializers.SerializerMethodField()

    def get_user(self, obj):
        return obj.user.username


    class Meta:
        model = ArticleModel
        fields = "__all__"

반응형

댓글