Skip to content

Latest commit

 

History

History
294 lines (197 loc) · 14.8 KB

ch09_단위테스트.md

File metadata and controls

294 lines (197 loc) · 14.8 KB

팀 에게 “테스트는 우리 코드가 일단 돌아만 가는지 확인하는 정도로, 막 짜는 것도 괜찮아~” 라고 말하는 순간

재앙이 시작될 것이다.

우리는 테스트 코드를 깨끗하게 작성해 주어야 한다. 왜 그럴까?

그리고 테스트 코드는 실제코드처럼 유지보수 대상이지만 다른 관점에서 신경 쓰며 작성해줘야 한다.

우리는 이 테스트 코드를 어떻게 바라보고, 어떻게 깨끗하게 작성 해 줄 수 있을까?

( 제목은 단위테스트였는데, 생각보다 단위테스트에 대한 내용은 거의 없는 듯 )

TDD ?

( 단위 테스트 보다는, 그냥 테스트 전체적인 얘기를하고 있는 챕터라 TDD 는 갑자기 왜 나온 건지 모르겠다. 단위 테스트 관점에서는 중요한 것 같긴 함. )

TDD 에 대한 법칙이 있다고 한다.

  1. 실제 코드를 짜기 전에 단위 테스트를 작성
  2. 실패하는 단위 테스트를 작성할 때 까지,실제 코드 작성하지 안기
  3. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위테스트 작성하기
  4. 현재 실패하는 테스트를 통과할 정도로만 실제 코드 작성하기

이렇게 테스트 코드 작성하다보면 실제코드와 맞먹을 정도로 방대한 테스트코드가 만들어진다.

이는 심각한 관리문제를 유발한다.

그러니 더더욱 처음에 작성하는 테스트코드에 신경을 써줘야 한다


깨끗한 테스트 코드 유지하기 ( 테스트 코드를 깨끗하게 유지해야 하는 이유 )

  • 테스트 코드역시 깨끗하게 작성해야 하는 이유
    • 엉망인 테스트 코드 케이스 → 실제 코드를 변경함에 따라 실패하는 테스트가 증가 → 유지보수하려니 너무 엉망이라 테스트를 유지보수하는데 시간이 오래 걸림→ 결국 테스트를 폐기 → 테스트가 없으니 실제 코드 변경을 망설이게 됨.
  • 그러니 테스트코드 는 실제 코드 못지 않게 중요하다. 실제코드 못지 않게 깨끗하게 짜야 한다.

테스트는 유연성, 유지보수성, 재사용성을 제공한다.

실제 코드에 대한 유연성과 유지보수성 재사용성이 테스트 케이스에서 비롯된다고 보는 것 같다.

  • 테스트 케이스가 없으면, 실제 코드 변경을 망설이게 된다. → 모든 변경이 잠정적인 버그가 된다.
  • 테스트 케이스가 있으면, 안심하고 아키텍쳐와 설계를 개선할 수 있다.
  • 앞에서 말한 것 처럼, 지저분한 테스트 코드가 존재할 경우 → 실제코드도 지저분해진다.
    • 실제 코드를 변경하는 능력이 떨어져 → 코드 구조 개선 도 잘 하지 못하게 됨.

깨끗한 테스트 코드란?

  • 테스트 코드에서는 특히 “가독성!!!” 이 매우 중요하다.
    • 최소의 표현으로 많은 것을 나타내야 한다.

이해하기 어려운 테스트 케이스 예시

온갖 잡다하고 무관한 코드를 이해해야지만, 이 테스트 케이스를 이해할 수 있어진다.

public void testGetPageHieratchyAsXml() throws Exception {
	// 중복되는 코드, 테스트 코드의 의도를 흐리는 코드 ================
  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 responder = new SerializedPageResponder();
  SimpleResponse response =
    (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
  String xml = response.getContent();
	
		// 중복되는 코드 ================
  assertEquals("text/xml", response.getContentType());
  assertSubString("<name>PageOne</name>", xml);
  assertSubString("<name>PageTwo</name>", xml);
  assertSubString("<name>ChildOne</name>", xml);
	// ====================	

}

만약 아래와 같이 작성한다면 어떨까?

참고로 아래의 리팩토링 된 코드는 “BUILD(테스트 자료 생성)-OPERATE(테스트 자료 조작)-CHECK 패턴” 이라는 것이 사용 되었다.

public void testGetPageHierarchyAsXml() throws Exception {
  makePages("PageOne", "PageOne.ChildOne", "PageTwo"); // 테스트 자료 만들기 

  submitRequest("root", "type:pages"); // 테스트 자료를 조작하여 결과 얻기 
	
	// 결과가 예상한 대로 나왔는지 확인하기 
  assertResponseIsXML();
  assertResponseContains(
    "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}

(개인적인 생각)

테스트는 하나의 시나리오라고 생각한다. 테스트를 읽으며 어떤 상황에서 무엇을 가지고 어떤 테스트를 하려는 것인지 이해 되는 것이 중요하다.

이전 코드는 코드를 읽으며 이러한 부분들을 직접 이해해야 한다.

반면 리팩토링 된 테스트 코드는 , 테스트를 위해 작성된 함수 이름을 통해 어떤 테스트 자료를 만들었고, 이 테스트 자료로 어떤 연산을 하였고, 어떤 것을 기대하는지 이해하기 쉽다.

  • 잡다한 코드 들로 인해 테스트를 이해하는데 오랜 시간이 걸려서는 안 된다.
  • 본론에 돌입해 진짜 필요한 자료 유형과 함수만 사용하도록 하자.

도메인에 특화된 테스트 언어(DSL)

위의 리팩토링 코드를 보면 makePages(), submitRequest() 와 같이 기존에는 없던 함수가 사용되었고 이들 덕에 테스트코드에서 무엇을 하려는 것인지 이해하는 것이 한결 편해졌다.

이는 DSL 로 테스트 코드를 구현하는 기법을 보여준 것이다.

그런데 DSL 이 대체 뭘까?

  • 특정 도메인의 요구사항을 “명확하게 표현” 할 수 있는 언어다.
  • 코드를 짜기 쉽도록 구현한 함수와 유틸리티 라고 볼 수 있다.
  • 이는 처음 부터 설계된 API 가 아니다. 세세한 사항으로 범벅된 코드를 계속 리팩토링하다가 진화된 API 다.
  • 이를 테스트 코드에 사용한다면?
    • 테스트를 구현하는 당사자와, 테스트를 읽는 사람의 이해를 도울 수 있다.

이처럼 우리는 코드를 좀 더 간결하고 표현력이 풍부하도록 계속해서 리팩토링 해야 한다.


이중 표준

테스트 코드에 적용하는 표준과 실제코드에 적용하는 표준은 다르다.

  • 테스트 코드 : 간결하고, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없다!
    • 이는 실제 코드가 실행되는 환경과, 테스트 코드가 실행되는 환경이 다르기 때문이다. ex) 임베디드시스템 코드라면 실제로는 메모리 등에 굉장히 신경을 써야 한다. 하지만 테스트 코드 자체는 이러한 메모리 사용 효율성 과 같은 것에 크게 신경쓰지 않아도 된다. 단, 무엇을 테스트하는 것인지 드러나는 것이 더 중요하다!
      • 테스트 환경은 자원이 제한적일 가능성이 낮다고 말하고 있다.
    • (고민❓❓ - 하지만 우리는 실제 환경과 같은 상황에 대한 테스트도 하고 싶고, 실제 그런 테스트환경을 세팅하고 하기도 하는데 이런 경우는 제외하고 말하는 걸까? )

그러니까..테스트 코드에서는 “표현력이 풍부” 하고 “읽기 쉬울 것” 이 중요하다.

예를들어 다음과 같은 코드를 보자

@Test
public void turnOnLoTempAlarmAtThreashold() throws Exception {
  hw.setTemp(WAY_TOO_COLD); 
  controller.tic(); 
  assertTrue(hw.heaterState());   
  assertTrue(hw.blowerState()); 
  assertFalse(hw.coolerState()); 
  assertFalse(hw.hiTempAlarm());       
  assertTrue(hw.loTempAlarm());
}

위 코드에서, WAY_TOO_COLD 상황에 대해서는

hw.heaterState() 를 먼저 읽고는 → 왼쪽을 보고 “히터가 켜져있는지를 확인해야하군”

hw.collerStatem() 를 먼저 읽고는 → 왼쪽을 보고 “쿨러는 꺼졌는지를 확인 해야하군”

이런식으로 이리 저리 눈을 돌리면서 무엇을 테스트하는 지 확인해야 한다.

이를 리팩토링 할 것이다.

@Test
public void turnOnCoolerAndBlowerIfTooHot() throws Exception {
  tooHot();
  assertEquals("hBChl", hw.getState()); 
}
  
@Test
public void turnOnHeaterAndBlowerIfTooCold() throws Exception {
  tooCold();
  assertEquals("HBchl", hw.getState()); 
}

@Test
public void turnOnHiTempAlarmAtThreshold() throws Exception {
  wayTooHot();
  assertEquals("hBCHl", hw.getState()); 
}

@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
  wayTooCold();
  assertEquals("HBchL", hw.getState()); 
}

사실 위의 코드를 보면 다소 당황스럽긴 하다.

미리 규칙을 세워두었기 때문이다 → {heater, blower, coller, hi-temp-alarm, lo-temp-alarm} 각각에 대해 켜진 것에 대해서는 대문자, 꺼진 것에 대해서는 소문자

  • (개인적으로 )테스트를 위해 우리끼리 임의로 정한 규칙을 기준으로, 테스트를 해석해야 해서 나쁘지 않나..라는 생각도 들었지만
    • 진짜로 첫 번째 코드에 비해서 어떤 결과를 보려는 것인지 더 잘 보이고 빠르게 읽히긴 한다
    • (개인적으로)단 위와 같은 경우는 정말로..규칙을 설명하는 주석 같은게 필요할 것 같다.

테스트 당 assert 하나

  • 단일 assert 문 규칙은 꽤 괜찮은 규칙이다.

    • 테스트 에서의 결론이 하나라 이해하기 쉽다.
  • 하지만, 단일 assert 문을 만드려고 테스트를 여러개로 쪼개야 하는 상황 중

    • 오히려 테스트를 분리하며 중복되는 코드가 많아질 수도 있다.
      • 이 때 방법 1 ) given/when 부분은 부모 클래스에 , then 은 자식 클래스에 두는 Template method pattern 을 쓸 수도 있고
      • 방법 2) @Before 에 given/when 을 넣을 수도 있다.
    • 방법 1,2 는 배보다 배꼽이 커지는 사태→ 그냥 하나의 테스트에서 assert 문 여러 번 사용하는게 더 나을 수도 있다.
  • assert 문 개수는 최대한 줄여주는게 좋다.

    • 개인적으로 이를 위해, 사용중인 테스트 프레임워크에서 제공되는 API 를 적극적으로 학습해서 사용하는 방법도 있다고 생각한다.

    예시) 어떤 문자열을 split 한 결과를 확인하는 테스트를 작성하고 싶다. 그 결과 assert 문이 다음과 같이 3개나 등장 하게 되었다.

    						Assertions.assertThat(parts).hasSize(2);
                Assertions.assertThat(parts[0]).isEqualTo("1");
                Assertions.assertThat(parts[1]).isEqualTo("2");

    assert 가 중복적으로 사용되고 있는 것 같다.

    assertj 의 API 를 사용해 위의 코드를 하나의 api 로 대체할 수 있다.

                assertThat(parts).containsExactly("1", "2");
            

테스트당 개념 하나

  • 테스트 함수 마다, 하나의 개념만 테스트 하라.

아래 코드의 문제점은 무엇일까?

public void testAddMonths() {
  SerialDate d1 = SerialDate.createInstance(31, 5, 2004); // 31일로 끝나는 달 5월이 주어졌을 때 
	
	// 30일로 끝나는 한 달을 더하면, 결과 날짜 정보는 30일이 된다. 
  SerialDate d2 = SerialDate.addMonths(1, d1);
  assertEquals(30, d2.getDayOfMonth());
  assertEquals(6, d2.getMonth());
  assertEquals(2004, d2.getYYYY());

	// 두 달을 더하는 데, 두 번째 달이 31로 끝나는 달이라면, 결과 날짜 정보는 31이 된다. 
  SerialDate d3 = SerialDate.addMonths(2, d1);
  assertEquals(31, d3.getDayOfMonth());
  assertEquals(7, d3.getMonth());
  assertEquals(2004, d3.getYYYY());

	// 30일로 끝나는 달에 대해, 31로 끝나는 달이 더해진다면, 결과 날짜 정보는 30 일이 된다. 
  SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
  assertEquals(30, d4.getDayOfMonth());
  assertEquals(7, d4.getMonth());
  assertEquals(2004, d4.getYYYY());
}

총 세가지 개념에 대한 테스트를 하나의 테스트 함수에서 진행하고 있음을 볼 수 있다.

( 각 개념을 확인하는데에 여러개의 assert 문이 사용되었다는 것이 문제는 아니다!!)


F.I.R.S.T

  • 깨끗한 테스트 코드가 따르는 다섯 가지 규칙
    • Fast: 테스트는 빨라야 한다. 그렇지 않으면 테스트를 자주 돌리지 않게 되고 → 초반에 발견할 문제를 빠르게 발견하지 못해 실제 코드의 품질마저도 망가지게 된다.
    • Independent : 테스트는 서로 독립적이어야 한다. 테스트 실행 순서에 영향을 받아서도 안된다. 어떤 테스트에 의해 다른 테스트가 실패해선 안 된다. 테스트 실패 원인 추적이 어려워지기 때문이다. (실제 코드의 문제인지 뭐의 문제인지를 모르게되니..) → 결함을 찾기가 어려워진다.
    • Repeatable : 반복가능해야한다. 어떤 환경에서도 반복가능해야 한다. 그렇지 않으면 테스트가 실패했을 때 “ 아 이거? 환경 때문이야~~” 같은 테스트 실패에 대한 변명거리가 생겨버린다.
    • Self-validating : 자감 검증 가능해야 한다. 테스트 자체로 성공 또는 실패를 알려줄 수 있어야 한다(그런 의미에서 assert 문이 없는 테스트는…그 쓸모가 별로 없는 듯 ) . 테스트 외에 로그같은 것을 확인하는 것이 필요하면 안 된다. 그럴 경우, 테스트 성공에 대한 판단이 주관적이 되고, 추가적인 작업이 필요해져 번거로운 작업이 된다.
    • Timely : 테스트는 적시에 작성해야 한다. 단위 테스트는 “실제 코드를 구현하기 직전에 구현” 해야 한다.실제 코드 구현 → 테스트 코드 인 경우, 실제 코드에 대한 테스트 작성이 너무 어렵다는 사실을 테스트 코드를 작성할 때서야 알게 된다. 애초에 테스트가 불가능하도록 코드를 작성하게 될지도 모른다. (이는 TDD 와 관련된 얘기 같다. TDD 의 장점으로 언급되는 것이, 테스트를 먼저 작성함으로서, 테스트하기 쉬운 구조로 코드를 작성할 수 있다는 것인데, 단위 테스트 작성이 쉬운 코드라는 것 자체가 상당히 유연한 아키첵쳐를 가졌을 확률이 높죠 )

결론

  • 테스트 코드는 실제코드의 유연성, 유지보수성, 재사용성을 강화한다.
  • 테스트 코드의 표현력을 높이고 간결하게 정리하자.
    • 테스트 API (표현력이 좋은!!) 를 구현해,DSL 을 만들자. → 테스트 작성도 쉬워지고, 읽는 것도 쉬워짐.
    • 테스트 코드가 망가지면 실제코드도 망가진다. 테스트 코드를 깨끗하게 유지하자!