TDD 시작하기 - 2
연재
- TDD 시작하기 - 1 : 스터디 조직, 강의 동영상, TDD 맛보기
- TDD 시작하기 - 2 : 행위 검증 vs 상태 검증
- TDD 시작하기 - 3 : 교재 1장~2장 정리, 참고 링크
행위 검증 vs 상태 검증
첫 시간에 동영상을 보며 이야기할 때 몇가지 해소가 안된 문제가 있었는데, 하나는 테스트 더블의 종류와 사용 목적이었고 하나는 행위 검증과 상태 검증이었다.
테스트 더블은 다른 분이 발표했기 때문에 이 블로그로는 자료를 가져오진 않겠지만, http://xunitpatterns.com/ 에서 주요 개념을 알 수 있다.
OKKYCON의 동영상에서도 행위 검증과 상태 검증에 관함 이야기가 많이 나왔는데, 정확히 언제/왜 사용해야 하는지 명확히 알기가 어려워 조금 더 찾아보게 됐다. 설계든 TDD든 정답이 없는 문제라서 초보자들은 갖은 상황을 떠올리며 이럴 때는 저럴 때는 어떻게 해야 하는가 하면서 논쟁이 지속되었다. 여전히 어렵긴 한데 중간 정리를 한번 해봤다.
행위 검증과 상태 검증에 관해서는 몇가지 블로그를 토대로 정리해본다.
왜 테스트가 자주 실패할까?
https://minslovey.tistory.com/97
켄트벡의 “Where, Oh Where to Test?”를 소개한다
- 원문 링크는 깨졌고, 여기 이 글을 소개하는 페이지가 있다
- 테스트를 위해 System Level Test를 해야 하는지 Unit Level Test를 해야 하는지 판단할 때에 Cost, Stability, Reliability를 반드시 고려해야 한다
- Cost
- 매우 포괄적인 개념으로 Stability와 Reliability를 포함
- 테스트를 작성하고 수행하는 데 걸리는 시간, 그리고 유지보수에 들어가는 비용
- 비용을 줄이기 위한 가장 좋은 방법
- 테스트 스크립트 수와 mock object와 같이 부가적으로 필요한 코드도 최대한 줄이기
- Stability
- Stability가 낮은 경우
- 테스트가 성공했는데, 결함이 있을 때
- 테스트가 실패했는데, 결함이 없을 때
- 만약 중요하지 않은 부분이면서 Stability를 갖춘 테스트를 작성하기가 어렵다면 작성하지 않는 것이 좋다
- 수동으로 하거나 이후에 QA에 의해 테스트하는 것이 비용대비 효과가 높다
- Stability가 낮은 경우
- Reliability
- 테스트는 본질적으로 문제가 없음을 보장할 수 없고, 우리에게 주어진 리소스에도 한계가 있다
- ‘버그가 있을 확률 × 버그에 의한 피해’가 높은 순서대로 테스트를 작성
구현에 의존적인 테스트를 만들지 말자
- 알람을 위해 특정 외부 모듈을 호출한다는 것을 보장하는 게 중요한 일일 때
- 최종 목적이 알람이라면, 의존하는 모듈이 바뀔 때 코드가 수정되어야 한다. (Stability가 낮다)
- 통합 테스트가 대안일 수 있다(how가 아닌 what에 집중)
- 이때 로그를 확인하는 식으로 통합테스트를 작성하면 구현에 관계없이 기능을 검증할 수 있다
- 그러나 시간이 더 걸린다
- 오류가 생겼을 때, 어디에서 문제가 생겼는지 상대적으로 알기 어렵다
- SUT가 아닌 다른 곳에 의존성이 생길 수가 있다(이 경우는 로그)
구현에 의존적인 테스트를 만들어야 한다면 최대한 의존하는 부분을 줄이며 테스트를 만들자
- 느슨한 검증
- 호출하는 메소드가 많다면, 특정 코드 블럭이 실행되는지(조건문 안에 들어가는지) 정도 확인하는 것으로 충분할 수 있다
- 특정 조건문에 의해 실행 여부만 확인하는 것이라면 조건문만 통과하는 지를 확인하면 된다
- Law of Demeter
- Tell, Don’t Ask
- 접점/커플링을 줄인다
Mocks Aren’t Stubs
https://martinfowler.com/articles/mocksArentStubs.html
행위 검증과 상태 검증, Mock과 stub의 차이를 이해하는데 매우 도움이 많이 된 글이다. 아래 요약글을 읽느니 번역된 글을 몇 번 더 읽는 것을 추천한다. 이건 내 블로그니까 나중에 다시 돌아볼 목적으로 정리해보자면,
Mocks과 Stubs 차이
- 테스트 결과가 검증되는 방식 - 상태 검증과 행위 검증
- 테스팅과 설계가 어우러지는 방식에 대한 철학
용어 구분
- SUT(System Under Test) = 주요 객체(primary object)
- 협력객체(collaborator) = 부차적 객체(secondary objects)
일반적인 테스트 (Regular Tests)
- 협력객체가 필요한 이유
- 테스트하려는 동작이 최소한 실행은 되었는지 확인하기 위해
- 검증에 필요하기 때문. order.fill()에는 warehouse의 상태에 잠재적 변화를 일으키니까
- 첫 예제는 상태 검증이다
- 메소드가 수행된 후 SUT와 협력객체의 상태를 살펴봄으로써 실행된 메소드가 올바로 동작했는지 판단
모의객체를 이용한 테스트(Tests with Mock Objects)
- 예측을 준비하고 → SUT를 돌려본 다음 → 검증
- SUT에 대해 assert하는 것은 예전과 같다
- 모의객체를 검증하는 것 - 예측에 맞게 호출 되었는지 확인
- 여기서 핵심적인 차이는 order가 warehouse와 상호작용할 때 올바로 수행되었는지 어떻게 확인하느냐이다
- 첫번째 테스트
- 상태 검증에서는 order나 warehouse의 상태를 assert 함으로써 할 수 있다
- mock은 행위 검증을 한다
- setup에서 모의객체에게 예측치를 알려주고 검증시에는 스스로 확인해보라고 한다
- assert로 확인하는 것은 order뿐이고, 수행한 메소드가 order의 상태를 변경하지 않는다면 assert는 아예 필요 없다
- 두번째 테스트
- 생성자가 아닌 MockObjectTestCase의 mock 메소드를 사용(명시적으로 verify를 호출할 필요가 없다)
- withAnyArguments를 써서 예측의 제약을 완화시켰다. 사실 withAnyArguments가 default라서 생략해도 괜찮았다
Test Double
- Dummy 객체는 전달되기만 하고 실제 사용되지는 않는다. 보통 파라미터 리스트를 채우는데에 사용된다
- Fake 객체는 동작하는 구현이 있다. 하지만 운영시에는 사용할 수 없는 간단한 형태이다. (인메모리 데이타베이스가 좋은 예이다)
- Stubs은 테스트시 호출이 되면 미리 준비된 답변으로 응답하는데, 테스트 시에 프로그램된 것 이외의 것에 대해서는 응답하지 않는다. 스텁은 호출에 대한 정보를 기록할 수도 있을 것이다. 이메일 게이트웨이 스텁은 ‘보낸’ 메시지들 혹은 몇개의 메시지를 ‘보냈’는가를 기억하는 것이다
- Mocks는 수신하기를 기대하는 호출의 명세(specification)인 예측으로 미리 프로그램 된 객체이다
- Mocks만 행위 검증 사용을 추구한다
- excercise 단계에서는 다른 더블과 같이 동작하지만, setup과 verification단계에서는 다르다
- 많은 사람들은 실제 객체를 다루기 불편할 때에만 테스트더블을 사용한다
MailServiceStub 예제
- public class MailServiceStub implements MailService {
- 메시지가 보내졌다는 것을 테스트한다
- 올바른 수신자에게 보내졌는지, 올바른 내용으로 보내졌는지는 테스트하지 않았지만 핵심을 보여주고 있다
- 스텁에 상태검증을 하려고 검증에 도움이 될 추가적인 메소드를 만들었다. 스텁은 MailService 인터페이스를 구현하고 추가된 테스트 메소드도 있다
Mock objects는 항상 행위검증을 사용하고, 스텁은 둘 다 가능하다. Meszaros는 행위검증을 하는 스텁을 가리켜 테스트스파이(Test Spy)라고 부른다
- 차이점은 더블이 얼마나 정확히 수행과 검증을 하는가이다
언제 모의객체(혹은 다른 더블)를 사용할 것인가
- classical TDD 스타일은 가능하면 진짜 객체를 사용하고 진짜 객체를 사용하기가 만만하지 않으면 더블을 사용한다. 그러므로 고전적 TDD 실천가들은 warehouse에는 진짜 warehouse를 사용하고 메일 서비스에는 더블을 사용할 것이다. 더블의 종류는 그리 중요하지 않다.
- mockist TDD 실천가는 관심있는 행위를 가진 모든 객체에 모의객체를 사용하려 한다. 예제에서는 warehouse와 메일서비스 모두에 대해서이다.
- (BDD는 모의객체 접근법을 취한다.)
- 차이점 중에서 선택하기
- 컨텍스트
- order와 warehouse 사이처럼 객체들간의 협력이 간단한가 아니면 order와 메일서비스처럼 쉽지 않은가?
- 간단한 협력이라면 고민할 게 없다. classical TDD에선 진짜 객체, mockist TDD에선 mock
- 간단하지 않다면, mockist TDD에선 당연히 mock과 행위 검증. classical TDD에선 가장 쉬운 방법을 선택
- 캐시처럼 다루기 힘든 협력관계가 아닌데도 상태검증을 하기 쉽지 않은 경우는 행위 검증이 좋을 것이다. 그 반대의 경우도 존재함
- mock object는 XP 커뮤니티로부터 왔다.
- 만들고자 하는 시스템 외부에서 SUT의 인터페이스를 만들면서 테스트를 작성함으로써 스토리 개발을 시작한다 (outside-in)
- 모의객체에 대한 예측은 다음 단계의 명세가 되고 테스트들의 시작점이 된다
- 만들고자 하는 시스템 외부에서 SUT의 인터페이스를 만들면서 테스트를 작성함으로써 스토리 개발을 시작한다 (outside-in)
- classical TDD는 어떤 기능을 택하고 이 기능이 동작하려면 도메인에서 무엇이 필요한지 결정한다 (middle-out)
- 필요한 것을 도메인 객체들이 수행하게 하고 일단 동작하면 그 위에 UI를 올린다
- 컨텍스트
- 픽스처 준비 (Fixture Setup)
- 고전주의 TDD에서는, SUT 뿐만 아니라 테스트의 필요에 따라 SUT가 필요로 하는 모든 협력 객체들도 만든다.
- 복잡한 픽스처를 가능한 한 재사용하려 한다
- 대부분의 픽스처는 생성 비용이 크지 않고, 만약 크다면 보통 더블이 사용된다
- 모의객체를 이용한 테스트에서는, SUT와 그에 인접한 모의객체만을 작성하면 된다. 복잡한 픽스처를 구성하는 작업을 피할 수 있다 (최소한 이론적으로는)
- 고전주의 TDD에서는, SUT 뿐만 아니라 테스트의 필요에 따라 SUT가 필요로 하는 모든 협력 객체들도 만든다.
- 테스트의 고립성 (Test Isolation)
- mockist TDD에선 시스템에서 버그가 생기면, 보통 SUT가 버그를 가진 테스트만 실패하게 된다
- 대신 협력 객체에 대해 정교한 테스트를 만들어야 한다는 것이 명확하다
- classical TDD에선 이 객체를 이용하는 클라이언트가 있는 모든 테스트도 실패하며, 다른 객체를 테스트할 때 버그를 가진 객체가 협력객체로 사용되는 곳에서 모두 실패하게 된다
- 결과적으로 매우 많이 사용되는 객체가 실패하면 시스템 전체에 걸쳐 실패하는 테스트의 파장이 일어난다
- 하나의 테스트가, 하나의 객체가 아닌 여러 객체의 클러스터에 대해 수행되는 주요 테스트로서의 역할을 하고 있다
- 테스트가 너무 정교하지 않은 경향이 있지만, 디버깅에 어려움을 겪는다면 정교한 테스트를 (TDD 기법으로) 만들어 나가야 한다.
- 본질적으로 고전적 xunit 테스트는 단지 유닛테스트일 뿐 아니라, 작은-통합 테스트이기도 하다
- 한 객체를 테스트할 때에 놓쳤을지 모를 에러를, 클라이언트 테스트가 잡을 수 있다
- 모의객체 테스트는 그런 품질을 잃게 된다
- 어떤 스타일의 테스트를 하든, 전체 시스템에 걸쳐 수행되는 거친 정밀도의 인수테스트와 결합해야 한다
- mockist TDD에선 시스템에서 버그가 생기면, 보통 SUT가 버그를 가진 테스트만 실패하게 된다
- 테스트와 구현의 결합성 (Coupling Tests to Implementations)
- 모의객체 테스트를 작성하는 것은, SUT가 그것의 공급자와 올바로 대화하는지 확인하기 위해 SUT의 나가는 호출을 테스트하는 것
- 따라서 모의객체 테스트는 메소드의 구현에 많이 결합되어 있다
- 모의객체 테스팅에서는 테스트 작성시 행위 구현에 대해 생각하게 만든다
- 고전주의 테스트는 최종 상태에만 신경 쓴다
- 고전주의자들은 외부 인터페이스 호출로부터 어떤 일이 일어나는지에 대해서만 생각하고 구현의 모든 고려사항들은 테스트 작성이 끝난 이후로 남기는 것이 중요하다고 생각
- 구현에 결합되는 것은 리팩토링에도 간섭을 일으키는데, 구현이 바뀌면 고전적 테스팅보다 테스트를 깨트릴 가능성이 훨씬 더 많기 때문이다
- Tell, Don’t ask
- 상태기반 검증에서 알려진 이슈가 단지 검증에 쓰려고 접근자 메소드를 만들게 된다는 것이다
- 테스팅만을 위해서 어떤 객체의 API에 메소드를 추가하는 것은 전혀 납득할 수 없는 것인데, 행위 검증을 사용하면 그런 문제를 피할 수 있다
- 모의객체주의자들은 역할 인터페이스(role interfaces) 를 좋아하고 모의객체 스타일이 역할 인터페이스를 장려한다고 단언한다
- 상태기반 검증에서 알려진 이슈가 단지 검증에 쓰려고 접근자 메소드를 만들게 된다는 것이다
- 모의객체 테스트를 작성하는 것은, SUT가 그것의 공급자와 올바로 대화하는지 확인하기 위해 SUT의 나가는 호출을 테스트하는 것
- mockist가 도움이 될 때
- 당신이 실패하는 테스트를 디버깅하느라 많은 시간을 보내고 있는데 그 실패가 깔끔하지 않고 문제가 어딘지 알려주지 않는 경우
- 객체가 충분히 행위를 가지고 있지 않아서, 모의객체 테스팅이 개발팀에게 행위가 더 풍부한 객체를 만들도록 장려할 수 있을 때
기타 더 읽어 볼 것
- “테스트 대역은 일종의 가정이고 가정이 늘어날 수록 실제 결과는 예측하기 어려워진다.”
결론
마틴 파울러 옹이나 여러 선구자들의 의견을 종합해볼 때 mock을 사용하는 것은 좋지 않다고 생각이 들지도 모르겠다. 그런데 “테스트 주도 개발로 배우는 객체 지향 설계와 실천”에서도 이 분들이 강조하는 소프트웨어 설계 원칙을 매우 강조하면서도 mock을 써야할 이유를 설명한다. Mock을 SUT의 행위를 검증하는 것 정도로만 보지 않고 많은 개발 언어에서 충분히 표현하기 어려운(인터페이스 만으로는 관계를 보여주는데 한계가 있다고도 주장한다) 객체간의 관계를 mock을 통한 테스트로 더 명확히 보여줄 수 있다는 생각이다. 정말 그런 가치가 있을지는 책을 다 읽어보고 판단할 일이다. 이 책은 단순히 mock을 잘 쓰는 법만 알려주진 않는다.
내가 추천하면 아무도 안 믿을테니 켄트백의 추천사 중 일부를 소개해본다.
이 책에 나온 테스트 주도 개발 방식은 내가 연습했던 것과는 다르다. 아직까진 그 차이를 분명하게 표현할 수 없지만 지은이들이 자신의 기법을 명확하고 자신감있게 발표하는 것을 보고 배운 바가 있다. 기법의 다양성은 나만의 개발 방법을 더욱 다듬는 새로운 영감의 원천이 되었다. “테스트 주도 개발로 배우는 객체 지향 설계와 실천”에서는 다양한 기법이 서로를 떠받치는 논리정연하고 일관된 개발 체계를 제시한다.