매일 작성하는 단위 테스트…
단위테스트를 왜(why) 작성하는지를 알아보고, 좋은 테스트코드를 작성하기 위한 방법(how)과 단위 테스트를 하면 좋은 부분(what)에 대해 알아봅니다.
이 글은 구글 엔지니어는 이렇게 일한다 테스트 부분(CH11-13), 단위테스트 책을 기반으로 정리한 내용입니다.
보통 테스트의 대상이 되는 요소들에는 다음이 있습니다.
위와 같은 테스트를 실행하면 시스템에 특정 값을 입력하고 출력 결과를 확인하는 과정을 자동화할 수 있고 시스템이 잘 동작하는지를 판단하게 됩니다. 그리고 이런 간단한 테스트가 여러개 모이면 제품이 전체적으로 의도한대로 잘 동작하는지 확인할수 있게됩니다.
구글에서 말하는 단위테스트는 단일 클래스나 메서드처럼 범위가 상대적으로 좁은 테스트를 말합니다. (단위 테스트는 일반적으로 크기가 작지만 반드시 그런 것은 아니라고 합니다.)
테스트의 가장 중요한 목적은 버그 예방
이고, 그 다음으로 중요한 목적은 엔지니어의생산성 개선
에 있는데,
범위가 더 넓은 테스트들과 비교해서 단위 테스트
는 생산성을 끌어올리는 훌륭한 수단이 될 수 있는 특성을 많이 지니고 있습니다.
단위 테스트는 엔지니어의 일상에서 비중이 크기 때문에, 구글은
테스트 유지보수성(test maintability)
를 상당히 중시합니다.
유저보수성이 높은 테스트, 즉 유지보수하기 쉬운 테스트는 그냥 잘 작동하는(just work),한 번 작성해두면 실패하지 않는 한 엔지니어가 신경 쓸 필요 없고, 실패한다면 진짜 버그를 찾았다는 뜻인 테스트를 의미합니다.
테스트가 깨져서 오류가 났는데 실패한 테스트들을 하나씩 해결하느라 하루를 다 소비했다면..?
근데 그 테스트가 깨져서 확인해보 버그를 찾은것이 아니라 내부 구현에 의해 난 오류라면...
위와 같은 상황에서 테스트는 원래 의도와는 정반대의 효과를 냈습니다. 코드의 품질을 의미있게 높여주지도 못했고, 생산성을 갉아먹었습니다.
이러한 상황을 방지하기 위해 질 나쁜 테스트는 반영되기 전에 수정돼야 합니다. 위와 같은 문제는 아래와 같은 상황에 의해 발생했을 확률이 높습니다.
“깨지기 쉬운”
테스트라서“불명확한”
테스트라서그럼 어떻게, “깨지지 않고”
, “명확한”
테스트 코드를 작성해서 생산성을 높일 수 있는지 알아봅시다.
- 순수 리팩토링 (테스트 변경 X)
- 새로운 기능 추가 (테스트 변경 X, 새 기능에 대한 테스트만 추가)
- 버그 수정 (테스트 변경 X, 누락된 테스트만 추가)
- 행위 변경 (테스트 변경 O)
위와 같은 상황에서의 요점은 리팩토링, 새 기능 추가, 버그 수정 시에는 기존 테스트를 손볼일이 없어야 한다는 것입니다. 기존 테스트를 수정해야 하는 경우는 시스템의 행위 달라지는 파괴적인 변경이 일어날 때 뿐입니다.
그럼 이런 상황에서 깨지지 않는 테스트를 만들기 위해서는 어떻게 해야될까요?
테스트 대상을 다른 사용자 코드와 똑같은 방식으로 호출하는 것입니다. 내부 구현을 위한 코드가 아닌 공개되어 있는 API를 사용하면 됩니다. 즉, 테스트가 시스템을 사용자와 똑같은 방식으로 사용하는 것입니다.
// 은행 거래 API
public void processTransaction(Transaction transaction){
if(isValid(transaction)){
saveToDatebase(transaction);
}
}
private boolean isValid(Transaction t){
return t.getAmount() < t.getSender().getBalance();
}
🔴 거래 메소드의 구현을 바로 검증하는 안좋은 테스트
@Test
public void empyAccountShouldNotBeValid(){
assertThat(processor.isValid(newTransaction().setSendor(EMPTY_ACCOUNT)))
.isFalse();
}
🟢 공개 API로 테스트
@Test
public void shouldTransferFunds(){
processor.setAccountBalance("me", 150);
processor.processTransaction(newTransaction()
.setSender("me")
.setRecipient("you")
.setAmount("100"));
assertThat(processor.getAccountBalance("me")).isEqualTo(50);
}
시스템이 기대한대로 동작하는지 검증하는 방법은 크게 두가지가 있습니다. 첫번째는 상태 테스트
로, 메소드 호출 후 상태변화를 관찰하는 것이고, 두번째는 상호작용 테스트
로 과정에서 다른 모듈들과 협력해서 기대한 동작을 수행하는지를 확인하는 것입니다.
대체로 상호작용 테스트가 상태 테스트보다 깨지기 쉽습니다. 이유는, 결과가 “무엇(what)”인지를 테스트하는것이 아니라, “어떻게(how)” 작동하냐를 확인하려 들기 때문입니다.
🔴 상호작용 테스트
@Test
//테스트의 목적: 데이터가 반영이 됐는지.
public void sholdWriteToDatabase(){
accounts.createUser("foobar");
//database 의 메소드를 호출했는지를 검증(상호작용검증)
verify(database).put("foobar"); // 데이터베이스의 put()메서드가 호출됐는지를 확인
}
보통 상호작용 테스트가 만들어지는 가장 큰 원인은 모킹 프레임워크에 지나치게 의존하기 때문인데, 모킹 프레잌워크를 이용하면 테스트 대역을 쉽게 만들수 있지만 깨지기 쉬운 테스트 코드를 작성하게 되는 원인이 되기도 합니다.
🟢 상태 테스트
@Test
public void sholdCreateUsers(){
accounts.createUser("foobar");
assertThat(accounts.getUser("foobar")).isNotNull();
}
깨지기 쉬운 요소를 제거했다고 해도, 언젠간 테스트는 실패할 수 있습니다. 실패한 테스트는 엔지니어에게 유용한 신호가 되기 때문에 단위 테스트의 존재 가치를 증명하는 중요한 수단입니다.
이때, 테스트 실패의 원인을 빠르게 파악하는 것이 중요한데, 이것이 바로 테스트의 명확성
에 달려있습니다.
명확한 테스트란 테스트의 존재 이유와 실패 원인을 엔지니어가 곧바로 알아 차릴 수 있는 테스트를 말합니다.
아래 예제는 계산기를 생성하고, 계산이 잘동작하는지를 테스트하는 코드입니다.
🔴 불완전하고 산만한 코드
@Test
public void sholdPerformAddition(){
Calculator calculator = new Calculator(new RoundingStrategy(),
"unused", ENABLE_COSINE_FEATURE, 0.01)
int result = calculator.calculate(newTestCalculation());
assertThat(result).isEqualTo(5);
}
🟢 완전하고 간결한 코드
@Test
public void sholdPerformAddition(){
Calculator calculator = new Calculator();
int result = calculator.calculate(newCalculation(2, Optation.PLUS, 3));
assertThat(result).isEqualTo(5);
}
많은 엔지니어가 본능적으로 테스트의 구조를 대상 코드의 구조와 일치시키려고 하는데, 이 방식은 처음에는 편리하지만 시간이 지날수록 문제를 발생시킬 확률이 높습니다.
function display(user:User, transation:Transaction){
ui.showMessage(transaction.getItemName() + '을(를) 구입하셨습니다.');
if(user.getBalance() < LOW_BALANCE_THRESHOLD){
ui.showMessage('경고 : 잔고가 부족합니다');
}
}
🔴 메서드의 두 메시지를 모두 검증하려는 모습.
//메서드를 테스트하는 경우
test("display메소드 전체를 한번에 테스트하는 경우", ()=>{
display(new User(LOW_BANLANCE_THRESHOLD + 2, new Transaction('물품',3));
expect(ui.getText()).contains("물품을(를) 구입하셨습니다.");
expect(ui.getText()).contains("잔고가 부족합니다.");
})
이런상황에서 메서드가 더 복잡해지고 더 많은 기능을 구현한다면(분기가 많아지고, 메세지도 추가된다면..?), 이 단위 테스트 역시 계속 복잡해지고 커져서 다루기가 까다로집니다. 테스트를 메서드별로 작성하지 않고 행위별로 작성하면 됩니다.
여기서 행위란 특정 상태에서 특정한 일련의 입력을 받았을 떄 시스템이 보장하는
반응
을 의미합니다.
🟢 행위별로 검증하는 모습
//행위를 테스트하는 경우
test("물품 이름을 보여준다.", ()=>{
display(new User(), new Transaction('물품'));
expect(ui.getText()).contains("물품을(를) 구입하셨습니다.");
}
test("은행 잔고가 부족하면, 거래를 거부한다.", ()=>{
display(new User(LOW_BANLANCE_THRESHOLD + 2, new Transaction('물품',3));
expect(ui.getText()).contains("잔고가 부족합니다.");
})
이와 같이 행위 주도 테스트는 대체로 메소드 중심 테스트보다 다음과 같은 이유에서 명확한다.
지금까지, 단위 테스트를 왜(why) 작성해야하는지, 그리고 어떻게 단위테스트를 작성해야하는지(how) 에 대해 알아봤습니다.
그럼 시스템관점에서 어떤 부분(what)을 테스트하는 것이 가치 있는일인지, 그리고 어떤 코드를 테스트했을 때 가장 효과가 좋은지에 대해 알아봅시다.
모든 제품 코드는 다음과 같이 크게 2개의 차원으로 분류할 수 있습니다.
먼저,복잡도와 도메인 유의성
에 대해 살펴봅시다.
두번째로 협력자의 수
란, 해당 클래스 또는 메서드 가진 의존성 개수을 의미합니다. 협력자가 많을 수록 코드는 길어지고 테스트 비용이 높아집니다.
이를 도표로 나눠서 유형을 구분하면 다음과 같은 네가지 코드 유형을 볼 수 있습니다.
이 때 좌측 상단 사분면(도메인 모델 및 알고리즘)을 단위 테스트하면 노력 대비 가장 이롭습니다. 협력자가 없어 유지보수성은 높지만, 코드가 복잡하고 중요한 로직을 수행하기 때문에 회귀 방지
가 뛰어나기 때문입니다.
회귀 방지
SW 버그를 방지할 수 있어야 한다는 의미
반면 간단한 코드는 테스트할 필요가 없다고 합니다.. (책에서는 거의 가치가 0에 가깝다고 표현할정도록)
컨트롤러의 경우 포괄적인 통합 테스트의 일부로서 간단히 테스트해야 한다고 합니다.
가장 문제가 되는 코드는 복잡한 코드인데, 단위 테스트가 어렵겠지만 테스트 커버리지 없이 내버려두는 것은 위험합니다. 이때 어떻게 이 딜레마를 우회할 수 있는지를 책에서 소개하고 있어 그 방법을 간단하게 설명하고 세미나를 마무리해보려고 합니다.
💡 TIP . 코드가 더 중요해지거나, 복잡해질수록 협력자는 적어야한다. **목표: 지나치게 복잡한 코드를 알고리즘과 컨트롤러로 나눠서 리팩토링 하기.**
public class User{
// 구체적인 구현 생략
public changeEmail(userId: number, newEmail: string ):void{
// 1. 데이터베이스에서 사용자 정보 검색 (이메일, 유형)
const userData = DataBase.getUserById(userId);
const userEmail= userData[1];
const userType = userData[2];
if(userEmail === newEmail){
return;
}
// 2. 데이터베이스에서 조직의 도메인 이름과 직원 수 검색
const companyData = DataBase.getCompany();
const companyDomainName = companyData[0];
const numberOfEmployees = companyData[1];
const emailDomain = newEmail.split('@')[1];
const isEmailCorporate = emailDomain === companyDomainName;
const newType = isEmailCorporate ? UserType.Employee : UserType.Customer
//3. 필요한 경우 직원의 수 업데이트
if(userType !== newType){
const delta = newType === UserType.Employee ? 1: -1;
const newNumber= numberOfEmployees + delta;
DataBase.saveCompany(newNumber);
}
this.email = newEmail;
this.userType= newType;
//4. 데이터베이스에 사용자 저장
DataBase.saveUser(this);
//5. 메시지 버스에 알림 전송
MessageBus.sendEmailChangeMessage(userId, newEmail);
}
}
}
명시적 의존성
이고,암시적 의존성
입니다.위 두가지 측면에서 도메인 유의성이 높고, 외부 협력자 수가 높으므로 User는 지나치게 복잡한 코드로 분류됩니다.
public changeEmail(newEmail: string, companyDomainName: string, numberOfEmployees: number):number|undefined{
if(this.email === newEmail){
return;
}
const emailDomain = newEmail.split('@')[1];
const isEmailCorporate = emailDomain === companyDomainName;
const newType = isEmailCorporate ? UserType.Employee : UserType.Customer
//3. 필요한 경우 직원의 수 업데이트
if(this.userType !== newType){
const delta = newType === UserType.Employee ? 1: -1;
const newNumber= numberOfEmployees + delta;
}
this.email = newEmail;
this.userType= newType;
return numberOfEmployees;
}
class UserController{
private readonly database: InstanceType<typeof DataBase> = DataBase;
private readonly messageBus: InstanceType<typeof MessageBus>= MessageBus;
public changeEmail(userId:number, newEmail:string){
const userData = this.database.getUserById(userId);
const user = UserFactory.create(userData);
const companyData = this.database.getCompany();
const company = CompanyFactory.create(companyData);
user.changeEmail(newEmail, company.domainName, company.numberOfEmployees);
// 변경전
this.database.saveUser(user);
this.database.saveCompany(company);
// 변경후
this.database.save(user,company);
this.messageBus.sendEmailChangeMessage(userId, newEmail)
}
}
🚫 주의 : 위 예제는 단위 테스트의 관점에서 리팩토링한 것이기 때문에 성능에 대한 고려는 하지 않았습니다. 위와 같이 모든 외부 데이터에 대한 읽기/쓰기를 비즈니스 끝으로 밀어냈을 경우, 불필요한 읽기/쓰기가 발생할 확률이 있다고 책에서도 말하고 있습니다. (trade-off 항상 고려해야됨) 성능을 고려해서 도메인 클래스의 로직을 컨트롤러쪽으로 가져올 수 도 있습니다.