[TDD | Python] Test-Driven-Development 1-3장
study

[TDD | Python] Test-Driven-Development 1-3장

728x90

TDD(Test-Driven-Development, 테스트 주도 개발)를 공부하기 위해서 Test-Driven-Development with Python을 읽으면서 개념을 익히고 있다.

 

개발을 할 때, 테스트는 꽤나 중요하다. 사용자가 항상 나의 프로젝트에 '이상적으로' 접근했으면 좋겠지만, 실상은 그렇지 않다. 우리는 항상 최악의 상황을 생각하고, 그에 대한 대비가 완벽하게 되어있어야 한다. TDD는 그러한 테스트들을 만들어 두고, 해당 테스트가 통과하게끔 코드를 짜게 한다. 코드를 짠 뒤 테스트를 하는 게 아니라, 테스트를 짠 뒤 코드를 짠다.

어떻게 보면 나는 TDD를 이미 경험해 보았을지도 모른다. 온라인 저지 문제들을 많이 풀어본 입장으로, 문제가 틀렸을 때, 테스트케이스(반례)를 만들고, 해당 반례를 해결하기 위해서 코드를 작성하는 방법이 TDD와 일맥상통한다.

 

Django 프레임워크를 사용해서 웹 어플리케이션을 TDD를 통해 개발하는 것이 이 책의 진행 방향이다 (물론 Django를 배우는 것이 아니다. 웹 / DB / TDD를 포함관계로 생각하면 안 된다). 책이 오래 된 거라, deprecated된 기능들을 사용하는 경우도 많아서 해당 부분은 구글링하면서 메꿔나갔다. 더 나은 장고 기능을 공부하는 것은 여기에서는 조금 경계해야겠다. 분야가 다르니까, 그 부분은 웹, 장고를 더 공부하면서 발전할 일이다.

 

공부 과정은 github에 업로드한다. 책에서도 중간중간 push하는 부분이 있는데, 그 진도에 맞추어 commit/push를 진행할 예정이다.

 

1. Test를 작성하기 전까지는 아무것도 하지 말라

이 책의 가장 첫 장의 부제목과 같다. TDD에서 처음으로 해야 하는 일은 항상 정해져 있다: 테스트 코드 작성하기.

테스트를 작성하고, 생각했던 대로 실패하는 것을 확인한다. 그제서야 우리는 코드를 짤 자격이 생긴다. 코드를 짜는 것도 해당 테스트를 해결하는 코드만 작성한다는 것을 유념하자. 이 책을 처음 읽으면서 놀랐던 것은, 이런 것도 테스트를 해야 해? 라는 점이었다. 가장 먼저 작성한 코드는 아래와 같은 functional test (기능 작동 테스트)였는데, 코드는 아래와 같다.

import unittest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from webdriver_manager.chrome import ChromeDriverManager
browser = webdriver.Chrome(service=Service(ChromeDriverManager().install()),
                                        options=webdriver.ChromeOptions())
url = "http://localhost:8000"
try:
    browser.get(url)
    assert "success" in browser.title
except selenium.common.exceptions.WebDriverException:
    raise AssertionError(f"Failed to connect {url}")

장고 서버가 성공적으로 열리는지 테스트한다. 물론 project를 새로 만들지도 않았기 때문에 장고 서버는 열려있지조차 않다. 처음 만난 코드였지만, 나에게 TDD의 개념을 머릿속에 잡아주기에는 충분했다. 우리가 이 테스트를 실패했을 때, 그제야 우리는 프로젝트를 만들고 장고 서버를 여는 것이 허락된다.

 

2-1. Functional Test를 통해 작동 확인

위 코드는 Selenium이라는 모듈을 통해, Chrome web에서 직접 접근한 것과 같은 방식으로 웹에 접근한다. 사용자의 관점에서 잘 작동(function)하는 지 확인해야 한다. Functional Test는 때때로 Acceptance Test, End-to-End Test라고 불린다.

 

Functional Test는 사람이 읽을 수 있는 형식으로 쓰여야 한다. 진짜 한 사용자가 어떤 방식으로 웹에 접근하고, 어떤 기능을 사용하는 지 등을 자세하게 서술한다. 이 책에서는 To-Do List를 Django를 통해 구현하는 것에 대한 튜토리얼을 제공한다. 따라서, 

# 홍길동은 멋진 온라인 To-Do 앱을 들었다. 그는 곧바로 해당 웹 사이트에 방문했다.
browser.get(url)

# 그는 홈페이지의 제목을 확인한다
assert "To-Do" in browser.title

# 그는 To-Do List에 아이템을 만든다.
# "시장에서 우유 사기"라는 문장을 TextBox에 작성한다

# 그가 Enter를 누르는 순간, 페이지가 업데이트되고 페이지에
# "1. 시장에서 우유 사 오기"가 To-Do List에 나타난다.

# 아직 TextBox가 여전히 있다. 그는 "TDD 공부하기"를 입력하고, 엔터를 누른다

# 페이지가 다시 업데이트되고, 두 아이템이 리스트에 나타난다.

# 그는 웹사이트가 이 리스트를 기억하고 있을 지 궁금했다.
# 웹사이트에서는 unique URL를 제공하고 있었다.
# 그가 제공하는 URL에 접속하자, 그의 리스트가 여전히 존재했다.

# 안심한 그는 잠에 들 수 있었다.
browser.quit()

조금은 복잡하지만 사람이 읽고 이해할 수 있는 functional test의 틀을 잡는다. 이는 이 웹에서 사용할 수 있는 모든 기능을 포함하고 있어야 한다.

해당 테스트는 당연히 To-Do라는 문자열이 title에 없으므로 실패(fail)한다. 이로써 우리는 title에 To-Do를 넣을 수 있게 되었다. 다른 말로 하면, 우리는 테스트를 정확하게 작성했으므로 테스트가 실패하게 되었고, 우리가 어떤 곳에 코드를 작성해야 하는지 명확해졌다는 의미이다.

 

2-2. unittest 모듈을 사용한 테스트 작성

2-1에서 확인한 assert를 사용한 fail에서는 'AssertionError'를 발생시킨다. TraceBack을 확인하면서 왜 테스트를 실패하는지를 정확하게 확인해야 하는데, 단순히 'AssertionError'라는 것은 범위가 너무 넓다. 이를 위해 파이썬에서는 unittest라는 모듈을 제공하며, 해당 모듈을 사용해 더 편리하고 빠르게 테스트를 작성할 수 있다.

class NewVisitorTest(unittest.TestCase):
    def setUp(self) -> None:
        self.browser = webdriver.Chrome(service=Service(ChromeDriverManager().install()),
                                        options=webdriver.ChromeOptions())
        self.browser.implicitly_wait(3)

    def tearDown(self) -> None:
        self.browser.quit()

    def test_can_start_a_list_and_retrieve_it_later(self) -> None:
        # 홍길동은 멋진 온라인 To-Do 앱을 들었다. 그는 곧바로 해당 웹 사이트에 방문했다.
        self.browser.get('http://localhost:8000')
        # 그는 홈페이지의 제목을 확인한다
        self.assertIn("To-Do", self.browser.title)
		# ...

위와 같이 코드를 작성했다. 위 코드에는 몇가지 특징이 존재한다. 해당 모듈에는 setUp(), tearDown() 메서드가 존재하는데, 이는 매 테스트가 진행되기 전, 테스트가 종료된 뒤 실행되는 코드다. 또한 test_로 시작하는 메서드가 적어도 하나 이상 존재해야 하며, 그 메서드들이 테스트 메서드가 된다. 자세한 사항은 unittest를 참고!

 

3. Unit Test를 활용해 웹페이지 테스트

Functional Test가 실제 사용자처럼 프로젝트의 바깥에서 접근했다면, Unit Test는 프로젝트 내부에서 각각을 테스트한다. 전체적인 TDD 흐름은 아래와 같다.

 

1. functional test를 작성한다. 위에 서술한 것처럼, 유저의 관점에서 자세하게, 그리고 읽을 수 있게 작성한다

2. functional test가 실패한 것을 보았고, 이를 해결하기 위해 unit test들을 작성한다. 

3. unit test가 실패하게 될 텐데, 이를 해결할 수 있는 가장 작은 단위의 코드를 작성하고, 해당 unit test를 통과하게끔 한다. unit test가 많다면, 2-3을 반복한다.

4. functional test를 다시 진행해서 통과하는지 확인한다.

 

functional test는 application이 올바르게 작동(function)하는 코드를 작성토록 돕고, unit test는 깔끔하고 버그 없는 코드를 작성하도록 돕는다.

 

책의 진도를 나가다 보면 아래와 같은 테스트를 작성하게 된다.

#View
def home_page():
    pass

#Test
def test_homepage_returns_html(self):
    req = HttpRequest()
    res = home_page(req)
    self.assertTrue(res.content.startswith(b"<html>"))
    self.assertIn(b"<title>To-Do lists</title>", res.content)
    self.assertTrue(res.content.endswith(b"</html>"))

홈페이지가 HTML 양식을 지키고, 그 제목이 To-Do lists인지를 확인하는 unit test이다. 코드가 에러를 내뱉는 것을 절대 두려워하면 안 된다! 우리는 통과하지 못하는 것을 전제로 두고 테스트 코드를 작성한다. 이를 고쳐가면서 모든 테스트를 통과하는 것이 목표이다!

 

처음에 실행하면 아래와 같은 test fail을 확인할 수 있다.

TypeError: home_page() takes 0 positional arguments but 1 was given

home_page()에서 아무런 argument를 가지지 않기 때문이다. 이를 수정한다.

def home_page(request):
    pass

그럼 또 다음 fail을 확인할 수 있다.

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

View에서는 HttpResponse를 리턴해줘야 한다. 이를 리턴해 주자.

def home_page(request):
    return HttpResponse()
-> self.assertTrue(res.content.startswith(b"<html>"))
AssertionError: False is not true

Response가 HTML로 시작하지 않는다. 코드를 수정한다.

def home_page(request):
    return HttpResponse("<html>")
-> self.assertIn(b"<title>To-Do lists</title>", res.content)
AssertionError: b'<title>To-Do lists</title>' not found in b'<html>'

타이틀이 존재하지 않는다. 코드를 수정한다.

def home_page(request):
    return HttpResponse("<html><title>To-Do lists</title>")
-> self.assertTrue(res.content.endswith(b"</html>"))
AssertionError: False is not true

HTML이 닫히지 않았다. 코드를 수정한다.

def home_page(request):
    return HttpResponse("<html><title>To-Do lists</title></html>")
System check identified no issues (0 silenced).

🎉🎉🎉 테스트를 통과했다!

수정하는 과정이 조금 복잡해 보일지는 몰라도, 테스트를 올바르게 작성하게 된다면 우리가 지금 무엇을 해야 하는지를 명확하게 알려준다. 우리가 수정해야 하는 곳이 어느 부분인지도 알려준다. 수정한 뒤에 다른 곳에서 테스트가 실패한다면, 다른 말로 하면 우리가 방금 수정한 곳이 정확히 수정됐으며, 테스트를 통과했음을 알려주는 것이다.

 

unit test를 완료했으니, functional test를 실행해 전체적인 부분이 잘 돌아가는지 확인해 보자.

감격의 테스트 성공

책에서는 중간에 fail하는 함수를 두어 테스트가 무조건 fail하도록 만들었다. 생각해 보니 그렇게 하는 게 옳다. functional test에서는 모든 명세가 적혀 있으므로, 통과한다면 내가 해당 웹을 완벽하게 만든 셈이 되니까.

functional test에 다음과 같은 한 줄을 추가해서, 원하는 곳에서 테스트가 멈추도록 만든다.

self.fail("Finish the test!") # always fails in here
-> self.fail("Finish the test!") # always fails in here
AssertionError: Finish the test!

functional test는 실패했다만, 우리가 원하는 곳까지는 잘 진행되었음을 알 수 있다.

 

여기까지가 책의 3장까지의 내용이다. TDD가 무엇인지, functional test와 unit test가 어떻게 다른지, 개발 순서가 어떻게 되는지, 테스트가 실패한다는 것의 의미는 무엇인지 등 여러가지를 알게 되었다.

따로 프로젝트를 진행해본 적은 없어서 이해하기 어려울 줄 알았지만, 색다른 관점으로 개발을 바라본다는 것이 너무 재미있었다. 책 완독하고 나서 장고 공부하면서 TDD 기반으로 프로젝트를 꼭 진행해봐야지!