Open yepdi opened 2 years ago
단위 테스트의 목표 : 소프트웨어 프로젝트의 지속 가능한 성장을 가능하게 하는 것
테스트에 대한 비용
정의 : 테스트 스위트가 소스 코드를 얼마나 실행하는지 백분율로 나타내는 것
코드 커버리지 = 실행 코드 라인 수 / 전체 라인 수
분기 커버리지 = 통과 분기 / 전체 분기 수
문제점
커버리지 지표를 보는 방법은 지표 그 자체로 보는 것. 목표가 아님
개발 주기에 통합되어 있다
코드 베이스에서 가장 중요한 부분 대상
최소 유지비로 최대 가치
런던파
테스트 대상 시스템 (SUT)
고전파
공유 의존성 : 테스트 간 공유되고 서로의 결과에 영향을 미칠 수 있는 것 (ex. 데이터 베이스)
비공개 의존성 : 애플리케이션 실행 프로세스 외부에서 실행되는 의존성. 아직 메모리에 없는 데이터에 대한 프록시
격리 주체 | 단위의 크기 | 테스트 대역 사용 대상 | |
---|---|---|---|
런던파 | 단위 | 단일 클래스 | 불변 의존성 외 모든 의존성 |
고전파 | 단위 테스트 | 단일 클래스 또는 클래스 세트 | 공유 의존성 |
의존성을 다루는 방법
고전파와 런던파의 비교
AAA 패턴 : 준비, 실행, 검증 (given, when, then 과 유사한 패턴)
참고로 Mockito ArgumentCaptor 를 사용하여 값을 얻을 수 있다 참고 자료 : https://www.baeldung.com/mockito-argumentcaptor
단위 테스트든 통합 테스트든 분기가 없는 간단한 일련의 단계여야 한다
실행 구절은 보통 코드 한 줄이다. 실행 구절이 두 줄 이상인 경우 SUT의 공개 API에 문제가 있을 수 있다
public class CustomerTests {
public void Purchase_suceeds_when_enough_inventory() {
Store store = CreateStoreWithInventory(Product.Shampoo, 10)
....
}
public Store CreateStoreWithInventory(Product product, int quantity) { <- 비공개 팩토리 메서드
....
}
}
ParameterizedTest
annotation 제공 참고 자료 : https://www.baeldung.com/parameterized-tests-junit-5
오류 유형 표 | 작동 | 고장 | |
---|---|---|---|
테스트결과 | 테스트 통과 | 올바른 추론 | 2종 오류 (거짓 음성) |
테스트 실패 | 1종 오류 (거짓 양성) | 올바른 추론 |
사례 1 : 엔드 투 엔드 테스트
사례2 : 간단한 테스트
사례3 : 깨지기 쉬운 테스트 (실행이 빠르고 회귀를 잡을 가능성이 높으나 거짓 양성이 많은 테스트) ex. sql 문 그대로 테스트
결론 : 리팩터링 내성은 포기할 수 없다. 회귀 방지, 빠른 피드백 사이
블랙박스 테스트 : 시스템 내부 구조를 몰라도 시스템의 기능을 검사할 수 있는 소프트웨어 테스트 방법
화이트박스 테스트 : 애플리케이션의 내부 작업을 검증
회귀 방지 | 리팩터링 내성 | |
---|---|---|
화이트박스 테스트 | 좋음 | 나쁨 |
블랙박스 테스트 | 나쁨 | 좋음 |
목 : 외부로 나가는 상호 작용을 모방하고 검사. SUT가 상태를 변경하기 위한 의존성을 호출
스텁 : 내부로 들어오는 상호 작용 모방. SUT가 입력 데이터를 얻기 위한 의존성 호출
목과 스텁의 특성을 모두 나타낼 수 있는 테스트를 작성 가능하다
목과 스텁 그리고 CQS(Command Query Separation)
사용자의 이름
을 변경하는 것이라면 이름을 변경하는 것만 노출. 구현 세부사항(NormalizeName 메서드는 숨겨야한다)식별할 수 있는 동작 | 구현 세부사항 | |
---|---|---|
공개 | 좋음 | 나쁨 |
비공개 | 해당 없음 | 좋음 |
도메인 계층과 애플리케이션 서비스 계층간의 관심사 분리
애플리케이션 내부 통신
애플리케이션 간의 통신
식별할 수 있는 동작은 바깥 계층에서 안쪽으로 흐른다
구현 세부 사항은 테스트 코드내에서 검증하는 것이 아니고 식별할 수 있는 동작으로만 검증해야 한다
p. 173 예제 코드
시스템 간 통신은 CustomerController
애플리케이션 서비스와 외부 시스템(서드파티 애플리케이션), 이메일 게이트웨이 간 통신
시스템 내부 통신은 Customer 클래스와 Store 클래스 메서드 호출
런던파 : 불변 의존성을 제외한 모든 의존성에 목 사용 권장. 시스템 내 통신과 시스템 간 통신을 구분하지 않음
고전파 : 테스트 간에 공유하는 의존성만 교체하자고 하므로 유리.
의존성
고전파에서는 공유 의존성을 피할 것 권고. (테스트 격리)
모두 프로세스 외부 의존성을 목으로 대체하는 것은 아님
애플리케이션 데이터베이스
고전파는 상태 기반 스타일 선호. 런던파는 통신 기반 스타일 선호
출력 기반 | 상태 기반 | 통신 기반 | |
---|---|---|---|
리팩터링 내성을 위한 노력 | 낮음 | 중간 | 중간 |
유지비 | 낮음 | 중간 | 높음 |
=> 출력 기반 스타일과 상태 기반 스타일 조합하며 통신 기반 스타일을 섞어도 된다
복잡도 및 도메인 유의성 | 도메인 모델 및 알고리즘 | 지나치게 복잡한 코드 |
---|---|---|
-- | 간단한 코드 | 컨트롤러 |
협력자 수 |
암시적인 의존성을 명시적으로 만들기
애플리케이션 서비스 계층 도입
애플리케이션 서비스 복잡도 낮추기
새 Company 클래스 소개
Object[] companyData = _database.getCompany();
String companyDomainname = (String)companyData[0];
int numberOfEmployees = (int)companyData[1];
int newNumberOfEmployees = user.changeEmail(newEmail, companyDomainName, numberOfEmployees)
- user에서 업데이트 된 직원 수 반환 하는 부분 => **책임을 잘못 둔 것 + 추상화가 없다**
- 새로운 도메인 클래스인 **Company**를 만들자
```java
public class Company {
private String DomainName;
private int numberOfEmployees;
public void changeNumberOfEmployees(int delta) {
numberOfEmployees += delta;
}
public Boolean IsEmailCoporate(String email) {
String emailDomain = email.split("@")[1];
return emailDomain == domainName;
}
}
changeNumberOfEmployees
IsEmailCoporate
두가지 메서드 => 묻지 말고 말하라 (묻지말고 시켜라 같은 건가 ❓ )User
클래스 내에서 이메일이 회사 이메일인지 결정하고 직원수를 변경하는 것을 위임한다 의사 결정 프로세스의 중간 결과를 기반으로 프로세스 외부 의존성에서 추가 데이터를 조회할 경우에는...
외부에 있는 모든 읽기와 쓰기를 가장자리로 밀어낸다
도메인 모델에 프로세스 외부 의존성 주입 후 비즈니스 로직이 해당 의존성 호출 시점 직접 결정
의사 결정 프로세스 단계 더 세분화
CanExecute / Execute 패턴 사용
public String canChangeEmail() {
if (IsEmailConfirmed) return "cannot change";
return null;
}
public void changeEmail(String newEmail, Company company) {
if (canChangeEmail() == null) return;
...
}
// 도메인 모델
public void changeEmail(String newEmail, Company company) {
....
// 새 이벤트 추가
emailChangedEvents.add(new EmailChangedEvent(userId, newEmail));
}
// 컨트롤러
public void changeEmail(int userId, String new email) {
for (EmailChangedEvent event : user.emailChangedEvents) {
_messageBus.sendEmailChangedMessage(event.userId, event.newEmail);
}
}
식별할 수 있는 동작
컨트롤러 관점
내부 애플리케이션
실제 프로세스 외부 의존성 사용
해당 의존성 목으로 대체
관리 의존성 : 애플리케이션을 통해서만 접근. 해당 의존성과의 상호 작용은 외부 환경에서 볼 수 없다 ex. 데이터베이스
비관리 의존성 : 상호 작용을 외부에서 볼 수 있다 ex. SMTP 서버, 메시지 버스
=> 관리 의존성은 실제 인스턴스를 사용하고 비관리 의존성은 목으로 대체하라
관리 의존성이면서 비관리 의존성인 프로세스 외부 의존성 다루기
통합 테스트에서 실제 데이터베이스를 사용할 수 없다면?
jpa 를 사용하는 경우에는 interface가 같으니까 목으로 대체해도 리팩터링 저하가 일어나지 않을 것 같음 😓
로깅은 텍스트 파일이나 데이터베이스와 같은 프로세스 외부 의존성에 부작용 초래
애플리케이션과 로그 저장소 간의 상호 작용 검증하려면 목을 사용
진단 로깅의 경우 User가 비즈니스 로직과 프로세스 외부 의존성과 통신 간에 분리해야하는 원칙 위반 -> 도메인 이벤트로 해결
로깅은 얼마나 많아야 되나?
public void Changing_email_from_corporate_to_non_corporate() {
Mock messageBusMock = new Mock<IMessageBus>();
Mock loggerMock = new Mock<IDomainLogger>();
messageBusMock.Verify(x => x.SendEmailChangeMesage(user.UserId, "new@gmail.com"), Times.Once);
loggerMock.Verify(x => x.UserTypeHasChanged(user.UserId, UserType.Employee, UserType.Customer), Times.Once);
}
비관리 의존성인 IMessageBus와 IDomainLogger를 목으로 처리했다.
messageBusMock
의 문제점은 IMessageBus 인터페이스가 시스템 끝에 있지 않다는 것public interface IMessageBus
public class MessageBus : IMessageBus {
privae IBus _bus;
목은 프레임워크의 도움을 받아 생성. 스파이는 수동으로 작성
public interface IBus {
void send(String message);
}
public class BusSpy implement IBus {
private List<String> sentMsg = new ArrayList<String>();
public void send(String message) {
sentMessage.add(message);
}
// 검증 로직
public BusSpy ShouldSendNumberOfMessages(int number) {
Assertions.assertThat(number).isEqualTo(sentMsg.size());
}
public BusSpy withEmailChangedMessage(int userId, String newEmail) {
String message = "Type" + userId + "Email" + newEmail;
Assertions.Contains(sentMsg, x -> x == message);
}
}
}
비공개 메서드를 노출하면 테스트가 구현 세부 사항과 결합되고 결과적으로 리팩터링 내성이 떨어진다
포괄적인 식별할 수 있는 동작으로서 간접적으로 테스트 하는 것이 좋다
비공개 메서드가 너무 복잡해서 식별할 수 있는 동작으로 테스트하기에 충분히 커버리지를 얻을 수 없는 경우
비공개 메서드 테스트가 타당한 경우
식별할 수 있는 동작 | 구현 세부 사항 | |
---|---|---|
공개 | 좋음 | 나쁨 |
비공개 | 해당 없음 | 좋음 |
식별할 수 있는 동작을 공개로 하고 구현 세부 사항을 배공개로 하면 API 가 잘 설계된 것. 식별할 수 있는 동작과 비공개 메서드가 만나는 부분은 해당 없음으로 되어 있다.
public class Inquiry {
private Inquiry(bool isApproved, DateTime timeApproved) {
...
}
public void approve(DateTime now) {
....
}
}
ORM 라이브러리에 의해 데이터베이스에서 클래스가 복원되기 때문에 비공개 생성자는 비공개이다. 객체를 인스턴스화 할 수 없다. Inquiry 생성자는 비공개면서 식별할 수 있는 동작인 메서드의 예이다. 이 생성자는 ORM과의 계약을 지키며, 생성자가 비공개라고 해서 계약이 덜 중요하지 않다
class Customer {
private CustomerStatus status = CustomerStatus.Regular;
public void promote() {
status = CustomerStatus.Preferred;
}
public float getDiscount() {
return status == CustomerStatus.Preferred? 0.05 : 0
}
}
promote 메서드를 테스트 하려면 필드가 비공개 이므로 테스트 할 수 가 없다. 이 때는 새로 생성된 고객은 할인이 없음. 업그레이드시 5% 할인율 적용 같이 제품 코드가 클래스를 어떻게 사용되는지를 분석한다
public class CalculatorTest {
@Test
public void add_two_number {
int expected = value1 + value2; // Calculator.Add의 알고리즘과 동일한 로직
int actual = Calculator.Add(value1, value2);
}
}
테스트는 제품 코드에서 알고리즘 구현을 복사했다. 단순히 제품 코드에서 복사/붙여넣기를 한 것. 이것은 구현 세부사항과 결합되는 또다른 예이다.
public class Logger {
private Boolean isTestEnv;
public Logger(Boolean isTestEnv) {
this.isTestEnv = isTestEnv;
}
public void log(String text) {
if (isTestEnv) return;
....
}
}
if
문을 통해서 운영환경인지 테스트 환경인지를 구별해 운영 환경이라면 메시지를 파일에 기록하도록 되어 있다.
테스트 코드를 제품 코드 베이스와 분리해야 한다
public interface ILogger {
void log(String text);
}
public class FakeLogger: ILogger { // 테스트용
}
public class Logger: ILogger {
}
구체 클래스를 대신 목으로 처리할 수 도 있으나 단일 책임 원칙을 위배한다 클래스 내 비관리 외부 의존성을 호출하는 경우, 비 관리 의존성을 스텁으로 교체해야 한다.
즉, 클래스 내 도메인 로직과 비관리 의존성이 결합되어 있는 경우이다
=> 클래스를 두 부분으로 나눈다. 비관리 의존성과 도메인 로직을 분리하여 클래스를 나눈다 p.386
시간에 따라 달라지는 기능을 테스트하는 경우 거짓 양성이 발생할 수 잇다.
앰비언트 컨텍스트로서의 시간
명시적 의존성으로서의 시간
public interface IDateTimeServer {
public getTime();
}
public class DateTimeServer: IDateTimeServer {
public getTime() {
return DateTime.Now;
}
}
public class InquiryController {
IDateTimeServer datetime;
public InquiryController(IDateTimeServer dateTime) { // 시간을 서비스로 주입
}
public void approveInquiry(int id) {
inquiry.approve(datetime.getTime()). // 시간을 일반 값으로 주입
}
}
단위 테스트 책 에서 정리 할 내용 기록