테스트 주도 개발(Test-Driven Development, TDD)
은 소프트웨어 개발 방법론 중 하나로, 테스트 코드를 먼저 작성한 후 실제 코드를 작성하는 방식이다. 이 방법론은 코드의 품질을 높이고, 유지보수성을 향상시키며, 버그를 줄이는 데 도움을 준다. TDD는 보통 "레드-그린-리팩터" 사이클을 통해 이루어진다.
Red (레드) - 실패하는 테스트 작성
: 먼저, 작성하고자 하는 기능이나 메서드에 대한 테스트 케이스를 작성한다. 해당 기능이 구현되지 않았기 때문에 이 단계에서 테스트는 실패해야 한다.
Green (그린) - 기능 구현 및 테스트 통과
: 테스트를 통과하게 만들기 위해 최소한의 코드를 작성한다. 목표는 최대한 빠르게 테스트가 통과하도록 하는 것이며, 이때 작성된 코드는 최대한 간단하게 작성된다.
Refactor (리팩터) - 코드 리팩터링
: 테스트가 통과한 후, 코드를 리팩터링하여 중복을 제거하고, 가독성을 높이며, 최적화한다. 리팩터링 후에도 테스트는 여전히 통과해야 하며 이 사이클을 반복하면서 기능을 점진적으로 완성한다.
준비한 모델은 4개이다. 4개의 모델은 다음과 같은 연결 구조를 가진다.
author, book은 서로 ManytoMany의 관계를 가지므로 중개테이블인 bookauthor로 간접연결 하였고 publisher는 book과 외래키 관계이다.
from django.test import TestCase
# Create your tests here.
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from ..models import Author, Book, BookAuthor, Publisher
class PublisherViewSetTestCase(APITestCase):
def setUp(self):
self.publisher_data_test = {
"name": "테스트 출판사",
"address": "123 테스트 거리",
"city": "테스트 시티",
"state_province": "테스트 주",
"country": "테스트 나라",
"website": "http://testpublisher.com",
"published_books": [
{
"title": "테스트 책 제목",
"authors": [
{
"salutation": "Mr.",
"name": "저자 이름",
"email": "author@example.com"
}
],
"publication_date": "2024-06-19"
}
]
}
self.author_data = {
"salutation": "Mr.",
"name": "저자 이름",
"email": "author@example.com"
}
self.book_data = {
"title": "테스트 책 제목",
"publication_date": "2024-06-19"
}
self.publisher_data = {
"name": "테스트 출판사",
"address": "123 테스트 거리",
"city": "테스트 시티",
"state_province": "테스트 주",
"country": "테스트 나라",
"website": "http://testpublisher.com"
}
self.author = Author.objects.create(**self.author_data)
self.publisher = Publisher.objects.create(**self.publisher_data)
self.book = Book.objects.create(publisher = self.publisher, **self.book_data)
self.book_author = BookAuthor.objects.create(book_id = self.book, author_id = self.author)
# URL 설정
self.publisher_url = reverse('publisher-list')
def test_create_publisher(self):
#when
response = self.client.post(self.publisher_url, self.publisher_data_test, format='json')
#then
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Publisher.objects.count(), 2)
self.assertEqual(response.data['name'], self.publisher_data['name'])
def test_get_publishers(self):
#given
initial_count = Publisher.objects.all().count()
#when
response = self.client.get(self.publisher_url)
#then
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['count'], initial_count) # 전체 개수와 데이터베이스에 있는 개수 비교
self.assertEqual(len(response.data['results']), initial_count) # results 리스트 길이 확인
first_publisher_data = response.data['results'][0] if int(response.data.get('count')) > 0 else {}
self.assertEqual(first_publisher_data['name'], self.publisher.name)
self.assertEqual(first_publisher_data['address'], self.publisher.address)
self.assertEqual(first_publisher_data['city'], self.publisher.city)
self.assertEqual(first_publisher_data['state_province'], self.publisher.state_province)
self.assertEqual(first_publisher_data['country'], self.publisher.country)
self.assertEqual(first_publisher_data['website'], self.publisher.website)
def test_get_single_publisher(self):
url = reverse('publisher-detail', args=[self.publisher.id])
response = self.client.get(url, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.publisher.name)
def test_update_publisher(self):
url = reverse('publisher-detail', args=[self.publisher.id])
updated_data = self.publisher_data.copy()
updated_data['name'] = '업데이트된 테스트 출판사'
response = self.client.put(url, updated_data, format='json')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.publisher.refresh_from_db()
self.assertEqual(self.publisher.name, '업데이트된 테스트 출판사')
def test_delete_publisher(self):
url = reverse('publisher-detail', args=[self.publisher.id])
response = self.client.delete(url, format='json')
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(Publisher.objects.count(), 0)
위와 같은 test를 작성했다. 간단히 /publisher/에 대해 POST:create, GET:list, GET:detail, UPDATE, DELETE를 테스트한다. 해당 endpoint를 처리하는 view는 다음과 같다.
#views.py
class PublisherViewSet(viewsets.ModelViewSet):
queryset = Publisher.objects.all()
serializer_class = PublisherSerializer
model = Publisher
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ['salutation', 'name', 'email', 'headshot']
class BookSerializer(serializers.ModelSerializer):
authors = AuthorSerializer(many=True, required=False)
class Meta:
model = Book
fields = ['title', 'authors', 'publication_date']
def create(self, validated_data):
authors_data = validated_data.pop('authors')
book = Book.objects.create(**validated_data)
for author_data in authors_data:
author, created = Author.objects.get_or_create(**author_data)
book.authors.add(author)
return book
class PublisherSerializer(serializers.ModelSerializer):
# BookSerailizer를 내부에 가짐
# Book은 여러개가 될 수 있음.
published_books = BookSerializer(many=True, required=False)
# Meta 내부 클래스
# model : 현재 모델 시리얼라이저의 기반 모델을 할당
# fields : 직렬화, 역직렬화에 사용할 필드들을 명시
class Meta:
model = Publisher
fields = ['name', 'address', 'city', 'state_province', 'country', 'website', 'published_books']
# ModelSerializer의 create를 오버라이드하여 만든 커스텀 create메서드
# POST시, 새로운 레코드 생성시 사용된다.
# 역직렬화 과정에서 필요한 메서드이다.
def create(self, validated_data):
# 역직렬화 후 검증에 성공한 데이터는 validated_data에 들어온다.
# validated_data.pop으로 books를 빼면서 할당받는다.
books_data = validated_data.pop('published_books')
# 파블리셔 인스턴스를 생성한다.
# validated_data안에서 필요한 놈들만 가져와서 create한다.
publisher = Publisher.objects.create(**validated_data)
# book데이터는 외래키 관계이므로 여러개이다.
if books_data:
for book_data in books_data:
# bookdata에는 다대다 관계로 표현된 author_data들이 존재한다.
# 각각의 book_data 아래에 0~n개 존재한다.
# pop으로 authors_data를 뺀다.
authors_data = book_data.pop('authors')
# 먼저 빼는 이유는 순수한 book만 Book에 전달하기 위함이다.
book = Book.objects.create(publisher=publisher, **book_data)
# Book이 모두 구성되었다면 authors_data도 넣는다.
for author_data in authors_data:
author, created = Author.objects.get_or_create(**author_data)
book.authors.add(author)
return publisher
nested serializer 구조를 사용하여 Publisher 모델 생성시 Book, Author또한 같이 생성되어야 하는 종속성을 부여했다.
Arrange-Act-Assert
에서 Arrange에 해당하는 부분이다. django는 테스트시 테스트 메서드마다 테스트 데이터베이스를 생성하고 끝나면 이를 제거한다. 생성 후 빈 테스트 DB에 생성자로써 작업을 해줄 부분이 set_up 메서드 내부 내용이다.
settings.py에 test_db를 따로 등록하지 않아도 되며, (필요하다면 하세요) set_up 내용의 상태로 시작한다고 생각하면 된다.
위의 코드를 참고해서 설명하면 set_up 부분에서
self.author = Author.objects.create(**self.author_data)
self.publisher = Publisher.objects.create(**self.publisher_data)
self.book = Book.objects.create(publisher = self.publisher, **self.book_data)
self.book_author = BookAuthor.objects.create(book_id = self.book, author_id = self.author)
각 레코드들을 생성해놓고 시작한다. Publisher에 연결된 객체들을 모두 생성해놓아야 GET시에 문제가 없기 때문이다.
def test_create_publisher(self):
#when
response = self.client.post(self.publisher_url, self.publisher_data_test, format='json')
#then
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Publisher.objects.count(), 2)
self.assertEqual(response.data['name'], self.publisher_data['name'])
위의 테스트 메서드를 확인해보자. Arrange-Act-Assert
에서 Arrange는 set_up메서드에서 이루어지니 act(when), assert(then)를 작성해주면 된다. response는 Act에 해당한다. self.client.post
로 하여금 테스트 request.post를 한다고 보면된다. set_up에서 확보해놓은 baseUrl에 준비된 self.publisher_data_test를 json형태로 날려보낸다.
assert메서드 들은 모두 Assert부분에 해당한다고 보면된다. 검증부분인데, status_code가 정상흐름인지 확인하고, post가 잘 되었다면 set_up에서 생성한 것 포함 총 2개의 레코드가 존재해야 하므로, 2개인지 확인한다. 그리고 넣은 데이터와, 넣기 전 데이터의 name이 동일하면 잘 들어간 것이므로 이까지 검증해서 모두 끝이나면 이 테스트 메서드는 성공한 것이다.
TDD는 기능이 기대한 대로 작동하는지 확인하기 위해 먼저 테스트를 작성한다. 이 과정에서 명확한 요구 사항 정의와 코드 설계를 촉진하게 되어, 전체적으로 코드 품질이 향상된다. 테스트를 자주 실행하므로 버그나 오류를 빠르게 발견하고 수정할 수 있다. 이는 개발 초기에 문제를 해결할 수 있게 하여 비용과 시간을 절약한다.리팩토링 시 테스트가 이미 작성되어 있으므로, 코드 변경 후에도 기능이 올바르게 작동하는지 쉽게 확인할 수 있다. 이는 리팩토링을 보다 안전하고 간편하게 만든다. TDD에서, 테스트를 먼저 작성함으로써, 코드가 테스트 가능하도록 설계됩니다. 이는 모듈화, 낮은 결합도, 높은 응집도를 촉진하게 되어, 전체적인 소프트웨어 설계가 개선된다. 테스트 코드를 통해 각 기능별 테스트가 이미 작성되어 있으므로, 버그 발생 시 특정 기능의 테스트를 실행하여 문제를 신속하게 찾아낼 수 있다. 코드 작성 시 테스트를 통과하는지 지속적으로 확인하므로, 신뢰할 수 있는 소프트웨어를 개발할 수 있다.