[ Architecture, Technology ,Web ] SSO(Single Sign On) 그리고 SAML에 대해

이미지
이번 프로젝트 내부에서 어쩌다보니  유저 인증 관련 업무를 담당하게 되었고, 해야하는 업무는 내부에 사용했던 적이 없던  새로운 개발 플랫폼에서  SSO의 프로토콜 중  SAML을 이용해 앱의 인증을 구현해야만 했다. SSO를 생각해본적 조차 없는 상황에 이를 새로운 개발 플랫폼에 도입해야 했기 때문에 많은 시행착오를 겪었으나 구현에 성공하였으며 덕분에 SSO에 대한 전반적인 지식을 쌓을 수 있었다. 이번에는 그러한 과정에서 나온 지식들과 경험을  공유하고자 한다. SSO에 대한 정의 먼저 사전적 정의 부터 살펴보자. 다만, 기술적인 용어다보니 자주 사용하는 옥스포드 사전에 정의를 찾을 수 없기 때문에  검색으로 찾을 수 있는 정의를 몇 가지 살펴보고 교차 검증을 해보자. 첫 번째 정의를 살펴보자. Single sign-on (SSO) is an identification method that enables users to log in to multiple applications and websites with one set of credentials.  SSO는 웹사이트에서 한 번의 인증(one set of credentials)으로 복수의 어플리케이션에 로그인 할 수 있는 인증(identification) 방법(method) 이다. 두 번째는 위키피디아의 정의이다. Single sign-on (SSO) is an authentication scheme that allows a user to log in with a single ID to any of several related, yet independent, software systems. SSO는 독립적이지만 연관되어있는 몇몇 소프트웨어에 대해 하나의 ID로 로그인을 할 수 있도록 하는 인증 구조(scheme) 세부 설명에 조금 차이가 있어 보이지만 전체적인 틀은 매우 비슷해 보인다.  몇 가지 포인트가 되는 단어를 추출해 이를 연결해보자면 아래와 같은 의미를 산출 할 수 있다. 독립적이지만 연관되어 있

[ Django, Python ] mozilla 튜토리얼 예제로 살펴보는 Django 분석 ⑨ - 2 : LocalLibrary Web Applcation Test

LocalLibrary 테스트


그렇다면 이제 본격적으로 
이번 튜토리얼에서 작성했던
LocalLibrary를 테스트 해보자.

여기서 작성할 테스트 코드는 
Model, View, Forms에 대한 테스트 코드이다.

물론 이 외에도 여러가지 작성할 수는 있겠지만
모든 테스트 코드를 작성할 수 없기 때문에 

또한 실제 자신이 작성한 코드에 대해서만 
테스트 코드를 작성하면 되기 때문에
이 외에 테스트 코드를 여기에서 작성하지는 않겠다.

① Model 테스트 코드 작성


먼저 /catalog/tests/ 경로의 
test_models.py 파일에


from django.test import TestCase

from catalog.models import Author

class AuthorModelTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Set up non-modified objects used by all test methods
        Author.objects.create(first_name='Big', last_name='Bob')

    def test_first_name_label(self):
        author = Author.objects.get(id=1)
        field_label = author._meta.get_field('first_name').verbose_name
        self.assertEquals(field_label, 'first name')

    def test_date_of_death_label(self):
        author=Author.objects.get(id=1)
        field_label = author._meta.get_field('date_of_death').verbose_name
        self.assertEquals(field_label, 'died')

    def test_first_name_max_length(self):
        author = Author.objects.get(id=1)
        max_length = author._meta.get_field('first_name').max_length
        self.assertEquals(max_length, 100)

    def test_object_name_is_last_name_comma_first_name(self):
        author = Author.objects.get(id=1)
        expected_object_name = f'{author.last_name}, {author.first_name}'
        self.assertEquals(expected_object_name, str(author))

    def test_get_absolute_url(self):
        author = Author.objects.get(id=1)
        # This will also fail if the urlconf is not defined.
        self.assertEquals(author.get_absolute_url(), '/catalog/author/1')
위와 같은 테스트 코드를 추가 하자.

이전에 언급햇다시피 
Class의 수 만큼 생성되는 그리고 
수정되지 않는 setUpTestData()를 주목해보자.

setUpTestData()코드 안에는
성이 Big 이고, 이름이 Bob을 가지고 있는 
Author 모델을 참조한 객체를 생성 한다.

따라서 이 객체는 테스트 중 한번 실행되고 
수정되지 않기 때문에

각 테스트 Method에서 부담없이 사용이 가능 할 것이다.

이 코드 중에 몇 가지 재미있는 부분이 있는데

# Get the metadata for the required field and use it to query the required field data
field_label = author._meta.get_field('first_name').verbose_name
이 코드에서 author.first_name.verbose_name를 이용해서 값을 가져오지 않는다.

왜냐하면 author.first_name문자열이지
속성을 통해 접근할 수 있는 first_name 객체가 아니기 때문이다.

대신 author 객체_meta 속성을 이용하여 
위의 코드와 같이 필요한 값을 가져올 수 있다.

다음 으로 아래의 코드는

# Compare the value to the expected result
self.assertEquals(field_label, 'first name')
assertTrue(field_label == 'first name') 대신에
assertEquals(field_label,'first name')를 사용했는데

assertTrue를 사용할 경우 True냐 False냐를 알려주지만,
assertEquals를 사용하면 실제 데이터의 값을 알려주기 때문이다.

또한 의도한 대로  Last Name, First Name 규칙에 맞게 생성되었는지,
그리고 Author 객체의 URL이 의도한대로 생성되었는지도 확인해봐야 한다.

def test_object_name_is_last_name_comma_first_name(self):
    author = Author.objects.get(id=1)
    expected_object_name = f'{author.last_name}, {author.first_name}'
    self.assertEquals(expected_object_name, str(author))
        
def test_get_absolute_url(self):
    author = Author.objects.get(id=1)
    # This will also fail if the urlconf is not defined.
    self.assertEquals(author.get_absolute_url(), '/catalog/author/1')
이는 위의 코드를 통해 이를 확인 할 수 있다.

그렇다면 이제 실제 테스트를 해보자.

명령어를 입력해 테스트를 실행하면 


위와 같은 에러가 나타나며
라벨이 died이도록 의도 했지만 
잘못 코딩해 Died였다는 사실을 알려준다.

실제 코드를 살펴보면    


위와 같이 Died가 입력되어 있는 것을 확인할 수 있다.
만약 올바르게 died라고 작성되어 있다면 
Died를 입력해 테스트를 해보면 위와 같은 로그가 출력 된다.

다른 Model들도 이와 같은 패턴으로 작성하면 된다.

② Form 테스트 코드 작성

Form 테스트는 위의 Model을 테스트 했을 때와 유사하다.

Form의 테스트 코드를 작성할 때 주목해야할 곳은
의도한대로 라벨필드도움말(help text)을 나타나는지 확인해야 한다.

그 외의 Django에서 알아서 해주는 곳은 따로
테스트할 필요는 없을 것이다.

예컨데 Model에 필드에서 정의한대로 
Date값을 가지는 지에 대해서는 테스트할 필요가 없다는 것이다.

다만 추가적인 검사가 필요하다면 
이에 대한 테스트는 필요할 것이다.

forms.py의 코드를 들여다보면

class RenewBookForm(forms.Form):
    """Form for a librarian to renew books."""
    renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

    def clean_renewal_date(self):
        data = self.cleaned_data['renewal_date']

        # Check if a date is not in the past.
        if data < datetime.date.today():
            raise ValidationError(_('Invalid date - renewal in past'))

        # Check if date is in the allowed range (+4 weeks from today).
        if data > datetime.date.today() + datetime.timedelta(weeks=4):
            raise ValidationError(_('Invalid date - renewal more than 4 weeks ahead'))

        # Remember to always return the cleaned data.
        return data
위와 같이 책에 대한 내용을 
수정할 때 사용하는 Form이 정의되어 있는데

renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

Date 타입과 도움말(help_text)을 가지고 있는
renewal_date라는 이름의 필드를 가지고 있다.

이에 대한 테스트 코드를 작성해보자.

/catalog/tests/ 경로의 test_forms.py 파일에


import datetime

from django.test import TestCase
from django.utils import timezone

from catalog.forms import RenewBookForm

class RenewBookFormTest(TestCase):
    def test_renew_form_date_field_label(self):
        form = RenewBookForm()
        self.assertTrue(form.fields['renewal_date'].label == None or form.fields['renewal_date'].label == 'renewal date')

    def test_renew_form_date_field_help_text(self):
        form = RenewBookForm()
        self.assertEqual(form.fields['renewal_date'].help_text, 'Enter a date between now and 4 weeks (default 3).')

    def test_renew_form_date_in_past(self):
        date = datetime.date.today() - datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_too_far_in_future(self):
        date = datetime.date.today() + datetime.timedelta(weeks=4) + datetime.timedelta(days=1)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertFalse(form.is_valid())

    def test_renew_form_date_today(self):
        date = datetime.date.today()
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())
        
    def test_renew_form_date_max(self):
        date = timezone.now() + datetime.timedelta(weeks=4)
        form = RenewBookForm(data={'renewal_date': date})
        self.assertTrue(form.is_valid())
위와 같은 테스트 코드를 추가 하자.

위의 코드는
정의한 필드의 라벨과 도움말(help text)가 의도된대로 정의되었는지,

그리고 입력된 날짜에 따라 데이터의 수정을 
허용할지 거부할지의 로직이 의도한대로 작동하는지 테스트를 한다.

여기서 None의 값을 확인하는 이유는
Django에서는 라벨 값이 정의되어있지 않으면 
None으로 반환하기 때문이다.


이후 테스트를 실행해보면


위와 같은 결과가 나올 수 있다.

물론 나는 값을 확인하기 위해 help_text의 값을 일부 변경하였다.

③ View 테스트 코드 작성


View테스트 하기 위해서는 Django test Client를 사용할 수 있다.

이 클래스는 더미 웹 브라우저와 같이 작동하며,
URL의 GET과 POST 요청을 동시에 할 수 있다.

또한 응답의 헤더와 상태 코드를 나타내는 
저수준의 HTTP,
HTML을 렌더링하기 위한 Template,
그리고 Template에 입력하는 데이터까지 
응답에 대한 거의 모든 것을 확인할 수 있다.

먼저 가장 간단한 View 중 하나인 

class AuthorListView(generic.ListView):
    model = Author
    paginate_by = 10
AuthorListView 부터 테스트 코드를 작성해보자.

이 View는 제너릭 리스트 View로 
이에 대한 대부분의 처리는 Django에서 한다.

만약 Django에 대해 신뢰하고 있다면
우리가 해야할 일은 올바른 URL로 
올바른 View에 접근하고 있는지만 확인하면 된다.

/catalog/tests/ 경로의 test_views.py 파일에


from django.test import TestCase
from django.urls import reverse

from catalog.models import Author

class AuthorListViewTest(TestCase):
    @classmethod
    def setUpTestData(cls):
        # Create 13 authors for pagination tests
        number_of_authors = 13

        for author_id in range(number_of_authors):
            Author.objects.create(
                first_name=f'Christian {author_id}',
                last_name=f'Surname {author_id}',
            )
           
    def test_view_url_exists_at_desired_location(self):
        response = self.client.get('/catalog/authors/')
        self.assertEqual(response.status_code, 200)
           
    def test_view_url_accessible_by_name(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        
    def test_view_uses_correct_template(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, 'catalog/author_list.html')
        
    def test_pagination_is_ten(self):
        response = self.client.get(reverse('authors'))
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertTrue(len(response.context['author_list']) == 10)

    def test_lists_all_authors(self):
        # Get second page and confirm it has (exactly) remaining 3 items
        response = self.client.get(reverse('authors')+'?page=2')
        self.assertEqual(response.status_code, 200)
        self.assertTrue('is_paginated' in response.context)
        self.assertTrue(response.context['is_paginated'] == True)
        self.assertTrue(len(response.context['author_list']) == 3)
위의 코드를 추가하자.

여기서의 모든 테스트는 
TestCase의 파생 클래스에 속하는 클라이언트를 사용해
GET을 요청(Request)하고, 응답(Response)을 받는다. 

첫 번째 테스트는 상대적 경로로 특정 URL을 확인하고
두 번째 테스트는 URL에서 코딩했던 View의 이름에서 
얻는 URL을 확인 한다.

response = self.client.get('/catalog/authors/')
response = self.client.get(reverse('authors'))
위와 같이 응답(Response)을 받으면
응답(Response)에서 상태(State) 코드, 
사용된 Template, 페이징 처리(pagination)가 되었는지

그리고 반환된 객체의 갯수,
전체 아이템의 갯수를 확인 한다.

여기서 주목해야하는 변수는 response.context로 
View에 의해 Template에 전달되는 context 변수이다.

이 변수로서 어떤 데이터가 어떤 Template에 전달되는지를
확인할 수 있기 때문에
렌더링에 관한 나머지 문제는 오로지 Template의 문제라고 
추측할 수 있기 때문이다.

작성한 테스트 코드를 실행해보면 


위와 같이 새로운 테스트에 대한 Log가 출력 된다.

이 Log가 나타나는 이유는 
데이터를 적게 넣었기 때문에
페이징 처리를 데이터가 5개 되면 하도록 하였고
데이터가 충분하지 않아 페이징 갯수가 3개가 되지 않기 때문이다.

1) 로그인한 사용자에게만 보이는 View


이전에 자신이 빌린 책의 목록을 보여주는 

from django.contrib.auth.mixins import LoginRequiredMixin

class LoanedBooksByUserListView(LoginRequiredMixin, generic.ListView):
    """Generic class-based view listing books on loan to current user."""
    model = BookInstance
    template_name ='catalog/bookinstance_list_borrowed_user.html'
    paginate_by = 10

    def get_queryset(self):
        return BookInstance.objects.filter(borrower=self.request.user).filter(status__exact='o').order_by('due_back')
위와 같은 View를 작성한 적이 있었다.

이에 대한 테스트 코드를 작성해 보자.

먼저 /catalog/tests/ 경로의 test_views.py 파일에


import datetime

from django.utils import timezone
from django.contrib.auth.models import User # Required to assign User as a borrower

from catalog.models import BookInstance, Book, Genre, Language

class LoanedBookInstancesByUserListViewTest(TestCase):
    def setUp(self):
        # Create two users
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')
        
        test_user1.save()
        test_user2.save()
        
        # Create a book
        test_author = Author.objects.create(first_name='John', last_name='Smith')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )

        # Create genre as a post-step
        genre_objects_for_book = Genre.objects.all()
        test_book.genre.set(genre_objects_for_book) # Direct assignment of many-to-many types not allowed.
        test_book.save()

        # Create 30 BookInstance objects
        number_of_book_copies = 30
        for book_copy in range(number_of_book_copies):
            return_date = timezone.now() + datetime.timedelta(days=book_copy%5)
            the_borrower = test_user1 if book_copy % 2 else test_user2
            status = 'm'
            BookInstance.objects.create(
                book=test_book,
                imprint='Unlikely Imprint, 2016',
                due_back=return_date,
                borrower=the_borrower,
                status=status,
            )
        
    def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('my-borrowed'))
        self.assertRedirects(response, '/accounts/login/?next=/catalog/mybooks/')

    def test_logged_in_uses_correct_template(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))
        
        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)

        # Check we used correct template
        self.assertTemplateUsed(response, 'catalog/bookinstance_list_borrowed_user.html')
위와 같은 코드를 추가하자.

여기서 데이터를 수정할 것이기 때문에
setUpTestData()가 아닌 Setup()을 이용해서 계정을 생성한 다음 
테스트에 사용할 
BookInstance 객체와 관련된 데이터들을 생성 한다.

각각의 테스트 계정에 의해 책이 절반씩 대출되었지만 
작성한 테스트 코드를 확인하기 위해 
maintenace 상태로 설정 했다.

우선 로그인 하지 않은 사용자를
로그인 화면으로 리다이렉트 되는지를 확인하기 위해
test_redirect_if_not_logged_in() 함수에 
assertRedirects를 사용했다는 것을 볼 수 있다.


    def test_logged_in_uses_correct_template(self):
       
                          ・・・

        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)
test_logged_in_uses_correct_template() 함수에서는 
로그인 한 사용자에게 화면이 나타났다는 것을 확인하기 위해
응답(Response)의 상태 코드인 200번인가를 확인 한다.

이에 이어 현재 로그인 하는 사용자가 
대출한 책만을 반환하는지를 검증하는 코드를 


def test_only_borrowed_books_in_list(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))
        
        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)
        
        # Check that initially we don't have any books in list (none on loan)
        self.assertTrue('bookinstance_list' in response.context)
        self.assertEqual(len(response.context['bookinstance_list']), 0)
        
        # Now change all books to be on loan
        books = BookInstance.objects.all()[:10]

        for book in books:
            book.status = 'o'
            book.save()
        
        # Check that now we have borrowed books in the list
        response = self.client.get(reverse('my-borrowed'))
        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)
        
        self.assertTrue('bookinstance_list' in response.context)
        
        # Confirm all books belong to testuser1 and are on loan
        for bookitem in response.context['bookinstance_list']:
            self.assertEqual(response.context['user'], bookitem.borrower)
            self.assertEqual('o', bookitem.status)

    def test_pages_ordered_by_due_date(self):
        # Change all books to be on loan
        for book in BookInstance.objects.all():
            book.status='o'
            book.save()
            
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('my-borrowed'))
        
        # Check our user is logged in
        self.assertEqual(str(response.context['user']), 'testuser1')
        # Check that we got a response "success"
        self.assertEqual(response.status_code, 200)
                
        # Confirm that of the items, only 10 are displayed due to pagination.
        self.assertEqual(len(response.context['bookinstance_list']), 10)
        
        last_date = 0
        for book in response.context['bookinstance_list']:
            if last_date == 0:
                last_date = book.due_back
            else:
                self.assertTrue(last_date <= book.due_back)
                last_date = book.due_back
위와 같이 추가하자.

추가한 후에 테스트 코드를 실행해보면


새로운 에러는 발견되지 않고 
아까 확인을 못했던 에러가 발견되는데 
이는 내가 urls.py에서 url 경로를 설정할 때 
authors가 아닌 author로 해서 그렇다.

2) Form을 이용하는 View의 테스트 코드 작성


Form을 이용하는 View의 경우는 
앞에서의 테스트 코드 작성보다 좀 더 까다로울 수 있다.

왜냐하면 데이터 유효성 성공 화면, 실패 화면
모두의 경우를 고려한 테스트 코드를 작성해야하기 때문이다.

다행히도 데이터를 보여주기만 하는 View를 테스트할 때 
사용했던 것을 그대로 사용할 수 있다.

그럼 이제 책의 대출을 갱신하기 위한 View의
테스트 코드를 아래와 같이 작성해보자.


from catalog.forms import RenewBookForm

@permission_required('catalog.can_mark_returned')
def renew_book_librarian(request, pk):
    """View function for renewing a specific BookInstance by librarian."""
    book_instance = get_object_or_404(BookInstance, pk=pk)

    # If this is a POST request then process the Form data
    if request.method == 'POST':

        # Create a form instance and populate it with data from the request (binding):
        book_renewal_form = RenewBookForm(request.POST)

        # Check if the form is valid:
        if form.is_valid():
            # process the data in form.cleaned_data as required (here we just write it to the model due_back field)
            book_instance.due_back = form.cleaned_data['renewal_date']
            book_instance.save()

            # redirect to a new URL:
            return HttpResponseRedirect(reverse('all-borrowed'))

    # If this is a GET (or any other method) create the default form
    else:
        proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
        book_renewal_form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

    context = {
        'book_renewal_form': book_renewal_form,
        'book_instance': book_instance,
    }

    return render(request, 'catalog/book_renew_librarian.html', context)
정말로 can_mark_returned 라는 Permission을 가진 사용자만이 
View를 사용할 수 있는지,
그리고 Permission을 가지고 있지 않은 사용자들이
BookInstance를 수정하려고 시도하면 HTTP 404 에러 페이지
리다이렉트 되는지 테스트해봐야 한다. 

또한 Form의 디폴트 값이 
현재 날짜의 3주후의 날짜로 입력되어 있는지도 확인하고,
데이터 유효성 검사에 성공하면 url.py에 정의되어 있는 
all-borrowed-books 라는 이름의 View로 리다이렉트되는지 확인해야 한다.

이를 확인하기 위해 
/catalog/tests/ 경로에 test_views.py 파일에


import uuid

from django.contrib.auth.models import Permission # Required to grant the permission needed to set a book as returned.

class RenewBookInstancesViewTest(TestCase):
    def setUp(self):
        # Create a user
        test_user1 = User.objects.create_user(username='testuser1', password='1X<ISRUkw+tuK')
        test_user2 = User.objects.create_user(username='testuser2', password='2HJ1vRV0Z&3iD')

        test_user1.save()
        test_user2.save()
        
        permission = Permission.objects.get(name='Set book as returned')
        test_user2.user_permissions.add(permission)
        test_user2.save()

        # Create a book
        test_author = Author.objects.create(first_name='John', last_name='Smith')
        test_genre = Genre.objects.create(name='Fantasy')
        test_language = Language.objects.create(name='English')
        test_book = Book.objects.create(
            title='Book Title',
            summary='My book summary',
            isbn='ABCDEFG',
            author=test_author,
            language=test_language,
        )
        
        # Create genre as a post-step
        genre_objects_for_book = Genre.objects.all()
        test_book.genre.set(genre_objects_for_book) # Direct assignment of many-to-many types not allowed.
        test_book.save()

        # Create a BookInstance object for test_user1
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance1 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user1,
            status='o',
        )

        # Create a BookInstance object for test_user2
        return_date = datetime.date.today() + datetime.timedelta(days=5)
        self.test_bookinstance2 = BookInstance.objects.create(
            book=test_book,
            imprint='Unlikely Imprint, 2016',
            due_back=return_date,
            borrower=test_user2,
            status='o',
        )
위와 같은 코드를 추가하자.

이렇게 된다면 두 명의 사용자와 두 개의 책 인스턴스가 생성되며
testuser2라는 이름의 User에게 Permission이 부여된다.

또한 아래와 같은 코드를 추가하면


def test_redirect_if_not_logged_in(self):
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        # Manually check redirect (Can't use assertRedirect, because the redirect URL is unpredictable)
        self.assertEqual(response.status_code, 302)
        self.assertTrue(response.url.startswith('/accounts/login/'))
        
    def test_redirect_if_logged_in_but_not_correct_permission(self):
        login = self.client.login(username='testuser1', password='1X<ISRUkw+tuK')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 403)

    def test_logged_in_with_permission_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance2.pk}))
        
        # Check that it lets us login - this is our book and we have the right permissions.
        self.assertEqual(response.status_code, 200)

    def test_logged_in_with_permission_another_users_borrowed_book(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        
        # Check that it lets us login. We're a librarian, so we can view any users book
        self.assertEqual(response.status_code, 200)

    def test_HTTP404_for_invalid_book_if_logged_in(self):
        # unlikely UID to match our bookinstance!
        test_uid = uuid.uuid4()
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk':test_uid}))
        self.assertEqual(response.status_code, 404)
        
    def test_uses_correct_template(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        response = self.client.get(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}))
        self.assertEqual(response.status_code, 200)

        # Check we used correct template
        self.assertTemplateUsed(response, 'catalog/book_renew_librarian.html')
사용자가 로그인하지 않은 경우,
사용자가 로그인 했지만 Permission이 없는 경우,
사용자에게 Permission은 있지만 빌린 책이 없을 경우,
존재하지 않는 BookInstance에 접근하려고 하는 경우,
마지막으로 올바른 Template가 사용되었는지도 확인 할 수 있다.

이에 아래의 코드를 추가하면


 def test_redirects_to_all_borrowed_book_list_on_success(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        valid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=2)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future})
        self.assertRedirects(response, reverse('all-borrowed'))
Form에 디폴드 값이 현재의 3주 후인지를 확인할 수 있다.





    def test_redirects_to_all_borrowed_book_list_on_success(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        valid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=2)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk':self.test_bookinstance1.pk,}), {'renewal_date':valid_date_in_future})
        self.assertRedirects(response, reverse('all-borrowed'))

    def test_form_invalid_renewal_date_past(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        date_in_past = datetime.date.today() - datetime.timedelta(weeks=1)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': date_in_past})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response, 'form', 'renewal_date', 'Invalid date - renewal in past')
        
    def test_form_invalid_renewal_date_future(self):
        login = self.client.login(username='testuser2', password='2HJ1vRV0Z&3iD')
        invalid_date_in_future = datetime.date.today() + datetime.timedelta(weeks=5)
        response = self.client.post(reverse('renew-book-librarian', kwargs={'pk': self.test_bookinstance1.pk}), {'renewal_date': invalid_date_in_future})
        self.assertEqual(response.status_code, 200)
        self.assertFormError(response, 'form', 'renewal_date', 'Invalid date - renewal more than 4 weeks ahead')
또한 위의 세 가지 함수를 통해 
Update가 성공하면 
모든 도서 목록을 나타내는 페이지로 리다이렉트 되는지

Update 할때 너무 과거이거나 너무 미래일 때의 
에러 메시지가 나타나는지 테스트 한다.

④ Template 테스트 코드 작성


Django는 View에서 올바른 Template이 호출되고
올바른 데이터가 전송되고 있는지를 확인할 수 있는 테스트 API를 제공하지만
HTML출력이 예상대로 렌더링 되는지 확인할 수 있는 API는 제공하지 않는다.

사실 테스트 코드를 작성하는 것은 재미도 없고
화려하지도 않으며 
심지어 만든다고 하더라도 오래 지속되거나
전혀 사용되지 않는 경우가 많다.

하지만, 코드를 변경 후 안전하게 배포하고
유지관리하는 데에는 테스트가 필수적이다.





참고




이 블로그의 인기 게시물

[ Web ] 웹 애플리케이션 아키텍처 (Web Application Architecture)

[ Web ] 서버 사이드(Sever Side) ? 클라이언트 사이드(Client Side)? 1 [서론, 클라이언트 사이드(Client Side)]

[ Web ] 웹 애플리케이션 서버 아키텍처의 정의 및 유형 ( Define and Types of Web Application Server Architecture )