테스트 주도 개발(TDD)은 일종의 개발 방식 또는 개발 패턴을 말한다. 무언가를 개발할 때 바로 개발부터 하는 것이 아니라 개발하려는 항목에 대한 점검 사항을 테스트코드로 만들고, 그 테스트를 통과시키는 방식으로 개발을 진행하는 방법이다.
위 그림의 왼쪽 처럼, 기존의 개발 방식은 단순한 웹 사이트를 만들 때 효율적일 수 있다. 하지만, 모델의 구조가 복잡하고, 기능이 다양하고, 페이지도 많은 웹 사이트를 만들 대는 해당 방식이 효율적이지 않을 수 있다. 왜냐하면 프로그램이 복잡해질수록 추가한 기능 사이에 상호 연관성이 점점 늘어나기 때문이다. 그러다 어떤 문제가 발생했을 때, 그 문제가 너무 많은 요소와 얽혀 있다면 아주 곤란할 것이다.
만약 개발을 한 단계씩 진행할 때마다 정석대로 테스트한다면, 이런 사고를 예방할 수 있을것이다. 하지만 개발할 프로그램이 복잡해질수록 매번 소스 코드를 테스트하는 것은 아주 번거로울 것이다. 그때 사용하는 방식이 테스트 주도 개발(TDD) 이다.
개발한 코드가 테스트를 만족하는지 자동으로 확인하면서 개발을 진행하므로 매번 직접 테스트하느라 지치지도 않고, 사고가 발생할 확률도 줄어들게 된다.
터미널에서 python manage.py test
라고 입력해보자. 그럼 다음과 같이 "0개의 테스트를 진행한 결과 OK가 나왔다"는 것을 볼 수 있다.
이제 blog/tests.py를 다음과 같이 수정한다.
from django.test import TestCase
class TestView(TestCase):
def test_post_list(self):
self.assertEqual(2, 3)
TestCase
클래스를 상속받고 'Test'로 시작하는 이름을 가진 클래스를 정의한다. 여기에서는 장고의 MTV 구조 중 뷰 측면에서 테스트하겠다는 의미로 TestView라는 이름으로 정의하였다.
그 안에 test
로 시작하는 이름으로 함수를 정의하는것이 테스트 코드를 작성할 때의 규칙이다. 즉, 테스트 메서드를 정의할 때는 test_
를 앞에 붙여야하는 명명규칙이 있다.
이후 python manage.py test
를 싱행해보면 FAILED가 나온다. 왜냐하면 tests.py의 6번째 줄의 결과가 2 != 3
이므로 테스트가 실패했기 때문이다.
테스트 코드 작성은 만들고자 하는 코드의 내용을 정리하는 것부터 시작하는게 좋다!
포스트 목록 페이지의 디자인을 떠올려보자.
이렇게 구상한 내용을 구체화한 테스트 코드를 작성해보자.
blog/test.py에 다음과 같이 작성할 내용을 주석으로 우선 작성하자.
그리고 하나의 TestCase 내에서 기본적으로 설정되어야 하는 내용이 있으면 setUp()
함수에서 정의하면 된다. setUp()
함수는 테스트마다 매번 자동 호출되고, 예외가 발생하면 테스트 케이스를 실패로 취급한다.
from django.test import TestCase, Client
class TestView(TestCase):
def setUp(self):
self.client = Client()
def test_post_list(self):
# 1.1 포스트 목록 페이지를 가져온다.
# 1.2 정상적으로 페이지가 로드된다.
# 1.3 페이지 타이틀은 'Blog'이다.
# 1.4 내비게이션 바가 있다.
# 1.5 Blog, About Me라는 문구가 내비게이션 바에 있다.
# 2.1 메인 영역에 게이물이 하나도 없다면
# 2.2 main_area에 '아직 게시물이 없습니다'라는 문구가 보인다
# 3.1 게시물이 2개 있다면
# 3.2 포스트 목록 페이지를 새로고침했을 때
# 3.3 메인 영역에 포스트 2개의 타이틀이 존재한다
# 3.4 '아직 게시물이 없습니다'라는 문구는 더 이상 보이지 않는다
TestCase
를 이용한 테스트 방식은 실제 데이터베이스는 건드리지 않고 가상의 데이터베이스를 새로 만들어 테스트한다.
왜냐하면 이미 운영되고 있는 서버의 데이터베이스를 건드린다면, 테스트를 진행할 때의 레코드가 생성, 수정, 삭제 등의 작업이 들어갈 수도 있기 때문이다.
이제 앞에서 작성한 주석대로 테스트 코드를 다음과 같이 작성해보자.
from django.test import TestCase, Client
from bs4 import BeautifulSoup
from .models import Post
class TestView(TestCase):
def setUp(self):
self.client = Client()
def test_post_list(self):
# 1.1 포스트 목록 페이지를 가져온다.
response = self.client.get('/blog/')
# 1.2 정상적으로 페이지가 로드된다.
self.assertEqual(response.status_code, 200)
# 1.3 페이지 타이틀은 'Blog'이다.
soup = BeautifulSoup(response.content, 'html.parser')
self.assertEqual(soup.title.text, 'Blog')
# 1.4 내비게이션 바가 있다.
navbar = soup.nav
# 1.5 Blog, About Me라는 문구가 내비게이션 바에 있다.
self.assertIn('Blog', navbar.text)
self.assertIn('About Me', navbar.text)
# 2.1 메인 영역에 게이물이 하나도 없다면
self.assertEqual(Post.objects.count(), 0)
# 2.2 main_area에 '아직 게시물이 없습니다'라는 문구가 보인다
main_area = soup.find('div', id='main-area')
self.assertIn('아직 게시물이 없습니다.', main_area.text)
# 3.1 게시물이 2개 있다면
post_001 = Post.objects.create(
title='첫 번째 포스트입니다.',
content="Hello World.",
)
post_002 = Post.objects.create(
title='두 번째 포스트입니다.',
content="We are the World.",
)
self.assertEqual(Post.objects.count(), 2)
# 3.2 포스트 목록 페이지를 새로고침했을 때
response = self.client.get('/blog/')
soup = BeautifulSoup(response.content, 'html.parser')
self.assertEqual(response.status_code, 200)
# 3.3 메인 영역에 포스트 2개의 타이틀이 존재한다
main_area = soup.find('div', id='main-area')
self.assertIn(post_001.title, main_area.text)
self.assertIn(post_002.title, main_area.text)
# 3.4 '아직 게시물이 없습니다.'라는 문구는 더 이상 보이지 않는다
self.assertNotIn('아직 게시물이 없습니다', main_area.text)
1.1 : 장고 테스트에서 client
는 테스트를 위한 가상의 사용자이다. self.client.get('/blog/')
로 사용자가 웹 브라우저에 '127.0.0.1:8000/blog/'를 입력했다고 가정하고 그때 열리는 웹 페이지 정보를 response
에 저장.
1.2 : 웹 개발 분야에서는 서버에서 요청한 페이지를 찾을 수 없을 때 404 오류를 돌려주고, 성공적으로 결과를 돌려줄 때는 200을 보내도록 약속되어 있다. 페이지가 정상적으로 열렸다면 status_code
의 값으로 200이 나온다.
1.3 : 불러온 페이지 내용은 HTML로 되어있다. HTML 요소에 쉽게 접근하기 위해 먼저 Beautifulsoup로 읽어들이고, html.parser
명령어로 파싱한 결과를 soup
에 담는다. 그리고 self.assertEqual(soup.title.text, 'Blog')
로 titlte 요소에서 텍스트만 가져와 그 텍스트가 Blog인지 확인한다.
1.4 : soup.nav
로 soup
에 담긴 내용 중 nav
요소만 가져와 navbar
에 저장
1.5 : navbar
의 텍스트 중에 Blog와 About me가 있는지 확인
2.1 : self.assertEqual(Post.objects.count(), 0)
으로 작성된 포스트가 0개인지 확인한다. 테스트가 시작되면 테스트를 위한 새 데이터베이스를 임시로 만들어 진행한다. 단 setUp()
함수에서 설정한 요쇼는 포함시킨다.
그런데 현재 setUp()
함수는 테스트를 위해 생성된 데이터베이스에 어떤 정보도 미리 담아 놓으라는 말이 없으므로 테스트 데이터베이스에는 현재 포스트가 하나도 없어야 한다!
2.2 : id가 main-area
인 div
요소를 찾아 main_area
에 저장한다. 그리고 데이터베이스에 저장된 post 레코드가 하나도 없으니 메인 영역에 '아직 게시물이 없습니다'라는 문구가 나타나는지 점검.
3.1 : Post 레코드가 데이터베이스에 존재하는 상황도 테스트하기 위해 포스트를 2개 만든다. Post.objects.create()
로 새로운 포스트를 만들 수 있다. 그리고 테스트 데이터베이스에 포스트 2개가 잘 생성되어 있는지 확인한다.
3.2 : 페이지를 새로고침하기 위해 1.1부터 1.3의 과정을 일부 반복한다.
3.3 : 새로 만든 두 포스트의 타이틀이 id
가 main-area
인 요소에 있는지 확인한다.
3.4 : 포스트가 생성되었으니 '아직 게시물이 없습니다'라는 문구가 메인 영역에 더 이상 나타나지 않아야 한다.
python manage.py test
를 실행하면 결과는 실패로 나온다...
오류 메시지를 보면, self.assertIn('아직 게시물이 없습니다', main_area.text)
가 문제라고 알려준다. NoneType
객체에 text라는 attribute가 없다고도 알려준다.
이는 아직 post_list.html 파일에 id가 main-area
인 div
요소를 만들지 않았기 때문에 main_area
에 None이 저장된 것 이다.
post_list.html을 다음과 같이 수정하자.
<div class="container my-3">
<div class="row">
<div class="col-md-8 col-lg-9" id="main-area"> <!-- id="main-area" 추가 -->
<h1>Blog</h1>
{% if post_list.exists %} <!-- 객체가 있는지 없는지 확인 -->
{% for p in post_list %}
<!-- Blog post-->
<div class="card mb-4">
{% if p.head_image %}
<img class="card-img-top" src="{{ p.head_image.url }}" alt="{{ p }} head image" />
{% else %}
<img class="card-img-top" src="http://picsum.photos/seed/{{ p.pk }}/800/200" alt="random image">
{% endif %}
<div class="card-body">
<h2 class="card-title h4">{{ p.title }}</h2>
{% if p.hook_text %}
<h5 class="text-muted">{{ p.hook_text }}</h5>
{% endif %}
<p class="card-text">{{ p.content | truncatewords:45 }}</p>
<a class="btn btn-primary" href="{{ p.get_absolute_url }}">Read more →</a>
</div>
<div class="card-footer small text-muted">Posted on {{ p.created_at }} by <a href="#">작성자명 쓸 위치(개발예정)</a>
</div>
</div>
{% endfor %}
{% else %}
<h3>아직 게시물이 없습니다.</h3> <!-- 게시물이 없을때 문구 출력 추가 -->
{% endif %}
(템플릿에서 함수를 사용할 때 괄호를 입력하지 않는것은 까먹지 말자!!)
이제 다시 테스트를 돌리면 성공이 나온다.
앞서 포스트 목록 페이지의 테스트 코드를 작성하기 전에 주석으로 정리한 것 처럼, 포스트 상세 페이지에 꼭 필요하다고 판단되는 기능을 주석으로 정리해보자.
또한 blog/tests.py의 test_post_list()
함수 아래에 test_post_detail()
함수를 새로 만들자.
(..생략..)
def test_post_detail(self):
# 1.1 포스트가 하나 있다
# 1.2 그 포스트의 url은 '/blog/1/'이다
# 2. 첫 번째 포스트의 상세 페이지 테스트
# 2.1 첫 번째 포스트의 url로 접근하면 정상적으로 작동한다(status code:200)
# 2.2 포스트 목록 페이지와 똑같은 내비게이션 바가 있다
# 2.3 첫 번째 포스트의 제목이 웹 브라우저 탭 타이틀에 들어 있다
# 2.4 첫 번째 포스트의 제목이 포스트 영역에 있다
# 2.5 첫 번째 포스트의 작성자가 포스트 영역에 있다(아직 구현할 수 없음)
# 2.6 첫 번째 포스트의 내용이 포스트 영역에 있다
주석으로 쓴 테스트 항목을 실제 테스트 코드로 변환한다.
(..생략..)
def test_post_detail(self):
# 1.1 포스트가 하나 있다
post_001 = Post.objects.create(
title = '첫 번째 포스트입니다.',
content='Hello World'
)
# 1.2 그 포스트의 url은 '/blog/1/'이다
self.assertEqual(post_001.get_absolute_url(), '/blog/1/')
# 2. 첫 번째 포스트의 상세 페이지 테스트
# 2.1 첫 번째 포스트의 url로 접근하면 정상적으로 작동한다(status code:200)
response = self.client.get(post_001.get_absolute_url())
self.assertEqual(response.status_code, 200)
soup = BeautifulSoup(response.content, 'html.parser')
# 2.2 포스트 목록 페이지와 똑같은 내비게이션 바가 있다
navbar = soup.nav
self.assertIn('Blog', navbar.text)
self.assertIn('About Me', navbar.text)
# 2.3 첫 번째 포스트의 제목이 웹 브라우저 탭 타이틀에 들어 있다
self.assertIn(post_001.title, soup.title.text)
# 2.4 첫 번째 포스트의 제목이 포스트 영역에 있다
main_area = soup.find('div', id='main-area')
post_area = main_area.find('div', id='post-area')
self.assertIn(post_001.title, post_area.text)
# 2.5 첫 번째 포스트의 작성자가 포스트 영역에 있다(아직 구현할 수 없음)
# 아직 작성 불가
# 2.6 첫 번째 포스트의 내용이 포스트 영역에 있다
self.assertIn(post_001.content, post_area.text)
1.1 : test_post_detail()
함수를 실행하면 다시 새 데이터베이스를 만든다. 새 데이터베이스에는 아무 것도 없는 상태이므로 포스트를 하나 만든다
1.2 : 첫 번째 포스트(post_001)가 만들어 졌고, 이 포스트 레코드의 pk는 1이다. 즉, 이 포스트의 URL은 '/blog/1/'이 된다
2.1 : '/blog/1/'로 접근했을 때 status_code
값으로 200이 반환되는지 확인한다. 그리고 이 페이지를 Beautifulsoup로 파싱하여 다루기 편하게 한다.
2.2 : 내비게이션 바의 텍스트가 포스트 목록 페이지의 것과 똑같은지 점검
2.3 : 이 포스트의 title 필드 값이 웹 브라우저 탭의 타이틀에 있는지 확인
2.4 : 메인 영역에서 포스트 영역만 불러온다. id='main-area
인 div
요소를 찾고, 그 안에서 id='post-area'
인 div
요소를 찾아 post_area
에 담는다. 그리고 post_001 포스트의 title 필그 값이 포스트 영역 안에 있는지 확인.
2.5 : 작성자가 화면에 보이는지 확인하는 내용. 하지만 아직 작성자 부분은 구현하지 않았기 때문에 패스
2.6 : 마지막으로 post_001의 내용이 포스트 영역에 있는지 확인
이제 테스트를 돌려보자. 그럼 다음과 같이 실패 메시지가 나온다.
확인해보니 navbar.text에 Blog라는 문구가 안보인다고 하고 있다.
이것은 startbootstrap.com의 디자인을 그대로 가져와서 적용하면서, 내비게이션 바는 수정하지 않았기 때문이다.
해당 부분은 다음장에서 다루겠다.
TestCase를 이용한 테스트 방식은 실제 데이터베이스는 건드리지 않고 가상의 데이터베이스를 새로 만들어 테스트
테스트 메서드를 정의할 때는 test_
를 앞에 붙여야하는 명명규칙이 존재.
def setUp(self)
: 테스트마다 매번 자동 호출. 예외가 발생하면 테스트 케이스를 실패로 취급
함께 보면 좋은 내용
https://kkn1125.github.io/blog/django-testcase01/