[TDD] Test-Driven Development with Python 3장

SUNGJIN KIM·2022년 6월 26일
0

tdd-with-python

목록 보기
3/7
post-thumbnail

˗ˋˏ♡ˎˊ˗ 단위 테스트를 이용한 간단한 홈페이지 테스트

  • 단위 테스트와 기능 테스트의 정의 및 차이
  • Django 내 단위 테스트
  • 뷰를 위한 단위 테스트

단위 테스트와 기능 테스트의 정의 및 차이

  • 기능 테스트는 사용자 관점에서 애플리케이션 외부를 테스트 하는 것
  • 단위 테스트는 프로그래머 관점에서 그 내부를 테스트하는 것
기능 테스트단위 테스트
관점사용자 관점프로그래머 관점
목표애플리케이션 외부애플리케이션 내부
레이어상위 레벨하위 레벨
목적제대로 된 기능성을 갖춘 애플리케이션을 구축깔끔하고 버그없는 코드를 작성

Django 내 단위 테스트

해당 명령어를 통해 작업 목록 앱을 생성해준다.
python3 manage.py startapp lists
superlists/superlists 와 같은 위치에 superlists/lists라는 폴더가 생성된다.

lists내의 test.py 파일을 열어 코드를 수정한다.

from django.test import TestCase

# Create your tests here.
class SmokeTest(TestCase):
    def test_bad_maths(self):
        self.assertEqual(1+1,3)

해당 코드를 실행해주면 아래와 같이 결과가 나온다.

$ python3 manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_bad_maths (lists.tests.SmokeTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/lists/tests.py", line 9, in test_bad_maths
    self.assertEqual(1+1,3)
AssertionError: 2 != 3

----------------------------------------------------------------------
Ran 1 test in 0.001s

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

이렇게 결과값이 나온다면 정상적인 기대결과값을 얻은 것이다.


Django 의 MVC, URL, 뷰 함수

Django 는 대체로 모델-뷰-컨트롤러(Model-View-Controller, MVC)라는 고전적인 패턴을 따른다.

주요 역할은 일반적인 웹 서버처럼 사용자가 특정 URL을 요청했을때 어떤 처리를 할지 결정하는 것이다.

Django의 처리 흐름은 다음과 같다.

  1. 특정 URL에 대한 HTTP "요청"을 받는다.
  2. Django는 특정 규칙을 이용해서 해당 요청에 어떤 뷰 함수를 실행할지 결정한다. (URL "해석" 이라고 하는 처리다.)
  3. 이 뷰 기능이 요청을 처리해서 HTTP "응답"으로 반환한다.

이에 우리가 테스트 해야할 것은 2가지다.

  1. URL의 사이트 루트("/")를 해석해서 특정 뷰 기능에 매칭시킬 수 있는가?
  2. 이 뷰 기능이 특정 HTML을 반환하게 해서 기능 테스트를 통과할 수 있는가?

여기서 View 함수가 무엇을 의미하냐면,
View는 필요한 데이타를 모델 (혹은 외부)에서 가져와서 적절히 가공하여 웹 페이지 결과를 만들도록 컨트롤하는 역활을 한다.

참고 자료 : http://pythonstudy.xyz/python/article/306-Django-%EB%B7%B0-View

1. URL의 사이트 루트를 해석하여 특정 뷰 기능에 매칭시킬 수 있는가?

lists/test.py

from django.urls import resolve
from django.test import TestCase
from lists.views import home_page


# Create your tests here.
# class SmokeTest(TestCase):
#     def test_bad_maths(self):
#         self.assertEqual(1+1,3)

class HomePageTest(TestCase):
    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)
    

책에 써있는 코드와 조금 다르게 작성하였는데, 이는 아래 마무리 단계를 참고하면 될 것 같다.

해당 코드를 실행하면 에러가 발생하게 된다.
이유는 아직 존재하지 않는 무언가를 import 하려고 했기 때문이다.

실패 테스트를 진행해봤으니 이를 해결하기 위한 최소한의 수정만 해보도록 한다.

2. 실패 테스트에 대한 수정 - 1

현재 윗 상황에서 실패한 이유는 lists.views 에서 home_page를 import 할 수 없는 상태이기 때문이다.
이에 views.py의 코드를 아래와 같이 수정해주었다.

from django.shortcuts import render

# Create your views here.
home_page = None

코드 수정 후, 해당 코드를 실행해보면 아래와 같이 결과가 나온다.

Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E
======================================================================
ERROR: test_root_url_resolves_to_home_page_view (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/lists/tests.py", line 13, in test_root_url_resolves_to_home_page_view
    found = resolve('/')
  File "python3.9/site-packages/django/urls/base.py", line 24, in resolve
    return get_resolver(urlconf).resolve(path)
  File "/python3.9/site-packages/django/urls/resolvers.py", line 683, in resolve
    raise Resolver404({"tried": tried, "path": new_path})
django.urls.exceptions.Resolver404: {'tried': [[<URLResolver <URLPattern list> (admin:admin) 'admin/'>]], 'path': ''}

----------------------------------------------------------------------
Ran 1 test in 0.001s

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

해당 TraceBack을 살펴보면 아래와 같은 내용을 확인할 수 있다.
1. 어떤 테스트가 실패하고 있는가? (현재는 예측된 실패)
2. 어떤 에러가 발생했는가?
3. 어느 부분에서 에러가 발생했는가? (어느 코드에서 에러가 발생했는지)

해당 오류는 "/" 확인 시, Django가 404에러를 발생시켜서 발생하는 오류이다.
해당 문제를 해결해보자.

3. 실패 테스트에 대한 수정 - 2

url.py 코드에 아래의 내용을 추가한다.

superlists/urls.py

  • 책에 작성되어있는 코드랑 다른 부분이 있는데, 해당 부분은 마무리에 내용을 추가해 기재하니 참고하면 될 듯 하다.
from django.contrib import admin
from django.urls import path, include
from lists import views

urlpatterns = [
    # Examples : 
    path('', views.home_page, name='home'),
    # path('^blog/',include('blog.urls')),
    # path('^admin/', include(admin.site.urls)),
]

해당 코드로 단위 테스트를 다시 실행해보면 아래와 같이 결과가 나온다.

  File "/superlists/urls.py", line 26, in <module>
    path('', views.home, name='home'),
AttributeError: module 'lists.views' has no attribute 'home'

이를 미루어 볼 때, lists.views.home이 존재하지 않는다고 이해가 된다.
해당 객체를 home_page를 가리키게 변경한다.

path('', views.home_page, name='home'),

    raise TypeError(
TypeError: view must be a callable or a list/tuple in the case of include().

변경했을때에도, home_page를 호출할 수 없다는 메시지가 뜨고있으나 이전과는 다르다.

home_page가 아직 함수가 아니기에 이와 같은 오류가 발생하므로 이전에 작성한 Home_page = None 부분을 실제 함수로 변경해본다.

lists/views.py

from django.shortcuts import render

# Create your views here.
# home_page = None

def home_page():
    pass

변경하고 테스트 실행 시, 첫 단위 테스트가 성공하게 된다.

$ python3 manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

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

뷰를 위한 단위 테스트

뷰를 위한 테스트를 작성할 때는 단순히 빈 함수를 작성하는 것이 아닌 HTML 형식의 실제 응답을 반환하는 함수를 작성해야 한다.

test.py 내 해당 테스트를 추가한다.

lists/tests.py

from urllib import response
from django.urls import resolve
from django.test import TestCase
from lists.views import home_page
from django.http import HttpRequest

class HomePageTest(TestCase):
    def test_root_url_resolves_to_home_page_view(self):
        found = resolve('/')
        self.assertEqual(found.func, home_page)
    
    def test_home_page_returns_correct_html(self):
        request = HttpRequest()
        response = home_page(request)
        self.assertTrue(response.content.startswith(b'<html>'))
        self.assertIn(b'<title>To-Do lists</title>', response.content)
        self.assertTrue(response.content.endswith(b'</html>'))

단위 테스트를 실행해서 결과를 확인해보면 아래와 같다.

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
E.
======================================================================
ERROR: test_home_page_returns_correct_html (lists.tests.HomePageTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "lists/tests.py", line 20, in test_home_page_returns_correct_html
    response = home_page(request)
TypeError: home_page() takes 0 positional arguments but 1 was given

----------------------------------------------------------------------
Ran 2 tests in 0.001s

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

TDD 단위 테스트 - 코드 주기

  1. 터미널에서 단위 테스트를 실행해서 어떻게 실패하는지 확인한다.
  2. 편집기상에서 현재 실패 테스트를 수정하기 위한 최소한의 코드를 변경한다.

코드의 품질을 높이고 싶으면 코드 변경을 최소화해야한다.
이렇게 최소화한 코드는 하나하나 테스트에 의해 검증되어야 한다.
자신이 있어도 작은 단위로 나누어 코드를 변경해야한다.

예시) 최소한의 코드변경 - 테스트 반복

1) list/views.py

def home_page(request):
    pass

2) 1번 내용 테스트

AttributeError: 'NoneType' object has no attribute 'content'

3) django.http.httpRespnse 사용

from django.shortcuts import render
from django.http import HttpResponse

# Create your views here.
# home_page = None

def home_page(request):
    return HttpResponse()

4) 3번 내용 테스트

Traceback (most recent call last):
  File "/lists/tests.py", line 21, in test_home_page_returns_correct_html
    self.assertTrue(response.content.startswith(b'<html>'))
AssertionError: False is not true

5) response 값 추가

def home_page(request):
    return HttpResponse('<html><title>To-Do lists</title>')

6) 5번 내용 테스트

Traceback (most recent call last):
  File "lists/tests.py", line 23, in test_home_page_returns_correct_html
    self.assertTrue(response.content.endswith(b'</html>'))
AssertionError: False is not true

7) 마지막 테스트

def home_page(request):
    return HttpResponse('<html><title>To-Do lists</title></html>')

8) 7번 내용 테스트 (성공!)

Found 2 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.001s

OK

해당 테스트가 끝나고 난 후, 단위 테스트를 진행해본다.

F
======================================================================
FAIL: test_can_start_a_list_and_retrieve_it_later (__main__.NewVisitorTest)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/Users/woonmong/python/wm-tdd/tdd_project/django_project/superlists/functional_test.py", line 21, in test_can_start_a_list_and_retrieve_it_later
    self.fail('Finish the test!')
AssertionError: Finish the test!

----------------------------------------------------------------------
Ran 1 test in 3.393s

FAILED (failures=1)

여기서는 실패는 작업 완료 메시지를 출력하기 위해 심어둔 AssertionError 때문이고, 성공한 것이다.
드디어 웹 페이지를 가지게 되었다.

git Commit

3일차, 3장의 내용도 커밋 후 복습을 해본다.

3일차 실습 내용 :
https://github.com/woonmong712/wm-tdd


마무리

진행 중 발생한 특이 사항에 대해 공유해보면 아래와 같다.

1) django.core.urlresolvers Error

튜토리얼을 그대로 따라하던 중, 해당 구문에서 에러가 발생하였다.

from django.core.urlresolvers import resolve

확인해보니, 해당 문제는 Django 버전 문제로, Django 2.0에서는 django.core.urlresolvers 모듈을 삭제했기 때문에 발생하는 오류였다.

코드를 아래와 같이 바꿔주면 정상적으로 사용할 수 있다.

from django.urls import resolve

2) ImportError: cannot import name 'patterns' from 'django.urls'

Django 1.8 이후로는 patterns 가 없어졌다고 한다.
As of Django 1.10, the patterns module has been removed (it had been deprecated since 1.8).

그냥 이렇게 사용하면 된다.

urlpatterns = [
    # Examplets:
    url(r'^$','superlists.views.home', name='home'),
    # url(r'^blog/, include('blog.urls')),
    #url(r'^admin/', include(admin.site.urls)),
]

3) ImportError: cannot import name 'url' from 'django.urls'

이것도 마찬가지로 Django == 4.0 버전부터 없어졌다고 한다.
https://forum.djangoproject.com/t/django-4-0-url-import-error/11065

url 대신 path 로 바꿔서 사용하면 된다.

from django.urls import path, include

# urlpatterns = [
#     path('admin/', admin.site.urls),
# ]

urlpatterns = [
    # Examples : 
    path(r'^$', 'superlists.views.home', name='home'),
    # path(r'^blog/',include('blog.urls')),
    # path(r'^admin/', include(admin.site.urls)),
]

코드 변경 및 기대 결과 확인
책이 이전버전이여서, 현재 버전에 맞게 코드를 수정해주었다.
수정해준 다음에 진행했을때 드디어 정상적으로 원하는 기대결과를 얻을 수 있었다.

from django.contrib import admin
from django.urls import path, include
from lists import views

# urlpatterns = [
#     path('admin/', admin.site.urls),
# ]


urlpatterns = [
    # Examples : 
    path('', views.home_page, name='home'),
    # path('^blog/',include('blog.urls')),
    # path('^admin/', include(admin.site.urls)),
]

성공 결과 :

Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

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


참고 도서 : 클린 코드를 위한 테스트 주도 개발 (비제이퍼블릭,2015)
profile
#QA #woonmong

0개의 댓글