테스트란?
테스트란 시스템이나 소프트웨어가 의도한 대로 동작하는 지를 검증하는 행위로, '얼마나 잘 작동하는지', '언제 그렇지 못한지'를 확인하는 행위이다.
그렇다면 이 행위를 어떻게 할 수 있는 걸까?
만약에 여러분이 네이버로 이직해서 포털 사이트의 백엔드 시스템의 몇몇 기능들을 변경해야 하는 상황이라고 해보자. 그래서 테스트를 통해 기능을 변경하려고 했다. 그런데 테스트 코드가 하나도 없어서 이 거대한 시스템을 변경하기 어려울 것 같다고 느끼는 상황이라면 어떻게 해야 하는걸까?
이런 상황에서 테스트로 시스템을 변경하는 방법에는 두 가지 방법이 있다.
첫 째, 편집 후 기도하기
둘 째, 보호 후 수정하기
편집 후 기도하기?
전통적인 테스트 방법 중에 하나로 아래와 같은 과정을 갖는다.
코드 변경 계획 세우기
코드 이해하기
변경하기
테스트 코드 작성하기
동작 확인하기
기도하기 🙏
그러나 이 방법은 변경 전에 계획을 해야 하고, 이를 이해하기 위한 시간이 소모된다는 단점이 있다. 이는 변경 후에 피드백을 받기 까지, 버그를 발견하기 까지 오랜 시간이 걸린다는 것이고 이를 고치는 비용이 증가할 수 있음을 의미한다.
보호 후 수정하기?
변경 대상 주변에 테스트 루틴을 배치해 보호하는 방법이다.
테스트 코드를 먼저 작성
변경하기
돌려보고
수정하고
검증하기
반복 🔄
이 방법을 사용하면 테스트와 그에 대한 반복을 통해 피드백을 빨리 받을 수 있고, 그렇기에 자신감 있게 기능을 추가하거나 변경할 수 있게 된다. 그런데 이 방법 어디서 많이 보던 사이클이지 않은가?
그렇다. 테스트 주도 개발과 굉장히 유사하다!
테스트 주도 개발?
테스트 주도 개발이란 개발 초기 단계부터 테스트를 중심으로 코드를 작성하는 방법론이다.
하지만 이 글에서 나는 이를 테스트가 없는 시스템을 변경하는 방법론이라 정의하고 싶다.
왜냐하면 단순히 새로운 기능을 개발할 때만 유용한 것이 아니라, 기존에 테스트가 없는 레거시 시스템을 안전하게 변경하고 개선하는 데에도 매우 효과적이기 때문이다.
테스트 주도 개발은 아래와 같은 과정을 반복하게 된다.
테스트 실패하기
예외 상황을 먼저 처리하기 ⭐️
테스트 통과하기
리팩토링
반복
이 때, 예외 상황을 먼저 처리하면 개발하고 있는 기능의 전체 구조를 정리하기에 좋다. 예외 상황을 먼저 고려함으로써 코드의 흐름이 더 명확해질 수 있고, 정상 동작과 예외 동작을 구분하기 쉬워진다.
왜 해야 하는가?
버그 없는❌ → 버그를 잡기 쉬운⭕️
리팩토링 → 응집도⬆️, 결합도⬇️ → 모듈화
테스트 가능한 구조 지향
여러 설계를 지원하기에 좋음
- DDD, FSD, Clean Architecture, Hexagonal Architecture
테스트 코드 자체가 실행 가능한 문서 → 특정 상황에서 동작하는 예제 코드
디버깅 개선 → 테스트 코드로 오류 위치를 쉽게 파악
리뷰 간소화 → 리뷰어가 테스트가 잘 통과하는지 파악
- 극단적인 이유 → 여기 사람 죽어요 😱
왜 하지 않는가?
결정적인 이유는 생산성 저하 때문
러닝커브📈
빠르게 개발⚡️
납기일 준수📆
빈번한 요구사항 변경🤬
대규모 레거시 → 많은 리팩토링✏️
너무 많은 외부 의존성 → 테스트 어려움😰 또는 느린 테스트🐌
TDD를 하기 전 알아야 할 개념
테스트 3종 세트
스몰 테스트
→ 클래스나 함수 같은 원자적 동작 단위
→ 이것마저 쪼갠
→ 프로세스 하나
→ 단위 테스트미디움 테스트
→ 컴포넌트들 간의 상호작용
→ 여러 프로세스
→ 통합 테스트빅 테스트
→ 여러 시스템 간의 상호작용
→ E2E 테스트
테스트 제약
스몰 테스트
서버 연결❌
블로킹 호출❌ → Sleep, File System, etc
미디움 테스트
여러 컴포넌트⭕️
localhost로만 연결⭕️
블로킹 호출⭕️
빅 테스트
여러 시스템⭕️
→ 코드가 아닌 설정 검증 → 예) 원격 클러스터에서 구동 중인 시스템과 상호작용테스트 불가능한 구조
개발 워크플로에 영향을 주지 않아야할 때 → 빌드, 릴리즈
어떤 테스트를 우선시 해야 하는가?
스몰 테스트를 우선해야 한다!
왜?
속도가 빠름 → 자주 실행할 수 있음 → 빠른 변경
결정적일 경우가 많음 → 더 결정적이게 만들수도 있음
오류 위치 파악이 쉬움
어떤 테스트를 덜 해야 하는가?
미디움과 빅 테스트
왜?
외부 요소가 개입됨 → 테스트하기 어려움
→ 속도가 느림 → 자주 실행해 볼 수 없음
→ 비결정적인 경우가 많음 → 테스트가 깨지기 쉬움SUT로 부터 멀어질수록 오류 위치 파악 힘듬
테스트 피라미드
아이스크림 콘과 모래 시계
테스트 대역
실제 구현 대신 사용하는 객체나 함수
가짜 객체
페이크
실제 구현에 어느정도 충실한 동작
외부 의존성을 어느정도 대체 → 인메모리 데이터베이스
모의 객체
스텁 + 스파이
→ 스텁? 최대한 단순하게 만들어 덧씌우는 대역
→ 스파이? 호출된 내역을 기록기대한대로 상호작용 하는지
모의 객체 프레임워크를 사용
테스트 대역의 효과
- 테스트 가능한 구조를 지향
→ 테스트 가능한 구조로 바뀌어야 한다면 대역이 필요하기 때문 → 가짜 객체
→ 그런데 무조건은 아님 → 모의 객체 때문
TDD를 하면서 고려해야 할 점
변하지 않는 테스트 만들기
테스트는 요구사항이 변경되지 않는 한 절대로 변하지 않아야 한다.
순수 리팩토링할 때
인터페이스는 놔두고 내부 로직만 리팩토링
행위가 변경❌
새로운 기능 추가 또는 버그 수정할 때
- 기존에 있던 기능과 테스트에 영향❌
시스템 행위 변경할 때
이 상황이 오면 절대 안됨 ☠️
행위 변경 → 입/출력, 상호작용 모두 변경 → 테스트 코드도 새로 작성
→ 비용 증가
모의 객체를 남용하지 마세요
- 상호작용만을 테스트하게 됨
→ 어떤 모듈이 다른 모듈과 협력할 때 기대한 동작이 수행되었는가
→ 내부 구현을 단순한 걸로 덮어씀
→ 실제 구현과 다름을 의미
→ 그러면 상태를 어떻게 검증하지?
→ 테스트 신뢰성 하락
@Test
public void shouldWriteToDatabase() {
accounts.createUser("foobar");
verify(database).put("foobar"); // database가 put()을 호출했는 지 확인
}
내부 구현이 쓰기 후 바로 삭제가 되는데 통과
put()을 add()로 리팩토링해 같은 기능을 하는 API로 바꿨는데 실패
@Test
public void shouldCreateUsers() {
accounts.createUser("foobar");
assertThat(accounts.getUser("foobar")).isNotNull();
}
상호작용이 아닌 상태를 테스트
→ 어떤 동작을 하고 난 후의 값이나 객체의 상태 확인
→ 테스트할 때 제일 관심있는 것만 표현쉽게 깨지지 않는 테스트!
언제 모의 객체를 사용해도 될까?
정말로 상태 테스트가 불가능할 때
- 예를 들어,
sendMail()
,saveRecord()
- 예를 들어,
테스트 하나에서 최대 하나만 쓰도록 노력
가능하면 실제 구현이나 가짜 객체 사용 ⭐️
실제 구현? 빠르고 결정적이며 의존성 구조가 단순한 것
→ 값 객체(VO), 컬렉션 클래스(리스트, 맵, 금액, 날짜, 주소 등)가짜 객체? 실제 구현이 복잡한 구조일 때
→ 멀티 스레드 수행 순서, 통제 불가한 외부 서비스 등
References
마이클 C. 페더스, "레거시 코드 활용 전략", 에이콘출판사
타이터스 윈터스 외 2명, "구글 엔지니어는 이렇게 일한다", 한빛미디어 ⭐️
백명석, 최범균, "백발의 개발자를 꿈꾸며", 패스트캠퍼스
김우근, "Java/Spring 주니어 개발자를 위한 오답노트", 인프런 ⭐️
김우근, "Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트", 인프런 ⭐️