일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 |
- 자바
- 데이터 사이언스
- data science methodology
- 코딩테스트
- 코테
- 부스트캠프
- 코세라
- Clean Code
- 문자열
- Python
- 알고리즘
- 티스토리챌린지
- 클린코드
- string
- softeer
- IBM
- 데이터과학
- Coursera
- 프로그래머스
- 소프티어
- Boostcamp AI
- 클린코드 파이썬
- 오블완
- Java
- 데이터사이언스
- AI Mathematics
- programmers
- 깨끗한 코드
- 파이썬
- Data Science
- Today
- Total
떼닝로그
[9장] 단위 테스트 본문
애자일과 TDD(Test Driven Development) 덕택에 단위 테스트를 자동화하는 프로그래머들이 많아졌고 더 늘어나는 추세지만,
우리 분야에 테스트를 추가하려고 급하게 서두르는 와중에 제대로 된 테스트 케이스를 작성해야 한다는 좀 더 미묘한 사실을 놓치고 있다.
TDD 법칙 세 가지
- TDD는 실제 코드를 짜기 전에 단위 테스트부터 짜라고 요구한다.
- 첫째 법칙 : 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
- 둘째 법칙 : 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 셋째 법칙 : 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
- 위 세 가지 규칙을 따르면 테스트 코드와 실제 코드가 함께 나올뿐더러, 테스트 코드가 실제 코드보다 불과 몇 초 전에 나온다.
- 이렇게 일하면 실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나오지만, 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.
깨끗한 테스트 코드 유지하기
- 실제 코드가 진화하면 테스트 코드도 변화해야 한다.
- 테스트 코드가 지저분하고 복잡할수록 실제 코드를 작성하는 시간보다 테스트 케이스를 추가하고 변경하는 시간이 더 걸릴 수 있다.
- 테스트 코드는 실제 코드 못지 않게 중요하다. 테스트 코드는 사고와 설계와 주의가 필요하고, 실제 코드 못지 않게 깨끗하게 짜야 한다.
테스트는 유연성, 유지보수성, 재사용성을 제공한다.
- 테스트 코드를 깨끗하게 유지하지 않으면 결국 잃어버리게 되고, 테스트 케이스가 없으면 실제 코드를 유연하게 만드는 버팀목이 사라지게 된다.
- 코드에 유연성, 유지보수성, 재사용성을 제공하는 버팀목이 단위 테스트. 테스트 케이스가 있으면 변경이 두렵지 않으니까!
- 테스트 커버리지가 높을수록 공포는 줄어든다. 안심하고 아키텍처와 설계를 개선할 수 있다.
- 실제 코드를 점검하는 자동화된 단위 테스트 슈트는 설계와 아키텍처를 최대한 깨끗하게 보존하는 열쇠다.
- 테스트 코드가 지저분하면 코드를 변경하는 능력이 떨어지며 코드 구조를 개선하는 능력도 떨어진다.
깨끗한 테스트 코드
- 깨끗한 테스트 코드를 만드려면 가독성, 가독성, 가독성이 필요하다. 가독성을 위해서는 명료성, 단순성, 풍부한 표현력이 필요하다.
- 아래 9-1의 테스트 케이스 세 개는 이해하기 어렵기에 개선할 여지가 충분하다. 자질구레한 사항이 너무 많아 테스트 코드의 표현력이 떨어진다.
def testGetPageHierarchyAsXml():
crawler.addPage(root, PathParser.parse("PageOne"))
crawler.addPage(root, PathParser.parse("PageOne.ChildOne"))
crawler.addPage(root, PathParser.parse("PageTwo"))
request.setResource("root")
request.addInput("type", "pages")
responder = SerializedPageResponder()
response = (SimpleResponse)responder.makeResponse(FitNesseContext(root), request)
xml = response.getContent()
assertEqual("text/xml", response.getContentType())
assertIn("<name>PageOne</name>", xml)
assertIn("<name>PageTwo</name>", xml)
assertIn("<name>ChildOne</name>", xml)
def testGetPageHierarchyAsXmlDoesntContainSymbolicLinks():
pageOne = crawler.addPage(root, PathParser.parse("PageOne"))
crawler.addPage(root, PathParser.parse("PageOne.ChildOne"))
crawler.addPage(root, PathParser.parse("PageTwo"))
data = pageOne.getData()
properties = data.getProperties()
symLinks = properties.set(SymbolicPage.PROPERTY_NAME)
symLinks.set("SymPage", "PageTwo")
pageone.commit(data)
request.setResource("root")
request.addInput("type", "pages")
responder = SerializedPageResponder()
response = (SimpleResponse)responder.makeResponse(FitNesseContext(root), request)
xml = response.getContent()
assertEqual("text/xml", response.getContentType())
assertIn("<name>PageOne</name>", xml)
assertIn("<name>PageTwo</name>", xml)
assertIn("<name>ChildOne</name>", xml)
assertNotIn("SymPage", xml)
def testGetDataAsHtml():
crawler.addPage(root, PathParser.parse("TestPageOne"), "test page")
request.setResource("TestPageOne")
request.addInput("type", "data")
responder = SerializedPageResponder()
response = (SimpleResponse)responder.makeResponse(FitNesseContext(root), request)
xml = response.getContent()
assertEqual("text/xml", response.getContentType())
assertIn("test page", xml)
assertIn("<Test", xml)
- PathParser는 문자열을 pagePath 인스턴스로 변환한다. 이 코드는 테스트와 무관하며 테스트 코드의 의도만 흐린다.
- 위 코드는 읽는 사람을 고려하지 않는다.
- 아래 코드 9-2는 9-1를 개선한 코드로, 정확히 동일한 테스트를 수행하지만 좀 더 깨끗하고 이해하기 쉽다.
def testGetPateHierarchyAsXml():
makePages("PageOne", "PageOne.ChildOne", "PageTwo")
submitRequest("root", "type:pages")
assertIs()
assertIn("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>")
def testSymbolicLinksAreNotInXmlPageHierarchy():
page = makePage("PageOne")
makePages("PageOne.ChildOne", "PageTwo")
addLinkTo(page, "PageTwo", "SymPage")
submitRequest("root", "type:pages")
assertIs()
assertIn("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>")
assertNotIn("SymPage")
def testGetDataAsXml():
makePageWithContent("TestPageOne", "test page")
submitRequest("TestPageOne", "type:data")
assertIs()
assertIn("test page", "<Test")
- BUILD-OPERATE-CHECK 패턴이 위와 같은 테스트 구조에 적합하다.
- 각 테스트는 명확히 테스트 자료 부분, 테스트 자료 조작 부분, 조작한 결과가 올바른지 확인하는 부분, 총 세 부분으로 나뉘어져 있다.
- 테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만 사용하기 때문에 코드를 읽는 사람이 코드가 수행하는 기능을 재빨리 이해할 수 있다.
도메인에 특화된 테스트 언어
- 코드 9-2는 도메인에 특화된 언어(DSL)로 테스트 코드를 구현하는 기법을 보여준다.
- API 위에다 함수와 유틸리티를 구현한 후 그 함수와 유틸리티를 사용함으로써 테스트를 구현하는 당사자와 나중에 테스트를 읽어볼 독자를 도와주는 테스트 언어를 만들게 된다.
- 숙련된 개발자라면 자기 코드를 좀 더 간결하고 표현력이 풍부한 코드로 리팩터링해야 마땅하다.
이중 표준
- 테스트 API 코드에 적용하는 표준은 단순하고, 간결하고, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요가 없기 때문에 실제 코드에 적용하는 표준과 확실히 다르다.
def turnOnLowTempAlarmAtThreashold():
hw.setTemp(WAY_TOO_COLD)
controller.tic()
assertTrue(hw.heaterState())
assertTrue(hw.blowerState())
assertFalse(hw.collerState())
assertFalse(hw.hiTempAlarm())
assertTrue(hw.lowTempAlarm())
- 위 코드를 읽으면 코드에서 점검하는 상태 이름과 상태 값을 확인하느라 눈길이 이리저리 흩어지게 된다. 테스트 코드를 읽기가 어렵다.
- 아래는 위 코드를 가독성 높여 변환한 코드이다.
def turnOnLowTempAlarmAtThreshold():
wayTooCold()
assertEqual("HBchL", hw.getState())
- 일단 의미만 안다면 눈길이 문자열을 따라 움직이며 결과를 재빨리 판단한다.
- 아래 코드를 보면 테스트 코드를 이해하기 너무도 쉽다는 사실이 분명히 드러난다.
def turnOnCollerAndBlowerIfTooHot():
tooHot()
assertEqual("hBChl", hw.getState())
def turnOnHeaterAndBlowerIfTooCold():
tooCold()
assertEqual("HBchl", hw.getState())
def turnOnHiTempAlarmAtThreshold():
wayTooHot()
assertEqual("hBCHl", hw.getState())
def turnOnLowTempAlarmAtThreshold():
wayTooCold()
assertEqual("HBchL", hw.getState())
- 아래 코드는 getState 함수를 보여준다. 코드가 그리 효율적이지 못하다는 사실에 주목한다.
def getState():
state = ""
state += heater ? "H" : "h"
state += blower ? "B" : "b"
state += cooler ? "C" : "c"
state += hiTempAlarm ? "H" : "h"
state += loTempAlarm ? "L" : "l"
return state
- stringBuffer는 보기에 흉하다.
- 테스트 환경은 자원이 제한적일 가능성이 낮다.
- 이것이 이중 표준의 본질이다. 실제 환경에서는 절대로 안 되지만 테스트 환경에서는 전혀 문제없는 방식이 있다. 코드의 깨끗함과는 철저히 무관하다.
테스트 당 assert 하나
- assert 문이 단 하나인 함수는 결론이 하나라서 코드를 이해하기 쉽고 빠르다.
- 코드 9-2에서는 assert문을 하나로 병합하고 있는데, 이보다는 아래의 9-7처럼 테스트를 두 개로 쪼개 각자가 assert를 수행하는 것이 낫다.
def testGetPageHierarchyAsXml():
givenPages("PageOne", "PageOne.ChildOne", "PageTwo")
whenRequestIsIssued("root", "type:pages")
thenResponseShouldBeXML()
def testGetPageHierarchyHasRightTags():
givenPages("PageOne", "PageOne.ChildOne", "PageTwo")
whenRequestIsIssued("root", "type:pages")
thenResponseShouldContain("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>")
- 위에서 함수 이름을 바꿔 given-when-then이라는 관례를 사용했다는 사실에 주목한다. 이를 사용함으로써 테스트 코드를 읽기가 쉬워졌지만, 테스트를 분리함으로써 중복되는 코드가 많아졌다.
- TEMPLATE METHOD 패턴을 사용하면 중복을 제거할 수 있다. given/when 부분을 부모 클래스에 두고 then 부분을 자식 클래스에 두거나, 완전히 독자적인 테스트 클래스를 만들어 @Before 함수에 given/when 부분을 넣고 @Test 함수에 then 부분을 넣어도 된다.
- assert 문 개수는 최대한 줄여야 좋다.
테스트 당 개념 하나
- 테스트 함수마다 한 개념만 테스트하라.
- 새 개념을 한 함수로 몰아넣으면 독자가 각 절이 거기에 존재하는 이유와 각 절이 테스트하는 개념을 모두 이해해야 한다.
###
# addMonths() 메서드를 테스트하는 장황한 코드
###
def testAddMonths():
d1 = SerialDate.createInstance(31, 5, 2004)
d2 = SerialDate.addMonths(1, d1)
assertEqual(30, d2.getDayOfMonth())
assertEqual(6, d2.getMonth())
assertEqual(2004, d2.getYYYY())
d3 = SerialDate.addMonths(2, d1)
assertEqual(31, d3.getDayOfMonth())
assertEqual(7, d3.getMonth())
assertEqual(2004, d3.getYYYY())
d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1))
assertEqual(30, d4.getDayOfMonth())
assertEqual(7, d4.getMonth())
assertEqual(2004, d4.getYYYY())
- 셋으로 분리한 테스트 함수는 각각 다음 기능을 수행한다.
(5월처럼) 31일로 끝나는 달의 마지막 날짜가 주어지는 경우
- (6월처럼) 30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안 된다.
- 두 달을 더하면 그리고 두 번째 달이 31로 끝나면 날짜는 31일이 되어야 한다.
(6월처럼) 30일로 끝나는 달의 마지막 날자가 주어지는 경우
- 31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되면 안 된다.
- 이렇게 표현하면 장황한 테스트 코드 속에 감춰진 일반적인 규칙이 보인다.
- 즉 위의 코드는 각 절에 assert 문이 여럿이라는 사실이 문제가 아니라, 한 테스트 함수에서 여러 개념을 테스트한다는 사실이 문제다.
- 가장 좋은 규칙은 "개념 당 assert 문 수를 최소로 줄여라"와 "테스트 함수 하나는 개념 하나만 테스트하라"라 하겠다.
F.I.R.S.T
깨끗한 테스트는 다음 다섯 가지 규칙을 따르는데, 각 규칙에서 첫 글자를 따오면 FIRST가 된다.
- 빠르게(Fast) : 테스트는 자주 돌릴 수 있도록, 빨리 돌아야 한다. 빠르지 않으면 코드 품질이 망가지게 된다.
- 독립적으로(Independent) : 각 테스트는 서로 의존하면 안 된다. 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다. 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨진다.
- 반복가능하게(Repeatable) : 테스트는 어떤 환경에서도 반복 가능해야한다. 환경이 지원되지 않기에 테스트를 수행하지 못하는 상황에 직면한다.
- 자가검증하는(Self-Validating) : 테스트는 부울(bool)값으로 성공, 아니면 실패로 결과를 내야 한다. 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단이 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
- 적시에(Timely) : 테스트는 적시에 작성해야 한다. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
결론
- 테스트 코드는 실제 코드의 유연성, 유지보수성, 재사용성을 보존하고 강화한다.
- 테스트 코드는 지속적으로 깨끗하게, 표현력을 높이고 간결하게 정리하자.
'개발로그 > Clean Code' 카테고리의 다른 글
[11장] 시스템 (0) | 2023.02.11 |
---|---|
[10장] 클래스 (0) | 2023.01.09 |
[8장] 경계 (0) | 2022.12.26 |
[7장] 오류 처리 (0) | 2022.12.19 |
[6장] 객체와 자료 구조 (0) | 2022.12.09 |