Java
Java/Spring 테스트 - 1
jaamong
2024. 10. 2. 20:30
이 글은 아래 인프런 강의를 듣고 기록을 남기고자 작성하였습니다.
🔖목차🔖
- TDD
- 테스트란
- 테스트
- TDD
- 장단점
- 개발자의 고민
- 테스트 코드의 필요성
- 좋은 아키텍처란
- 필요성
- 테스트 3분류
- 개념
TDD
- 회귀 버그(Regression Bug) → 테스트 자동화
- 테스트 추가
- 소프트웨어의 내적 품질 향상 O, 사용자가 체감할 수 있는 외적 품질 향상 X
- 가시적 성과 지표 → 커버리지(Coverage)
- 비결정적인 테스트
- 테스트를 실행할 때마다 테스트 결과가 다름
- 어제는 테스트를 통과했는데 오늘은 통과하지 못하는 경우
- 레거시 코드에 테스트를 넣는 것 → TDD가 아님
- 레거시 코드에 테스트를 넣기만 하는 것은 크게 의미가 없을 수도
- 레거시 코드에 테스트를 넣는 과정은 코드 개선을 하면서 진행해야 의미가 있다 (외부 시스템 의존도 ↓ & 설계 진화)
- 커버리지 집착 X
- 생각보다 테스트가 필요 없는 코드가 있을 수도 있다. 커버리지 목적이 아닌, 테스트를 추가했을 때 얻을 수 있는 효용성에 집중하자.
- ✅테스트가 줄 수 있는 가치
- 회귀 버그 방지
- 유연한 설계 (매우 도전적)
- 테스트가 계속 신호를 보냄
- 프로젝트가 스프링이나 JPA에 의존적인 상태일 확률 높음
- 외부 시스템에 의존하는 상황을 피해야 함 → 설계 진화
- 테스트와 설계는 긴밀한 관계
- 테스트가 계속 신호를 보냄
- ✅왜 테스트를 해야 하고, 어떻게 해야 하는지 고민할 것
- ✅방향성 - TDD를 논하기 전에 테스트가 가능한 구조로 변경되어야 함
테스트란
테스트
⇒ 어떤 시스템이 제대로 동작하는지 검증하는 과정
- 인수 테스트사람이 직접 사용해 보면서 준비된 체크 리스트를 진행하는 테스트
- 자동 테스트테스트 코드라고 불리는 미리 작성된 코드를 보면서 결괏값과 예상값을 비교하는 테스트
- 주로 `when/given/then` 패턴으로 작성됨
TDD
📌아래 과정을 무한히 반복
- `RED` : 실패하는 테스트를 먼저 작성한다.
- 컴파일되는 코드를 만들고 테스트가 실패하는 것까지 확인해야 함
- 실패하는 테스트를 먼저 작성해야 하므로, 인터페이스를 먼저 만드는 것이 강제됨 → OOP 핵심 원리 중 하나인 행동에 집중하는 것과 동일 ⇒ What/Who 사이클을 고민하게 도와줌
- `GREEN` : 위 실패하는 테스트를 성공시킨다.
- 실제로 구현하는 단계
- 이후 테스트를 실행하면 성공
- `BLUE` : 리팩토링 한다.
장단점
- 장기적인 관점에서의 장점이 확실함
- 인터페이스를 먼저 만드는 것이 강제됨
- 개발 비용 감소
- 장기적으로 봤을 때 장점이 뚜렷하므로 요구사항이 명확하지 않거나, 서비스의 흥망성쇠가 눈에 보이지 않는 경우에는 적용하기 부담스러움(Ex. 스타트업)
- 적용 난이도 높음
개발자의 고민
- 무의미한 테스트
- OOP에서 말하는 행동은 메서드나 함수를 의미하는 것이 아니다. 따라서 모든 메서드를 테스트하는 것보다 중요한 로직을 잘 구분하여 해당 코드를 테스트하는 것이 낫다.
- 테스트가 불가능한 코드
- Ex. 사용자가 로그인하고 마지막 로그인 시간을 기록하는 코드 등...
- 테스트가 불가함 → 설계가 잘못되었을 가능성 높음 ⇒ 설계 발전
- 느리고 쉽게 깨지는 코드
- 코드 자체는 별 것 아니지만, 실행 시간이 오래 걸리는 경우
- 100개가 넘어가는 테스트 코드 중에 이와 같은 경우가 있다면 시간적으로 부담됨
- 코드 자체는 별 것 아니지만, 실행 시간이 오래 걸리는 경우
테스트 코드의 필요성
좋은 아키텍처란
- SOLID와 테스트는 굉장히 긴밀한 상관관계를 갖는다 → 서로에게 상호보완적
- `TEST` for 회귀버그 방지 ↔ `SOLID` for 좋은 아키텍처
- SOLID 원칙이 지켜지면 경계가 만들어짐 → 이로 인해 회귀 버그를 막을 수 있음
- 단일 책임 원칙(SRP)
- 테스트는 명료하고 간단하게 작성해야 하기 때문에, 단일 책임 원칙을 지키게 됨
- 테스트가 너무 많아져서 어떤 목적의 클래스인지 파악하기 어려운 지점이 생김. 이는 해당 클래스의 책임 과다 문제일 수 있음. → 클래스를 분할해야 하는 시점 ⇒ 자연스러운 책임 분배
- 개방 폐쇄 원칙(OCP)
- 테스트 컴포넌트와 프로덕션 컴포넌트를 나눠 작성하게 되고, 필요에 따라 이 컴포넌트를 자유자재로 탈부착이 가능하도록 개발하게 됨
- 리스코프 치환 원칙(LSP)
- 이상적으로 테스트는 모든 케이스에 대해커버하고 있으므로, 서브 클래스에 대한 치환 여부를 테스트가 알아서 판단
- 인터페이스 분리 원칙(ISP)
- 테스트는 그 자체로 인터페이스를 직접 사용해 볼 수 있는 환경. 불필요한 의존성을 실제로 확인할 수 있는 샌드박스.
- 의존관계 역전 원칙(DIP)
- 가짜 객체를 이용하여 테스트를 작성하려면 의존성이 역전되어 있어야 하는 경우가 생김
필요성
- 테스트를 어떻게 바라보냐에 따라 테스트의 가치는 다르게 측정됨
- 낮은 가치: "품질 보증을 위한 도구/회귀 버그 방지"만 바라보고 테스트 코드 작성
- 높은 가치: "회귀 버그 방지"와 "설계를 위한 도구"를 위한 테스트 코드 작성
RF. Effective unit testing
테스트 3분류
- 단위 테스트 → Small(소형) 테스트 (80%) 중요
- `단일 서버 / 단일 프로세스/ 단일 스레드`에서 실행되는 테스트를 의미
- `Disk IO`와 `Blocking call`이 있으면 안 됨
- Ex. `Thread.sleep`이 테스트에 있으면 소형 테스트가 아님
- 이러한 조건 때문에, 소형 테스트는 결과가 항상 결정적(Deterministic)이고 테스트 속도가 빠름
- 이러한 테스트를 여러 개 만들어서 코드 커버리지를 높여야 함
- 프로젝트에 아직 테스트가 없다면, 소형 테스트를 늘릴 수 있는 환경을 만들고, 소형 테스트를 늘려야 함.
- 통합 테스트 → Medium(중형) 테스트 (15%)
- 소형 테스트보다 완화된 기준
- `단일 서버 / 멀티 프로세스 / 멀티 스레드` 사용 가능
- H2와 같은 테스트용 DB 사용 가능
- 다시 말하면, 소형 테스트에서는 DB(H2) 사용 불가
- 소형 테스트보다 느림
- 멀티 스레드 환경에서 어떻게 동작할지 모르기 때문에 결과가 항상 같다는 보장을 할 수 없음
- 테스트 결과가 H2 같은 외부 모듈의 동작에 따라서 달라짐
- H2와 같은 테스트용 DB 사용 가능
- API 테스트 → Large(대형) 테스트 (5%)
- `멀티 서버` 가능 → E2E(End to End) 테스트
RF. Software Engineering at Google
개념
SUT
- System Under Test
- 테스트하려는 대상
- Ex. 북마크 결과가 제대로 됐는지 기록하는 테스트 코드가 있을 때, `SUT`는 user
BDD
- Behaviour driven development (given - when - then 또는 3A)
- 테스트를 작성하다 보면 어느 순간 "어디에 어떻게 테스트를 넣어야 하지?"라는 질문을 마주하게 된다. 그때 BDD가 "생동에 집중해야 한다"고 알려준다.
- 유저가 시스템을 사용하는 user story를 강조하고, 시나리오를 강조한다. 이를 지키기 위한 뼈대로 아래와 같은 방식을 사용하라고 권유함
- 어떤 상황이 주어졌을 때(given)
- 이 행동을 하면(when)
- 결과가 이렇다(then)
상호 작용 테스트(Interaction Test)
- 메서드가 실제로 호출이 됐는지 검증하는 테스트
- 일반적으로, 메서드가 실제로 호출됐는지 검증하는 방법은 좋지 않음
- 내부 구현을 어떻게 했는지 감시하는 것이므로 ⇒ 캡슐화 위배
- 객체에게 위임한 책임을 해당 객체가 제대로 수행했는지 확인만 하면 되는데 구현에 집착하게 된다.
- 일반적으로, 메서드가 실제로 호출됐는지 검증하는 방법은 좋지 않음
상태 검증 vs 행위 검증
- 상태 기반 검증(state-based-verification)
- 어떤 값을 시스템에 넣었을 때, 나오는 결괏값을 기댓값과 비교하는 방식
- 행위 기반 검증(behaviour-based-verification)
- == 상호 작용 테스트
- 어떤 값을 시스템에 넣었을 때, 협력 객체의 어떤 메서드를 실행하는가
- BDD에서 말하는 `행위`와는 다른 의미. 행위 기반 검증이 BDD를 말하는 것이 아님.
테스트 픽스처
- fixture = 비품, 설비
- 테스트를 하기 위해 필요한 자원이 있다면 미리 생성해 두는 것을 테스트 픽스처라고 함
- `@Before`를 적용한 메서드에 테스트를 하기 위한 `sut`을 미리 생성해 둠 ⇒ `sut = 테스트 픽스처`
- 테스트 픽스처는 sut가 될 수 있고, sut에 들어가야 하는 의존성 일부가 될 수도 있음.
비욘세 규칙
- 구글에서 만든 규칙(대중적이지 않음)이지만, 꽤 의미 있는 규칙
- "유지하고 싶은 상태나 정책이 있다면, 알아서 테스트를 만들어야 한다."
- 유지하고 싶은 상태가 있으면 전부 테스트로 작성 → 그게 곧 정책이 됨
- 유저 아이디가 이메일 형식이길 원한다면, 유저 아이디가 이메일이 아닐 때 예외를 던지는 테스트를 작성하면 됨
- 마일리지 쿠폰이 5만 원 이상일 때만 사용 가능하게 하고 싶다면, 5만원 미만일 때 예외를 던지는 테스트를 작성하면 됨
- 협업 시에도 큰 도움이 됨
- 개발 문서보다 더 효율적일 때도 있다.
Testability (다른 글에서 다시 정리)
- 테스트 가능성. 소프트웨어가 테스트 가능한 구조인가?
Test Double
- double == 스턴트맨과 같은 '대역'
- 회원 가입을 할 때 인증 메일을 보내는 시스템이 있을 때, 회원 가입하는 코드를 테스트할 때마다 인증 메일이 발송되어야 할까? 그럴 수는 없음 → 대신 이메일을 발송하는 부분에 가짜 객체(Test Double)를 넣어준다.
대역 종류
- Dummy
- 일을 시켜도 아무런 동작도 하지 않는 객체
- Fake
- Dummy와 달리 자체 로직을 갖고 있음
- 회원 가입 메일 내용이 제대로 만들어졌는지 테스트하고 싶음 → Fake 사용
- 발송한 메시지를 기록하는 로직을 가지고 있음
- 잘 만들어진 Fake는 테스트할 때 말고도, 로컬 개발 시에도 사용할 수 있음
- Stub
- 주로 외부 연동하는 컴포넌트에 많이 사용함
- 객체에 어떤 일을 시켰을 때 미리 준비된 값을 제공
class SubUserRepository implements UserRepository { public User getByEmail(String email) { if (email.equals("foo@bar.com")) { return User.Builder() .email("foo@bar.com") .status("PENDING") .build(); } throw new UsernameNotFoundException(email); } }
- 보통 `mokito` 프레임워크를 이용하여 구현됨
//given given(userRepository.getByEmail("foo@bar.com")).willReturn(User.builder()) .emamil("foo@bar.com") .status("PENDING") .build(); // when // ... // then // ...
- Mock
- 사실상 테스트 더블과 동일한 의미로 사용됨
- stub → mock, dummy → mock, fake → mock으로 부름
- 메서드 호출을 확인하기 위한 객체
- Spy
- 모든 메서드 호출을 낱낱이 기록해 두고 있는 객체
- 메서드가 몇 번 호출됐는지, 잘 호출됐는지 등을 검증
- 모든 메서드 호출을 낱낱이 기록해 두고 있는 객체