🌱 들어가기 전
이번 포스팅에서는 테스트에 대한 개발자의 고민 내용과 이론 및 개념을 살펴보자.
- 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공부하고 정리한 글입니다. ✏️
지난 포스팅과 이어집니다 !
https://deeper-dev.tistory.com/10
[테스트 코드와 설계] 테스트 코드 작성과 리팩토링
🌱 들어가기 전이번 포스팅에서는 간단한 계산기 기능의 코드를 리팩토링하고 테스트를 작성하는 과정을 살펴보자.- 김우근님의 'Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트'를 공
deeper-dev.tistory.com
<고민>
🌱 테스트
- 자동테스트 : 테스트 코드라는 미리 짜여진 코드를 돌려서 결과값이랑 예상한 값을 비교
💭 구조
//given : 이런 상황이 주어졌을때
//when : 이런 호출을 하면
//then : 이렇게 된다.
🌱 TDD
💭 테스트 주도 개발
1. RED : 깨지는 테스트 먼저 작성
테스트 코드는 똑바로 작성하되, 실제 코드 구현부는 구현하지 않고 에러를 던지도록 한다.
즉, 일단 컴파일되는 코드를 만드는 것이다.
중요한건 테스트가 실패하는지까지 제대로 확인하는 것!
-실제 코드 구현부 예시
public Entity getPostById(Long id) {
throw new RuntimeException("Method is not implemented.");
}
2. GREEN : 깨지는 테스트를 성공시키기
실제 코드 구현부를 제대로 구현한다.
테스트를 돌려 통과하는 것을 확인한다.
3. BLUE : 리팩토링
앞에서 짠 실제 코드 구현부를 리팩토링한다.
복잡한 코드에서는 이 과정이 파괴적일 수 있다. 하지만, 이전 단계에서 GREEN인 것을 확인했으니 바로 잘못된 리팩토링인지 아닌지 알 수 있다.
... 무한반복
💭 테스트 주도 개발의 장단점
1. 깨지는 테스트를 먼저 작성해야해서, 인터페이스를 먼저 만드는 것이 강제된다.
개발자는 구현체보다 인터페이스 만드는데 집중하게 된다: 알고리즘이 아니라, 객체들이 어떤 책임을 지고, 역할은 무엇인지 생각하게 만든다.
→ 인터페이스에 주목한다는건 객체지향의 핵심 원리 중 하나인, 행동에 집중한다는 말과 같다
이 과정을 통해 개발자가 저지르는 다수의 실수(인터페이스를 만들지 않고, 구현체부터 만드는 실수)가 교정된다.
객체 사이 협력 관계를 설계하기 위해, 먼저 어떤 행위(what)를 수행할 것인지 결정한 후, 누가(who) 그 행위를 수행할 것인지 결정해야 한다.
→ TDD가 What/Who 사이클을 고민하게 도와준다.
2. 장기적인 관점에서 개발 비용 감소
대부분의 코드를 커버하는 테스트가 생기게 된다. 따라서 신규 개발을 하게되어도 부담없이 개발할 수 있게된다.
🌱 개발자의 고민
💭 무의미한 테스트
ex ) Jpa의 함수(findById)만 호출하는 서비스 함수
public Entity getPostById(Long id) {
return postRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("Post", id));
}
위 코드는 2개의 테스트가 나올 것이다.
1. 데이터 찾을 수 있을 때, 데이터 반환
2. 데이터 찾을 수 없을 때, Exception 던진다.
이런건 JPA 구현체에서 알아서 잘 테스트 했을 것이다. 고작 이 코드를 위해 많은 테스트 코드를 짜야할까?
앞에서 말한, ‘TDD덕분에 개발자는 행동에 집중할 수 있게됐다’ 에서의 ‘행동’은 ‘메서드’나 ‘함수’가 아니다.
따라서, 모든 메서드를 테스트하기보다, 중요한 로직을 잘 구분해서 그 코드에 테스트 넣는게 좋다.
💭 느리고 쉽게 깨지는 테스트
💭 테스트가 불가한 코드
ex) UserService
// 간편한 예시를 위해, 코드를 간소화했습니다!
public void login(long id) {
User user = userRepository.findById(id);
user.setLastLoginAt(Clock.systemUTC().millis()); // 로그인 현재시간 기록
}
이 코드는 로그인할 때 현재 시간을 기록하고 있다. 과연 어떻게 테스트 할까?
UserServiceTest
void 사용자는_login_하면_마지막_로그인_시간이_현재_시간으로_기록된다() {
// given
long id = 1;
// when
userService.login(id); // 여기랑
//then
User user = userRepository.findById(id);
assertThat(user.getLastLoginAt()).isEqualTo(현재시간); // 여기의 시간은 다르다.
}
mock라이브러리를 넣으면 억지로 해결할 수 있다.
→ 신호다! 테스트를 못하는 상황이니까, 설계가 잘못됐다고 말하는 것이다. 강제로 테스트를 넣지말고, 설계를 발전시키자.
🌱 정리
테스트는 좋은 설계를 유도한다.
앞서 본 고민중 일부는 테스트가 보내는 신호다.
강제로 테스트를 넣지말고, 무언가 잘못됐으니 설계를 수정하자.
<이론>
🌱 테스트 코드의 필요성
- Regression 방지
- 좋은 아키텍처를 유도 (SOLID)
🌱 번외) 좋은 아키텍처란?
SOLID와 Test는 긴밀한 상관관계가 있다.
SOLID 원칙이 지켜지면 경계가 만들어지고, 회귀버그가 생기는 것을 막을 수 있다.
S : 단일 책임 원칙
- 하나의 클래스는 하나의 기능만 가지며, 클래스가 제공하는 모든 서비스는 그 하나의 책임을 수행하는데 집중되어야 한다. 어떤 변화에 의해 클래스를 변경해야하는 이유는 오직하나여야 한다.
테스트는 간단 명료하게 작성해야하기때문에, 단일 책임 원칙을 지키게 된다.
Why? 테스트 클래스에 테스트가 많아지면, 이 클래스의 목적이 눈에 안들어온다.
눈에 안들어오는 지점이 생기는건 테스트가 보내는 신호다! ‘이 클래스에 책임이 너무 과한거 아니야?’
이 때가 클래스를 분할해야하는 시점이다. 그러면서 책임이 자연스럽게 분배된다.
O : 개방 폐쇄 원칙
- 모든 소프트웨어 구성요소는 확장에는 열려있고 변경에는 닫혀있어야한다.
테스트 작성을 위해서는 테스트 컴포넌트와 프로덕션 컴포넌트를 나눠 작업하게 되고, 필요에 따라 컴포넌트 자유자재로 탈부착이 가능하게 개발하게 된다. 그리고 서로에게 영향을 주어서는 안된다. 이 과정에서 프로덕션 코드가 OCP원칙을 지키게 된다.
L : 리스코프 치환 원칙
- 서브타입은 언제나 기반타입으로 교체할 수 있어야한다.
이상적으로 테스트는 모든 케이스에 대해 커버하고있기때문에, 서브 클래스가 제대로 치환하는지를 테스트가 판단해준다.
I : 인터페이스 분리 원칙
- 자신이 사용하지 않는 인터페이스는 구현하지 말아야한다.
- 하나의 일반적인 인터페이스보다는 구체적인 여러개의 인터페이스가 낫다.
테스트는 그 자체로 인터페이스를 직접 사용해 볼 수 있는 환경이다. 그래서 테스트를 작성하다보면 불필요한 의존성이 보이곤한다. “인터페이스가 너무 많아서 뭘 호출해야할지 모르겠는데?” 라는 고민을 하게 된다. 이때가 인터페이스를 분리해야하는 시점인 것이다.
결국 단일 책임 원칙과 맥락이 같다.
D : 의존성 역전 원칙
- 객체는 구체적인 객체가 아닌 추상화에 의존해야 한다.
보통 테스트할 때에 가짜 객체(Fake, dummy)를 많이 이용한다. 이를 자연스럽게 하기 위해서는 의존성이 역전되어있어야 하는 경우가 많다.
테스트가 SOLID를 강제하는 것은 아니지만, 테스트를 넣으면서 이런 것도 함께 챙겨줘야 한다는 의미이다.
'어떻게 테스트 할까?'를 고민하다보면, 자연스럽게 SOLID가 따라온다.
테스트가 주는 가치에 대해 진지하게 고민해서, 회귀 버그 방지와 좋은 설계를 다 잡을 수 있도록 노력해야 한다.
🌱 테스트 3분류
💭 전통적인 3분류
API 테스트 - 통합 테스트 - 단위 테스트
사람마다 정의가 다르고, 모호하다.
💭 구글의 테스트 3분류
대형 테스트 - 중형 테스트 - 소형 테스트
소형 테스트
- 환경 : 단일 서버, 단일 프로세스, 단일 스레드
- 디스크 I/O 사용 x
- Blocking call 허용 x
ex) Thread.sleep()이 테스트에 있으면 소형테스트가 아니다.
결과가 항상 결정적이고, 테스트 속도가 빠르다.
따라서 소형 테스트가 정말 중요하다.
전체 테스트의 80% 차지해야한다. 소형 테스트를 여러개 만들어서 코드 커버리지를 높여야한다. 소형 테스트를 만들 수 있는 환경을 만들고 소형 테스트를 늘려야한다.
중형 테스트
- 환경 : 단일 서버, 멀티 프로세스, 멀티 스레드
ex ) h2 같은 테스트 DB를 사용할 수 있다.
소형테스트보다 속도가 느리다.
멀티 스레드 환경에서 어떻게 동작할지 모르기 때문에 결과가 항상 같다는 보장을 하지 못한다. 테스트 결과가 h2같은 외부 모듈의 동작에 따라 달라지기 때문이다.
전체 테스트의 15% 차지해야한다. 그러나, 스프링 개발자들이 중형 테스트를 너무 많이 만드는 실수를 저지르고 있다.
모든 테스트가 h2같은 테스트용 DB를 사용하고 있으면, 모든 테스트가 중형 테스트가 되는 것이라 좋은 방향이 아니다.
대형 테스트
- 환경 : 멀티 서버
- End to end 테스트
전체 테스트의 5% 차지해야한다.
<개념>
🌱 개념
- SUT
System under test (테스트하려는 대상, 객체)
- BDD
Behaviour driven development (given - when - then)
테스트를 작성하다보면, ‘어디에 어떻게 테스트를 넣어야하지?’ 라는 질문을 마주하게 된다.
이때, BDD가 답을 준다: 행동에 집중해야한다! 유저가 시스템을 사용하는 user story를 강조하고 시나리오를 강조한다.
given - when - then = BDD를 지키기위한 뼈대이다. ( = ‘Arrange - Act- Assert’ 와 동일)
- given : 어떤 상황이 주어졌을 때
- when : 이 행동을 하면
- then : 결과가 이렇다.
- 상호 작용 테스트
대상 함수의 구현을 호출하지 않으면서 그 함수가 어떻게 호출되는지를 검증하는 기법
즉, 메서드가 실제로 호출됐는지 검증하는 테스트이다.
// then
verify(sut).markModified(); // interaction test
그런데, 메서드가 실제로 호출됐는지 검증하는 것은 내부 구현을 어떻게 했는지 감시하는 것이기 때문에 좋은 방법이 아니다. 캡슐화에 위배된다.
우리는 객체한테 위임한 책임을 객체가 제대로 수행했는지만 확인하면 되는데, 구현에 집착하여 일을 이리해라 저리해라 감시하는 것이다.
상호 작용 테스트보다, 상태를 테스트 하는 것이 좋다.
// then
assertThat(sut.isModified()).isTrue();
- 상태 검증 vs 행위 검증
상태 기반 검증: 어떤 값을 넣었을 때 결과값을 기댓값과 비교하는 방식
행위 기반 검증: 어떤 값을 넣었을 때 협력 객체에 어떤 메서드를 실행하는지 확인
- 테스트 픽스처
테스트에 필요한 자원을 생성하는 것
ex) 어떤 테스트를 하기 위해, 필요한 SUT 혹은 SUT에 들어가는 의존성 일부를 @BeforeEach를 써서 테스트 전에 미리 만든다. 이 때 SUT가 테스트 픽스처이다.
- 비욘세 규칙
상태를 유지하고 싶었다면, 테스트를 만들었어야지
유지하고 싶은 상태가 있으면 전부 테스트로 작성해주세요. 그게 곧 정책이 될 겁니다.
테스트는 정책이고 프로그램이 지켜야 할 계약이다.
- Testability
테스트 가능성. 소프트웨어가 테스트 가능한 구조인가?
- test double
테스트 대역
🌱 대역
- Dummy
아무런 동작도 하지 않고, 그저 코드가 정상적으로 동작하기 위해 전달하는 객체
ex) 회원가입시 이메일을 보내는 시스템 테스트
테스트할 때마다 인증메일을 보내야할까? 그럴 수는 없다. 그래서 이메일 발송 부분에 가짜 객체를 넣는다.
// 중요한 부분만 표현
public void 이메일_회원가입() {
// given
// when
UserService sut = UserService.builder()
.registerEmailSender(new DummyRegisterEmailSender())
.build();
// then
assertThat(user.isPending()).isTrue();
}
일단 테스트가 돌아가게 하고, 결과 부분에서 사용자가 메일 인증을 대기하고 있는 상태인지 검증만 한다.
class DummyRegisterEmailSender implements RegisterEmailSender {
@Override
public void send(STring email, String message) {
// do nothing
}
}
- Fake
Local에서 사용하거나 테스트에서 사용하기 위해 만들어진 가짜 객체. 자체적인 로직이 있다는게 특징
ex) 회원가입 메일 내용이 제대로 만들어졌는지 테스트하고 싶다고 가정하자. 이때 Fake를 사용할 수 있다.
@Test
public void 이메일_회원가입() {
// given
UserCreateRequest userCreateRequest = UserCreateRequest.builder()
.email("test@local.com")
.password("123")
.build();
FakeRegisterEmailSender registerEmailSender = new FakeRegisterEmailSender();
// when
UserService sut = UserService.builder()
.registerEmailSender(registerEmailSender)
.userRepository(userRepository)
.build();
sut.register(userCreateRequest);
// then
User user = userRepository.getByEmail("test@local.com");
assertThat(user.isPending()).isTrue();
assertThat(registerEmailSender.findLatestMessage("test@local.com").isPresent()).isTrue(); // 검증
assertThat(registerEmailSender.findLatestMessage("test@local.com").get()).isEqualTo("~~~"); // 검증
}
class FakeRegisterEmailSender implements RegisterEmailSender {
private final Map<String, List<String>> latestMessage = new HashMap<>();
// 발송한 메시지를 기록하는 로직을 들고있다. 덕분에 테스트 코드에서도 어떤 메시지인지 확인할 수 있다.
@Override
public void send(String email, String message) {
List<String> records = latestMessage.getOrDefault(email, new ArrayList<>());
records.add(message);
latestMessages.put(email, records);
}
public Optional<String> findLatesMessage(String email) {
return latestMessages.getOrDefault(email, new ArrayList<>().stream().findFirst());
}
}
- Stub
미리 준비된 값을 출력하는 객체
외부 연동하는 컴포넌트들에 많이 사용한다.
ex) 아래 stub repo는 다른 로직없이 이메일이 일치하면 사용자 값을 내려준다.
class StubUserRepository implements UserRepository {
public User getByEmail(String email) {
if (email.equals("test@local.com")) {
return User.builder()
.email("test@local.com")
.status("PENDING")
.build();
}
throw new UsernameNotFoundException(email);
}
}
mokito를 이용해 구현할 수도 있다.
// given
given(userRepository.getByEmail("test@local.com")).willReturn(User.builder()
.email("test@local.com")
.status("PENDING")
.build());
- Mock
메소드 호출을 확인하기 위한 객체
자가 검증 능력을 갖춤. 사실상 테스트 더블과 동일한 의미로 사용된다. (stub, dummy, fake 다 mock이라고 부른다)
final class MockMailer implements Mailer
{
private bool hasBeenCalled = false;
public function sendWelcomeEmail(UserId userId): void
{
this.hasBeenCalled = true;
}
public function hasBeenCalled): bool
{
return this.hasBeenCalled;
}
}
이메일 전송 요청이 오면 요청이 온 사실을 기록해뒀다가 나중에 확인할 수 있도록 한다.
- Spy
메소드 호출을 전부 기록해뒀다가 나중에 확인하기 위한 객체
메소드가 몇 번 호출됐는지, 잘 호출됐는지 검증할 수 있다. 이 외에 다른 정보도 더 기록해서 검증에 사용하기도 한다.
final class EventDispatcherSpy implements EventDispatcher
{
private array events = [];
public function dispatch(object event): void
{
this.events[] = event;
}
public function dispatchedEvents(): array
{
return this.events;
}
}
'Back-End > Test Code' 카테고리의 다른 글
[테스트 코드와 설계] 프로젝트 Test Code 작성과 문제 해결 (0) | 2024.09.04 |
---|---|
[테스트 코드와 설계] 빌더 패턴과 엔티티, 그리고 테스트에 대한 조언 (0) | 2024.09.04 |
[테스트 코드와 설계] 의존성 주입과 의존성 역전을 활용하여 테스트 가능성 높이기 (0) | 2024.09.03 |
[테스트 코드와 설계] 테스트 코드 작성과 리팩토링 (0) | 2024.08.30 |
[테스트 코드와 설계] 실패하는 테스트 코드 (0) | 2024.08.29 |