Lucian Log
Blog
Computer Science
Algorithm
DB
Network
OS
General
AI
Blockchain
Concurrency
ETC
Git
Infrastructure
AWS
Docker
Java-Ecosystem
JPA
Java
Spring
JavaScript-Ecosystem
JavaScript
Next.js
React
TypeScript
Python-Ecosystem
Django
FastAPI
Python
SQLAlchemy
Software Engineering
Architecture
Culture
Test
Home
Contact
Copyright © 2024 |
Yankos
Home
>
Software Engineering
> Test
Now Loading ...
Test
단위 테스트 (Unit Testing) - 가치 있는 테스트 작성하기
단위 테스트 스타일과 함수형 아키텍처 단위 테스트 스타일 종류 출력 기반 테스트 (output-based testing, 함수형) SUT에 입력을 넣고 출력을 점검하는 방식 사이드 이펙트 X, 반환 값만 검증 e.g. decimal discount = sut.CalculateDiscount(product1, product2) Assert.Equal(0.02m, discount) 상태 기반 테스트 (state-based testing) 작업이 완료된 후 시스템 상태를 확인하는 방식 상태: SUT, 협력자, 프로세스 외부 의존성의 상태 (DB, 파일 시스템) e.g. sut.AddProduct(product) Assert.Equal(1, sut.Products.Count) 통신 기반 테스트 (communication based testing) 목을 사용해 SUT와 협력자 간의 통신을 검증 e.g emailGatewayMock.Verify(x => x.SendGreetingsEmail(), Times.Once) 단위 테스트 4대 요소 통한 비교 결론: 항상 출력 기반 테스트를 선호하자 객체 지향에 적용이 어렵지만, 테스트를 출력기반 스타일로 변경하는 기법 사용하면 됨 리팩토링 내성 출력 기반 테스트가 가장 우수 테스트가 테스트 대상 메서드에만 결합해 거짓 양성 방지 탁월 상태 기반 테스트, 통신 기반 테스트는 취약 테스트가 구현 세부 사항에 결합될 가능성 높음 유지 보수성 출력 기반 테스트가 가장 우수 거의 항상 짧고 간결하므로 유지보수가 쉬움 전역 상태나 내부 상태를 변경할리 없으므로, 프로세스 외부 의존성 X 상태 기반 테스트, 통신 기반 테스트는 취약 상태 기반 테스트, 통신 기반 테스트는 검증부가 커짐 보완 1: 검증부 헬퍼 메서드 -> 메서드 유지와 재사용에 대한 명분이 드물 것 보완 2: 검증 대상 클래스에 동등 멤버 정의 -> 코드 오염 (Code Pollution) 회귀 방지와 빠른 피드백은 단위 테스트 스타일과 관련이 적음 함수형 프로그래밍 사이드 이펙트가 없는 순수 함수 코드를 강조하는 프로그래밍 방식 숨은 입출력이 없어야 함 사이드 이펙트(인스턴스 상태 변경, 파일 I/O), 예외, 내외부 상태 참조(비공개 속성, DB 조회) 지향점 비즈니스 로직 코드와 사이드 이펙트 발생 코드를 분리 어떤 사이드 이펙트도 일으키지 않는 애플리케이션은 불가능 함수형 아키텍처 사이트 이펙트 코드를 최소화하고 순수 함수 방식 코드를 극대화하는 방식 구성 함수형 코어 (functional core, immutable core) 결정을 내리는 코드 수학적 함수로 작성 가변 셸 (mutable shell) 해당 결정에 따라 작용하는 코드 (실행) 수학적 함수에 의해 이뤄진 모든 결정을 가시적으로 변환 (DB 변경, 메시지 버스 전송) 협력 과정 가변 셸이 모든 입력 수집 -> 함수형 코어는 결정을 생성 -> 가변 셸은 결정을 사이드 이펙트로 변환 헥사고날 아키텍처과의 공통점 및 차이점 헥사고날 아키택처 ⊃ 함수형 아키텍처 (극단적으로 함수형 아키텍처 = 헥사고날 아키텍처) 공통점 관심사 분리 측면 도메인 : 애플리케이션 서비스 = 함수형 코어 : 가변 셸 단방향 의존성 흐름 차이점 사이드 이펙트 처리 헥사고날 아키텍처는 도메인 계층 내라면 사이드 이펙트 허용 함수형 아키텍처는 모든 사이드 이펙트를 함수형 코어 밖 가장자리로 밀어냄 단점 적용 불가 상황 존재 의사 결정 절차 중간에 프로세스 외부 의존성을 조회 시, 출력 기반 테스트 적용 불가 e.g. DB에 있는 방문자의 접근 레벨을 중간에 조회해야할 때 public FileUpdate AddRecord(..., IDatabase database) {...} 도메인은 절대로 DB에 의존해서는 안됨 해결책 애플리케이션 서비스 전면부에서 방문자 접근 레벨도 수집 접근 레벨이 필요 없어도 DB 조회하므로 성능 저하 그럼에도 사이드 이펙트 분리 유지 가능한 장점 AuditManager에 IsAccessLevelCheckRequired() 메서드 두기 애플리케이션 서비스에서 AddRecord() 전에 호출 true 반환 시 AddRecord()에 접근 레벨 전달 분리를 다소 완화하고 성능 향상 (필요할 때만 DB 조회) 성능 감소 함수형 아키텍처를 지키다보면 시스템이 외부 의존성을 더 많이 호출 e.g. 초기 버전과 목 버전과 달리 최종 버전은 디렉토리에서 모든 파일을 읽음 결론: 성능이 영향이 미미하다면 유지보수성을 택하는게 나음 코드베이스 크기 증가 함수형 아키텍처는 코드 복잡도가 낮아지고 유지보수성이 향상되지만 초기 코딩이 증가 복잡도가 낮은 간단한 프로젝트는 초기 투자가 타당 X 단위 테스트 스타일 선택 전략 최대한 출력 기반 테스트 지향 함수형 코어는 출력 기반 테스트로, 가변 셸은 훨씬 더 적은 수의 통합 테스트로 다루기 함수형 프로그래밍을 활용해 기반 코드가 함수형 아키텍처 지향하도록 재구성 출력 기반 스타일 변환 사이드 이펙트를 비즈니스 연산 끝으로 몰아서 비즈니스 로직을 사이드 이펙트와 분리 e.g. 파일 I/O가 섞인 코드 (AuditManger) 초기 도메인 객체 AuditManger는 파일 I/O 코드를 품고 있음 테스트도 파일 I/O로 검증 (단위 테스트 X, 통합 테스트 O) 방법 1: 파일 I/O를 목으로 대체해 주입하기 (목 사용 테스트) 방법 2: I/O 담당 클래스로 따로 만들어 외부로 빼기 (출력 기반 테스트) = 사이드 이펙트 외부 추출 AuditManager (함수형 코어) - Persister (가변 셸) AuditManager는 new FileUpdate() 식으로 업데이트 명령 반환 추가할만한 사항 삭제 유스케이스가 있다면 FileAction & ActionType Enum 처리 오류처리 필요시 예외 클래스를 만들어 반환 간헐적으로 상태 기반 테스트, 통신 기반 테스트 사용 객체 지향은 모든 테스트를 출력 기반 전환 불가 e.g. User 클래스의 email, type 속성 변경 상태 기반 테스트지만 사이드 이펙트가 메모리에 남아 있어 테스트 용이성 향상 최대한 출력 기반 테스트로 전환하되 비용에 따라 상태, 통신 기반 테스트를 적절히 섞자 스타일과 단위 테스트 분파 두 분파는 출력 기반 테스트를 사용 고전파는 상태 기반 테스트 선호, 런던파는 통신 기반 테스트 선호 코드 오염 (Code Pollution) 단위 테스트를 가능하게 하거나 단순화하기 위한 목적만으로 제품 코드베이스를 오염시키는 것을 말한다. 가치 있는 테스트를 위한 리팩토링 제품 코드의 4가지 유형 분류 기준 코드 복잡도: 코드 내 의사 결정 분기 수 도메인 유의성: 코드가 프로젝트 문제 도메인에 얼마나 의미가 있는지 협력자 수: 클래스나 메서드 내에 가변 의존성 또는 프로세스 외부 의존성 수 유형 도메인 모델 및 알고리즘 (중요) 복잡한 코드와 도메인 유의성을 갖는 코드가 단위테스트에서 가장 이로움 협력자가 없어 유지비가 낮고 회귀 방지 탁월 참고: 복잡도와 도메인 유의성은 서로 독립적 (도메인 코드가 안복잡할 수 있음) 간단한 코드 (테스트 필요 X) 컨트롤러 도메인 클래스나 외부 애플리케이션 같은 다른 구성 요소의 작업 조정 협력자가 많은 코드는 테스트 비용이 많이 듦 (유지 보수성 감소) 통합 테스트로 간단히 테스트 지나치게 복잡한 코드 알고리즘과 컨트롤러로 나누어 리팩토링하자 이상적으로 여기 속하는 코드는 없어야 함 e.g. 여러 책임을 가지고 있는 덩치 큰 컨트롤러 분리 불가능한 경우도 존재하지만 분리를 지향하면 지나치게 복잡한 코드는 아닐 것!! 컨트롤러에 비즈니스 로직이 있을 수도 있음 도메인 클래스에 협력자가 하나, 둘, 심지어 셋 있을 수도 있음 그래도 프로세스 외부 의존성 및 목 사용은 지양 지나치게 복잡한 코드 분할하기 험블 객체 패턴 (Humble Object) 험블 객체(험블 래퍼)를 두고 이곳에서 중요 로직과 테스트가 어려운 의존성을 붙이는 패턴 프레임워크 의존성과 결합되어 있는 코드는 테스트가 어려움 e.g. 비동기, 멀티스레딩, 사용자 인터페이스, 프로세스 외부 의존성 통신 방법 테스트 가능한 로직을 따로 추출 험블 객체를 통해 테스트 로직과 테스트 어려운 의존성을 각각 호출 험블 객체는 오케스트레이션을 할 뿐 자체적인 로직이 없으므로 테스트할 필요 X 예시 헥사고날 아키텍처, 함수형 아키텍처와 완전히 일치! 로직: 도메인 계층, 함수형 코어 테스트하기 어려운 의존성: 애플리케이션 서비스 계층, 가변 셸 단일 책임 원칙(SRP) 관점과도 일치 비즈니스 로직과 오케스트레이션 분리 MVC(Model-View-Controller), MVP(Model-View-Presenter) 패턴 컨트롤러와 프레젠터는 험블 객체로서 모델과 뷰를 붙임 DDD의 집계 패턴 (Aggregate Pattern) 클래스를 클러스터로 묶으면 코드 베이스의 총 통신 수가 줄고 테스트 용이성 향상 지나치게 복잡한 코드 리팩토링 단계 1단계: 암시적 의존성을 명시적 의존성으로 만들기 도메인 객체 내 프로세스 외부 의존성은 인터페이스를 두어 주입 (목 방식) e.g. 데이터베이스, 메시지 버스 통합 테스트에도 중요 그러나 도메인 모델은 프로세스 외부 의존성에 의존하지 않는 것이 깔끔 2단계: 애플리케이션 서비스 계층 도입 험블 컨트롤러로 오케스트레이션 책임을 위임 도메인 모델이 외부 시스템과 직접 통신하는 문제 극복 도메인 모델은 잘 분리되었지만 컨트롤러는 아직 복잡한 상태 3단계: 애플리케이션 서비스 복잡도 낮추기 객체 매핑 작업 추출하기 ORM 사용 원시 데이터베이스 사용 시 데이터 매핑을 위한 팩토리 클래스 작성 (in 도메인 모델) 방법 별도의 클래스 (권장) 간단한 경우, 기존 도메인 클래스의 정적 메서드 애플리케이션 서비스에서 조정 object[] userData = _database.GetUserById(userId); User user = UserFactory.create(userData); 테스트해볼 만함 언어 혹은 프레임워크 내 숨은 분기 존재 데이터 요소 접근이나 타입 캐스팅 예외 등 오케스트레이션 처리 절충하기 비즈니스 로직과 오케스트레이션 분리는 다음 패턴에서 가장 효율적 외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기 중간 결과를 바탕으로 프로세스 외부 의존성을 추가로 조회해야할 경우 존재 외부 읽기 - 비즈니스 로직 실행 - 외부 읽기 - 비즈니스 로직 실행 - 외부 쓰기 대처 방법 모든 대처 방법은 위 3가지 특성 중 2가지만 가질 수 있으므로 선택 필요 도메인 모델 테스트 유의성: 도메인 클래스 내 협력자 수와 유형 영향 컨트롤러 단순성: 분기 수 영향 성능: 프로세스 외부 의존성 호출 수 종류 의사 결정 프로세스 단계를 더 세분화하기 지나치게 복잡한 컨트롤러를 만들지만 완화 방법 사용으로 절충 CanExecute/Execute 패턴 사용 도메인 클래스 내 CanExecute() 메서드에 두기 모든 유효성 검사 진행 메서드 Execute() 및컨트롤러 둘 모두에서 호출! 비즈니스 로직이 컨트롤러로 유출되는 것을 방지 (캡슐화) 도메인 계층의 모든 결정 통합 e.g. User에 CanChangeEmail() 메서드 두기 모든 유효성 검사를 CanChangeEmail()에 두기 ChangeEmail()은 CanChangeEmail() 호출 컨트롤러도 CanChangeEmail() 호출 외부 통신 여부 결정, 성공하면 외부 통신 CanExecute/Execute 패턴 적용 불가능한 경우도 존재 파편화 로직을 컨트롤러에 넣고 통합테스트로 처리해야 함 e.g. 이메일 고유성 검증 프로세스 외부 의존성에 따른 도메인 로직 의사 결정 모든 외부 읽기 쓰기를 가장자리로 밀어내기 대부분 프로젝트에서 성능은 매우 중요하므로 고려 X 도메인 모델에 프로세스 외부 의존성 주입(내부에서 외부 읽기쓰기 결정) 비즈니스 로직과 외부 통신이 결합되므로 테스트와 유지보수 어렵 도메인 이벤트를 사용해 도메인 모델 변경 사항 추적하기 도메인 이벤트는 도메인 모델의 중요 변경 사항을 추적하고 외부에 알리는데 사용됨 e.g. 메시지 버스에 메시지를 보내서 외부에 변경 알리기 비즈니스 로직 파편화 예방 의사 결정 책임을 도메인 모델에 유지 컨트롤러로 도메인 로직이 유출되는 것을 방지 e.g. 이메일 변경이 안되었다면 이벤트만으로 이메일 메시지 전송 안할 수 있음 DB는 이메일 변경이 안되어도 매번 저장해도 됨 식별할 수 있는 동작 X, 상대적으로 성능 차이 미미 ORM 사용 시 상태 변경 없으면 DB I/O가 없어 더욱 용이 이메일 메시지 전송은 식별할 수 있는 동작이어서 조정 필요 도메인 이벤트 구현 도메인 이벤트 클래스 public class EmailChangedEvent { public int UserId { get; } public string NewEmail { get; } } 외부 시스템에 통보하는데 필요한 데이터가 포함된 클래스 e.g. 사용자 ID, Email 과거 시제 명명 (이미 일어난 일들을 나타내므로) 값 객체 (불변) DomainEvent를 공통 클래스로 뽑아도 좋음 도메인 클래스 이벤트 컬렉션 보유 e.g. List<DomainEvent> 컨트롤러 끝에서 외부로 이벤트 발행하거나 이벤트 디스패처 사용 가능 e.g. User 도메인 클래스 ChangeEmail() 메서드 EmailChangedEvents.Add(new EmailChangedEvent(UserId, newEmail)); 컨트롤러 끝단에서 도메인 이벤트 처리 foreach (var ev in user.EmailChangedEvents) { _messageBus.SendEmailChangedMessage(ev.UserId, ev.NewEmail); } 4단계: 책임 명확히 위임하기 잘못 둔 책임은 새로운 클래스에 두어 리팩토링 e.g. Company 클래스 - ChangeNumberOfEmployees(), IsEmailCorporate() 5단계: 테스트 적용 외부 클라이언트 입장에서 식별할 수 있는 동작을 파악해 계층적으로 테스트하자! 고객(클라이언트) 입장에서 컨트롤러의 ChangeEmail() 및 메시지 버스 호출 컨트롤러(클라이언트) 입장에서 User의 ChangeEmail() User(클라이언트) 입장에서 Company의 ChangeNumberOfEmployees(), IsEmailCorporate() 즉, 외부 계층의 관점에서 각 계층을 테스트하고, 기저 계층과의 통신(구현)은 무시 단위 테스트 User의 ChangeEmail() 테스트 Changing_email_from_non_corporate_to_corporate() Assert.Equal(2, company.NumberOfEmployees) Assert.Equal("new@mycop.com, sut.Email) Assert.Equal(UserType.Employee, sut.Type) Changing_email_from_corporate_to_non_corporate() sut.Email.Should().Be("new@gmail.com"); sut.Type.Should().Be(UserType.Customer); sut.EmailChangedEvents.Should().Equal(new EmailChangedEvent(1, "new@gmail.com")); 도메인 이벤트 검증 Changing_email_without_changing_user_type() Changing_email_to_the_same_one() Company 테스트 도메인 유의성이 있는 모든 전제 조건은 테스트 O ChangeNumberOfEmployees() -> 전제조건: 직원수는 음수 X 도메인 유의성이 없는 전제 조건은 테스트 X UserFactory의 Create() -> 전제조건: data.Length >= 3 User와 Company 생성자 테스트 -> 필요 X 통합 테스트 UserController의 ChangeEmail() 테스트 액티브 레코드 패턴 (Active Record pattern) 도메인 클래스가 스스로 데이터베이스를 검색하고 저장하는 방식을 말한다. 단순하고 단기적인 프로젝트에는 잘 작동하지만, 코드베이스가 커지면 확장하기 어렵다. CanExecute/Execute 패턴 예시 도메인 클래스 내 유효성 검사를 담당하는 CanExcute()는 Execute()와 컨트롤러에서 모두 호출한다. User 도메인 클래스 public string CanChangeEmail() { if (IsEmailConfirmed) return "Can't change a confirmed email"; return null; } public void ChangeEmail(string newEmail, Company company) { Precondition.Requires(CanChangeEmail() == null); ... } 컨트롤러 public string ChangeEmail(int userId, string newEmail) { object[] userData = _database.GetUserById(userId); User user = UserFactory.Create(userData); string error = user.CanChangeEmail(); if (error != null) return error; object[] companyData = _database.GetCompany(); Company company = CompanyFactory.Create(companyData); ... } 통합 테스트 통합 테스트: 단위 테스트가 아닌 모든 테스트 단위 테스트의 3가지 요구 사항을 하나라도 충족하지 않으면 통합테스트 단일 동작 단위를 검증 빠르게 수행 다른 테스트와 별도로 처리 통합 테스트는 시스템이 전체적으로 잘 작동하는지 확신하기 위해 사용 각 부분이 외부 시스템(DB, 메시지 버스)과 어떻게 통합되는지 확인 필요 모든 테스트는 도메인 모델과 컨트롤러에만 초점을 맞춰야 한다! 단위 테스트로 가능한 한 많이 비즈니스 시나리오의 예외 상황 확인 통합 테스트는 주요 흐름(happy path)과 단위 테스트가 못 다루는 기타 예외 상황(edge case) 확인 비즈니스 시나리오 당 1~2개 -> 시스템 전체의 정확도 보장 통합 테스트 전략 가장 긴 주요 흐름(happy path)을 선택해 프로세스 외부 의존성과의 상호작용을 모두 확인 1개 테스트로 어렵다면 외부 통신을 모두 확인할 수 있도록 통합 테스트 추가 작성 컨트롤러에서 빠른 실패 원칙에 해당하는 예외는 통합 테스트로 다루지 말기 e.g. CanChangeEmail()는 통합 테스트 가치가 적음 애플리케이션 초반부에서 버그를 내어 데이터 손상으로 이어지지 않음 오히려 단위 테스트에서 확인하기 좋음 관리 의존성은 실제 인스턴스 사용하고, 비관리 의존성은 목으로 대체하자 프로세스 외부 의존성 유형 관리 의존성 애플리케이션을 통해서만 접근할 수 있는 의존성 ex. DB 구현 세부사항 (하위 호환 고려 X) 비관리 의존성 외부에서도 접근할 수 있는 의존성 ex. SMTP, 메시지버스 식별할 수 있는 동작 (하위 호환 유지 필요) 특이 케이스) 관리 의존성이면서 비관리 의존성인 경우 e.g. 다른 애플리케이션에서 접근할 수 있는 DB (특정 테이블 접근 권한 열어둠) 일시적 대응: 공유된 테이블을 비관리 의존성 취급하자 사실상 메시지 버스, 목 대체 필요 다만, 시스템 간 결합도와 복잡도가 증가하므로 지양 API, 메시지 버스 통신이 더 나음 실제 데이터베이스를 사용할 수 없는 경우, 통합 테스트 작성하지 말고 도메인 모델 단위 테스트에 집중 보안 혹은 비용 문제로 실제 DB를 사용할 수 없는 경우 존재 관리 의존성을 목으로 대체하면 회귀 방지에서 단위 테스트와 차이 X (리팩터링 내성도 저하) 엔드 투 엔드 테스트는 대부분의 경우 생략 가능 통합 테스트 보호 수준이 엔드 투 엔드와 비슷함 (관리 의존성 포함 및 비관리 의존성 목 대체) 배포 후 1~2개 정도의 중요한 엔드 투 엔드 테스트 작성 가능 엔드 투 엔드 테스트는 프로세스 외부 의존성을 모두 실제 인스턴스 사용해야 해 느림 검증: 메시지 버스를 직접 확인, 데이터베이스 상태는 애플리케이션을 통해 간접 확인 테스트 예시 (CRM 프로젝트) 가장 긴 주요 흐름 (Changing_email_from_corporate_to_non_corporate()) 기업 이메일에서 일반 이메일로 변경하는 것 사이드 이펙트 가장 많음 (DB update, 메시지버스) 단위 테스트로 어려운 예외 상황 (이메일을 변경할 수 없는 시나리오) 테스트 필요 X, 빠른 실패 케이스 로깅(Logging) 기능 테스트? //User 도메인 클래스 public void ChangeEmail(string newEmail, Company company) { _logger.Info( $"Changing email for user {UserId} to {newEmail}"); //진단 로깅 (지양) Precondition.Requires(CanChangeEmail() == null); if (Email == newEmail) return; UserType newType = company.IsEmailCorporate(newEmail) ? UserType.Employee : UserType.Customer; if (Type != newType) { int delta = newType == UserType.Employee ? 1 : -1; company.ChangeNumberOfEmployees(delta); AddDomainEvent( new UserTypeChangedEvent( UserId, Type, newType)); //DomainLogger 대신 도메인 이벤트 사용 } Email = newEmail; Type = newType; AddDomainEvent(new EmailChangedEvent(UserId, newEmail)); _logger.Info($"Email is changed for user {UserId}"); //진단 로깅 (지양) } 로깅은 횡단 관심사 (코드베이스 어느 부분에서나 필요함) 로깅은 프로세스 외부 의존성에 사이드 이펙트를 초래 (텍스트 파일, DB) 사이드 이펙트를 개발자 이외 사람(API 클라이언트, 고객)이 보는 경우 -> 반드시 테스트! 지원 로깅 (support logging) 식별할 수 있는 동작 e.g. 비즈니스 요구사항이므로 명시적으로 래퍼 클래스 만들기 (IDomainLogger, DomainLogger) public class DomainLogger : IDomainLogger { private readonly ILogger _logger; public DomainLogger(ILogger logger) { _logger = logger; } public void UserTypeHasChanged( int userId, UserType oldType, UserType newType) { _logger.Info( $"User {userId} changed type " + $"from {oldType} to {newType}"); } } 사이드 이펙트를 개발자만 보는 경우 -> 테스트 X 진단 로깅 (diagnostic logging) 구현 세부 사항 e.g. 로그 라이브러리 그대로 사용 (ILogger) 과도한 사용 지양 도메인 모델에서 절대 사용하지 말자 컨트롤러에서 무언가를 디버깅해야 할 때만 일시적으로 사용하고 제거하자 실제 테스트 지원 로깅은 도메인 클래스에서 필요할 때 도메인 이벤트 사용해 분리 프로세스 외부 의존성(로그 저장소)이 있으므로 컨트롤러에서 필요할 때 로그 라이브러리 그대로 사용 프로세스 외부 의존성을 조정하는 곳이므로 단위 테스트는 User에서 UserTypeChangedEvent 확인 통합 테스트는 목을 사용해 DomainLogger와의 상호 작용 확인 목 사용 가치 최대화 모범 전략 비관리 의존성만 목으로 대체하기 비관리 의존성 검증에 필요한 정확도에 따라 어느 지점을 목으로 대체할지 결정해야 함 메시지 버스는 발행되는 메시지 구조가 중요하므로 통신하는 시스템 끝 마지막 타입 검증이 유리 지원 로깅은 로그의 구조가 중요하진 않으므로 IDomainLogger만 목으로 처리해도 충분 e.g. 메시지 버스 예시 IMessageBus(도메인 관련 메시지 정의 래퍼 클래스) & IBus(메시지 버스 SDK 래퍼) public interface IMessageBus { void SendEmailChangedMessage(int userId, string newEmail); } public class MessageBus : IMessageBus { private readonly IBus _bus; public void SendEmailChangedMessage( int userId, string newEmail) { _bus.Send("Type: USER EMAIL CHANGED; " + $"Id: {userId}; " + $"NewEmail: {newEmail}"); } } public interface IBus { void Send(string message); } IBus를 목으로 처리하면 회귀방지, 리팩터링 내성 극대화 가능 IBus가 비관리 의존성과 통신하는 마지막 타입 구현 세부 사항이 아닌 실제 사이드 이펙트 검증 가능 테스트 - 목 버전 [Fact] public void Changing_email_from_corporate_to_non_corporate() { var busMock = new Mock<IBus>(); // IBus를 목으로 대체 var messageBus = new MessageBus(busMock.Object);//구체클래스 var loggerMock = new Mock<IDomainLogger>(); var sut = new UserController(db, messageBus, loggerMock.Object); /* ... */ busMock.Verify( x => x.Send( "Type: USER EMAIL CHANGED; " + $"Id: {user.UserId}; " + $"NewEmail: new@gmail.com"), Times.Once); } IMessageBus 인터페이스를 삭제하고 MessageBus로 대체 가능 목 대체 목적이 사라진 IMessageBus는 구현이 하나뿐인 인터페이스 테스트 - 스파이 버전 [Fact] public void Changing_email_from_corporate_to_non_corporate() { var busSpy = new BusSpy(); var messageBus = new MessageBus(busSpy); var loggerMock = new Mock<IDomainLogger>(); var sut = new UserController(db, messageBus, loggerMock.Object); /* ... */ busSpy.ShouldSendNumberOfMessages(1) .WithEmailChangedMessage(user.UserId, "new@gmail.com"); } 시스템 끝에 있는 클래스는 스파이가 목보다 나음 (스파이: 직접 작성한 목) public interface IBus { void Send(string message); } public class BusSpy : IBus { private List<string> _sentMessages = new List<string>(); public void Send(string message) { _sentMessages.Add(message); } public BusSpy ShouldSendNumberOfMessages(int number) { Assert.Equal(number, _sentMessages.Count); return this; } public BusSpy WithEmailChangedMessage(int userId, string newEmail) { string message = "Type: USER EMAIL CHANGED; " + $"Id: {userId}; " + $"NewEmail: {newEmail}"; Assert.Contains( _sentMessages, x => x == message); return this; } } 검증 단계에서 코드를 재사용해 테스트 크기 감소 간결한 영어 문장의 플루언트 인터페이스로 가독성 향상 통합 테스트에서만 목 사용하기 (단위 테스트 X) 항상 목 호출 수 확인하기 비관리 의존성 통신에서 확인해야할 것 예상하는 호출이 있는가? 예상치 못한 호출은 없는가? e.g. Times.Once (정확히 한 번만 전송되는지 확인하기) e.g. messageBusMock.VerifyNoOtherCalls(); (목 라이브러리 지원) 보유 타입만 목으로 처리하기 서드파티 라이브러리 위에 항상 어댑터를 작성하고, 해당 어댑터를 목으로 처리해야 함 손상 방지 계층으로서 서드파티 라이브러리의 복잡성을 추상화하고 필요한 기능만 노출 마찬가지로 비관리 의존성에만 적용 e.g. IBus 데이터베이스 테스트 (관리 의존성) 테스트 전제 조건 형상 관리 시스템에 데이터베이스를 유지하자 데이터베이스 스키마를 일반 코드 취급해 형상 관리 시스템에 저장 (Git) SQL 스크립트 형태 (테이블, 뷰, 인덱스, 저장 프로시저) 참조 데이터의 경우 SQL INSERT 문 형태로 함께 저장 (e.g. UserType 테이블) 모델 데이터베이스 사용은 안티패턴 데이베이스 스키마를 과거 특정 시점으로 되돌릴 수 없음 (추적 불가) 모든 개발자는 로컬에서 별도의 데이터베이스 인스턴스 사용하자 마이그레이션 기반 데이터베이스 배포 지향하자 마이그레이션 방식은 초기에는 구현과 유지보수가 어렵지만 효과적 상태 기반 방식 배포 중에 비교 도구가 스크립트를 자동 생성해 모델 DB에 맞게 운영 DB 업데이트 스크립트는 형상 관리로 저장 마이그레이션 방식 업그레이드 스크립트를 직접 작성해 형상 관리로 저장 SQL 스크립트 혹은 SQL로 변환할 수 있는 DSL 언어 사용 통합 테스트 트랜잭션 관리 대부분의 ORM은 Unit of Work 패턴을 구현 작업 단위 비즈니스 작업에서 하나의 트랜잭션으로 묶이는 데이터 변경 작업의 집합 e.g. JPA 영속성 컨텍스트 통합 테스트에서는 적어도 3개의 작업 단위를 사용하자! (준비, 실행, 검증 구절 당 하나씩) 통합 테스트는 가능한 운영 환경과 비슷해야함 같은 테스트에서 작업 단위 재사용은 운영 환경과 다른 환경을 만들어서 문제 e.g. 동일 테스트 내에서 DB에 바로 업데이트 쿼리를 날림 조회는 ORM의 1차 캐시에서 진행해서 업데이트 반영 X 검증부에서 업데이트가 안되어 테스트가 실패할 수 있음 공유 데이터베이스에서 각 통합 테스트 격리하기 통합 테스트를 순차적으로 실행하기 순차적 테스트가 병렬 테스트보다 실용적 (성능 향상 이점보다 복잡함이 큼) 대부분의 단위 테스트 프레임워크에서 기능 지원 두 가지 테스트군 만들기 (단위 테스트 & 통합 테스트) 통합테스트군은 테스트 병렬처리 비활성화하기 테스트 실행 간에 남은 데이터 제거하기 테스트 시작 시점에 데이터 정리하기 (Unit Testing 책의 권장 전략) 정리 단계를 실수로 건너 뛰지 않고 빠른 동작과 일관성을 제공 모든 통합 테스트에 기초 클래스 두고 삭제 스크립트 작성 public abstract class IntegrationTests { private const string ConnectionString = "..."; protected IntegrationTests() { ClearDatabase(); } private void ClearDatabase() { string query = "DELETE FROM dbo.[User];" + "DELETE FROM dbo.Company;"; using (var connection = new SqlConnection(ConnectionString)) { var command = new SqlCommand(query, connection) { CommandType = CommandType.Text }; connection.Open(); command.ExecuteNonQuery(); } } } 데이터베이스 트랜잭션에 각 테스트를 래핑하고 커밋하지 않기 (애매) 변경 내용이 자동으로 롤백되어 정리 단계 생략 문제를 해결하고 편리 운영 환경과 다른 환경을 만듦 ReadUncommited 격리 레벨이 아닌 이상 트랜잭션 하나에서 준비, 실행, 검증 구절을 진행 -> 1차 캐시로 인한 테스트 변질 발생 가능성 테스트 종료 시점에 데이터 정리하기 빠르지만 정리 단계를 건너뛰기 쉬움 테스트 도중 중단하면 데이터가 DB에 남아 있어 이후 테스트에 영향을 줌 각 테스트 전 데이터베이스 백업 복원하기 (지양) 가장 느림 컨테이너를 사용해도 컨테이터 인스턴스 제거 및 새 컨테이너 생성에 몇 초 걸림 인메모리 데이터베이스 피하기 테스트용 DB로 SQLite 같은 인메모리 DB를 사용할 수 있음 테스트 데이터를 제거할 필요 X 빠름 테스트 실행할 때마다 인스턴스화 가능 공유 의존성 X (단위 테스트화) 일반 DB와 기능적 일관성이 없음 (운영 환경과 테스트 환경 불일치) 거짓 양성, 나아가 거짓 음성 다량 발생 테스트에서도 운영환경과 같은 DBMS를 사용하자 (버전은 달라도 괜찮, 공급업체는 같음) 테스트 구절에서 코드 재사용하기 (통합 테스트 크기 줄이기) 비즈니스와 관련 없는 기술적인 부분을 비공개 메서드 혹은 헬퍼 클래스로 추출 (재사용) 헬퍼 메서드로 작업 단위(트랜잭션 수)가 더욱 늘어날 수 있지만 유지보수성 위해 절충 준비 구절 전략 기본적으로 테스트와 동일한 클래스에 팩토리 메서드 배치 기초 클래스에 두지 말자 모든 테스트에서 실행하는 코드만 둬야 함 e.g. 데이터 정리 코드 반복 있을 시, 헬퍼 클래스 생성 및 배치 오브젝트 마더 패턴 private User CreateUser( string email = "user@mycorp.com", UserType type = UserType.Employee, bool isEmailConfirmed = false) { using (var context = new CrmContext(ConnectionString)) { var user = new User(0, email, type, isEmailConfirmed); var repository = new UserRepository(context); repository.SaveUser(user); context.SaveChanges(); return user; } } 오브젝트 마더 (Object Mother) - 지향 테스트 픽스처(테스트 실행 대상)를 만드는데 도움이 되는 클래스 또는 메서드 준비 구절에서 코드 재사용 용이 테스트 데이터 빌더 패턴 - 지양 User user = new UserBuilder().WithEmail(..).WithType(..).Build(); 플루언트 인터페이스 제공 (약간의 가독성 향상) 마찬가지로 준비 구절에서 코드 재사용 용이 상용구가 너무 많이 필요하므로 불편 실행 구절 private string Execute( Func<UserController, string> func, MessageBus messageBus, IDomainLogger logger) { using (var context = new CrmContext(ConnectionString)) { var controller = new UserController( context, messageBus, logger); return func(controller); } } 컨트롤러 정보를 받아 실행하는 헬퍼 메서드 도입 (실행 구절 줄이기) 프로세스 외부 의존성도 한 번에 전달 검증 구절 public static class UserExtensions { public static User ShouldExist(this User user) { Assert.NotNull(user); return user; } public static User WithEmail(this User user, string email) { Assert.Equal(email, user.Email); return user; } } // Example usage User userFromDb = QueryUser(user.UserId); userFromDb .ShouldExist() .WithEmail("new@gmail.com") .WithType(UserType.Customer); Company companyFromDb = QueryCompany(); companyFromDb .ShouldExist() .WithNumberOfEmployees(0); 헬퍼 메서드 두기 (+플루언트 인터페이스) 읽기 테스트를 해야 하는가? 가장 복잡하거나 중요한 읽기 작업만 테스트하고 나머지는 무시 (할 경우 통합 테스트로 진행) 읽기 버그는 해로운 문제가 없음 성능면에서 일반 SQL 사용하는 것이 좋음! 도메인 모델도 필요 X ORM의 불필요한 추상화 계층 피할 수 있음 쓰기를 철저히 테스트하는 것이 매우 중요 위험성이 높기 때문에 매우 가치 있음 리포지토리 테스트를 해야 하는가? 마찬가지로 직접 테스트하지말고 통합 테스트의 일부로서만 다루기 컨트롤러 사분면에 소속 -> 통합테스트가 필요한데 이점이 적음 유지비가 높음 (외부 통신 존재) 그에 비해 회귀 방지 이점이 적음 (복잡도가 거의 없음) 복잡도가 있는 부분은 별도 알고리즘으로 추출해 테스트 e.g. 객체 매핑 작업 (UserFactory, CompanyFactory) EventDispatcher도 별도로 테스트하지 말자 유지비가 높지만 회귀 방지 이점이 적음 인터페이스의 사용이유 2가지 느슨한 결합 구체 클래스가 2개 이상일 때 추상화를 위해 사용 구체 클래스가 1개일 때 인터페이스 도입은 YAGNI 위배 (You aren’t gonna need it) 목 사용 구체 클래스가 1개일 경우에도 인터페이스를 사용하는 이유 인터페이스가 없으면 테스트 대역을 만들 수 없음 의존성을 목으로 처리할 필요가 있을 때만, 프로세스 외부 의존성에 인터페이스 두자 = 비관리 의존성에만 인터페이스를 쓰자 e.g. private readonly Database _database; private readonly ImessageBus _messageBus; Unit Testing 책 권장 백엔드 시스템 계층 간접 계층은 많은 애플리케이션 문제를 해결하지만, 가능한 간접 계층을 적게 사용하자. 도메인 모델 (도메인 로직) 애플리케이션 서비스 계층 = 컨트롤러 (외부 클라이언트에 대한 진입점 제공 및 오케스트레이션) 인프라 계층 (데이터베이스 저장소, ORM 매핑, SMTP 게이트웨이) 순환 의존성 제거하기 순환 의존성이란 둘 이상의 클래스가 제대로 작동하고자 직간접적으로 서로 의존하는 것을 말한다. 순환 의존성은 코드를 읽을 때 주변 클래스 그래프를 파악해야 하는 부담이 존재하며 테스트를 방해한다. 따라서, 순환 의존성은 최대한 제거하자. 실행 구절이 여러 개인 다단계 테스트 여러 개 실행 구절을 가지는 테스트는 프로세스 외부 의존성을 관리하기 어려운 경우에 발생한다. 따라서, 다단계 테스트는 거의 항상 엔드 투 엔드 테스트 범주에서만 허용된다. (통합테스트도 드묾) 단위 테스트는 절대로 실행 구절이 여러 개 있어서는 안된다. 식별할 수 있는 동작 기준 식별할 수 있는 동작은 다음 2가지 기준 중 하나를 충족해야 한다. 클라이언트의 목표 중 하나에 직접적 연관이 있음 외부에서 접근할 수 있는 프로세스 외부 의존성에서 사이드 이펙트가 발생함 권장 의존성 주입 모든 의존성은 항상 생성자 혹은 메서드를 통해 명시적으로 주입하자. 의존성을 내부로 숨기는 Ambient Context는 안티패턴이다. 이는 의존성이 숨어있어 변경이 어렵고 테스트가 더 어려워진다. 단위 테스트 안티 패턴 안티 패턴 1: 비공개 메서드 단위 테스트 식별할 수 있는 동작으로 간접적으로 비공개 메서드를 테스트해야 함 즉, 최대한 하지 말아야 한다! 식별할 수 있는 동작으로 테스트 해도 비공개 메서드가 매우 복잡해 커버리지가 낮은 경우 해당 비공개 메서드는 죽은 코드이거나 (삭제 필요) 추상화가 누락된 징후 (별도 클래스로 도출 필요) e.g. 복잡한 비공개 메서드 public class Order { private Customer _customer; private List<Product> _products; public string GenerateDescription() { return $"Customer name: {_customer.Name}, " + $"total number of products: {_products.Count}, " + $"total price: {GetPrice()}"; } private decimal GetPrice() { decimal basePrice = /* _products에 기반한 계산 */; decimal discounts = /* _customer에 기반한 계산 */; decimal taxes = /* _products에 기반한 계산 */; return basePrice - discounts + taxes; } } 추상화 적용 코드 public class Order { private Customer _customer; private List<Product> _products; public string GenerateDescription() { var calc = new PriceCalculator(); return $"Customer name: {_customer.Name}, " + $"total number of products: {_products.Count}, " + $"total price: {calc.Calculate(_customer, _products)}"; } } public class PriceCalculator { public decimal Calculate(Customer customer, List<Product> products) { decimal basePrice = /* _products에 기반한 계산 */; decimal discounts = /* _customer에 기반한 계산 */; decimal taxes = /* _products에 기반한 계산 */; return basePrice - discounts + taxes; } } 비공개 메서드 테스트가 타당한 예외도 존재 신용 조회 관리 시스템 (Inquiry 클래스의 비공개 생성자 내 승인 로직) 승인 로직은 중요하므로 단위테스트를 거쳐야 함 -> public 허용 안티 패턴 2: 단위 테스트 목적으로 비공개 상태 노출하기 비공개 상태 -> 식별할 수 없는 동작 비공개 상태를 바꾸는 메서드 테스트 -> 단일 메서드 보다 식별할 수 있는 동작 관점에서 테스트하자 추후에 비즈니스 요구 사항으로 공개 상태로 바뀌면, 그 때는 상태를 검증하면 좋다! 안티 패턴 3: 테스트로 유출된 도메인 지식 public class CalculatorTests { [Theory] [InlineData(1, 3)] [InlineData(11, 33)] [InlineData(100, 500)] public void Adding_two_numbers(int value1, int value2) { int expected = value1 + value2; // 유출 int actual = Calculator.Add(value1, value2); Assert.Equal(expected, actual); } } 테스트가 제품 코드에서 알고리즘 구현을 복사한 상황 (value1 + value2) 복잡한 알고리즘 다루는 테스트에서 주로 발생 구현 세부사항과 결합되는 테스트 (리팩토링 내성 0점) 테스트 작성 시 결과를 하드코딩하자! public class CalculatorTests { [Theory] [InlineData(1, 3, 4)] [InlineData(11, 33, 44)] [InlineData(100, 500, 600)] public void Adding_two_numbers(int value1, int value2, int expected) { int actual = Calculator.Add(value1, value2); Assert.Equal(expected, actual); } } 하드코딩 예상 결과값은 도메인 전문가의 도움을 받아 SUT가 아닌 다른 것으로 미리 계산 레거시 코드 리팩토링 시 레거시 코드로 결과를 생성하고 예상 결과 값으로 사용 가능 안티 패턴 4: 코드 오염 테스트에만 필요한 코드를 제품 코드에 추가하는 것 테스트 코드와 제품 코드가 혼재되면 유지비 증가 e.g. private readonly bool _isTestEnvironment 테스트 코드를 제품 코드 베이스와 반드시 분리하자! (운영용 진짜 구현체 & 테스트용 가짜 구현체) e.g. ILogger 인터페이스 -> Logger (운영용), FakeLogger (테스트용) 이 상황의 인터페이스도 일종의 코드 오염이지만, 오염도가 낮고 다루기 쉬움 안티 패턴 5: 구체 클래스를 목으로 처리하기 인터페이스가 아닌 구체 클래스로 목으로 처리할 경우 단일 책임 원칙 위배되는 시나리오일 가능성 여러 책임이 합쳐진 클래스일 가능성을 의심하고 분리하자! 안티 패턴 6: 앰비언트 컨텍스트로서의 시간 처리 시간을 정적 메서드 혹은 필드로 참조하는 것 -> 테스트가 더 어려움 (공유 의존성 발생) 더 나은 방안: 시간을 명시적 의존성으로 주입하기 컨트롤러에는 시간 관련 인스턴스를 전달 (클래스는 메서드에서 시간 반환) 도메인 클래스에는 시간을 값으로 전달 Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-22
단위 테스트 (Unit Testing) - 가치 있는 테스트 식별하기
좋은 단위 테스트의 4대 요소 좋은 단위 테스트의 4가지 특성 회귀 방지 (=소프트웨어 버그 방지) 중요 지표: 테스트로 실행되는 코드의 양, 코드 복잡도, 코드의 도메인 유의성 복잡도와 도메인 유의성이 높은 코드에 대한 테스트가 많을수록 회귀 방지가 탁월 리팩터링 내성 테스트 실패없이 애플리케이션 코드 리펙토링 가능한지에 대한 척도 중요지표: 거짓 양성 발생량 (적을수록 좋음) 거짓 양성: 리팩토링 후 기능이 의도대로 작동해도 테스트가 실패하는 상황 (허위 경보) 회귀 발생 시 조기 경고를 제공 X (잘못된 것이므로 개발자가 무시) 리팩토링에 대한 능력과 의지 감소 (테스트 스위트에 대한 신뢰가 부족) 거짓 양성의 원인: SUT의 구현 세부 사항과 결합된 테스트 (분리 필요) 해결책: 테스트에서 구현 세부사항이 아닌 최종 결과를검증하기 결합도를 낮추면 리팩토링 내성 상승 거짓 양성 발생량이 크게 감소 거짓 양성에 대한 올바른 대응은 테스트 스위트의 안정성을 높이는 것 빠른 피드백 중요 지표: 테스트 실행 속도 빠른 테스트는 버그 수정 비용이 대폭 감소 (더 많은 테스트를 자주 실행할 수 있음) 느린 테스트는 버그 수정 비용이 상승 (뒤늦게 버그를 발견, 시간 낭비) 유지 보수성 중요 지표: 유지비 (테스트 이해 난이도, 테스트 실행 난이도) 테스트 이해 난이도: 테스트의 크기를 의미 (코드라인이 적을수록 읽기 쉬움) 테스트 실행 난이도: 테스트가 프로세스 외부 종속성으로 작동하면, 의존성 운영 비용 고려 필요 회귀 방지 & 리팩터링 내성 간 관계 올바른 추론: 올바르게 작동해 테스트가 통과 & 기능이 고장나 테스트가 실패 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도 극대화를 목표로하는 특성 테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수) 거짓 양성, 거짓 음성 발생 확률 줄이기 -> 테스트 정확도 상승 회귀 방지가 훌륭한 테스트는 거짓 음성 수를 최소화 리팩터링 내성이 훌륭한 테스트는 거짓 양성 수를 최소화 중대형 프로젝트는 거짓 음성과 거짓 양성에 똑같이 주의를 기울여야 함 프로젝트 초반은 리팩토링이 많지 않아 거짓 양성은 무시할만 함 프로젝트 중후반으로 갈수록 리팩토링이 중요한데, 거짓 양성이 잦으면 문제가 커짐 테스트 전략 테스트의 가치 = 회귀 방지 X 리팩터링 내성 X 빠른 피드백 X 유지 보수성 하나라도 0이면 전체가 0 (모두 1도 불가능) 유지보수성은 다른 특성과 독립적 (엔드 투 엔드 테스트에서만 회귀 방지와 연관됨) 회귀 방지, 리팩토링 내성, 빠른 피드백은 상호 배타적 -> 하나를 희생해야 둘이 최대 가능 회귀 방지 희생 -> 너무 간단한 테스트 리팩토링 내성 희생 -> 구현에 결합된 깨지기 쉬운 테스트 빠른 피드백 희생 -> 엔드 투 엔드 테스트 각 요소에 높은 임계치를 두고 이를 충족하는 테스트만 테스트 스위트에 남기기 소수의 매우 가치 있는 테스트가 프로젝트의 지속적 성장에 효과적 전략적 절충 리팩토링 내성은 최대화 필요 (리팩토링 내성은 대부분 있거나 없거나 둘 중 하나이므로…) 회귀 방지와 빠른 피드백 사이에서 조절하자 테스트 피라미드 관점 전략 테스트 유형 간 비율은 피라미드 형태를 유지할 것 (팀, 프로젝트 마다 비율 차이 O) 모든 테스트 계층은 가능한 거짓 양성 최소화 목표 (리팩토링 내성 최대화) 피라미드 내 테스트 유형에 따라 회귀 방지와 빠른 피드백 사이에서 선택함 엔드 투 엔드 테스트는 매우 중요한 기능에만 적용 빠른 피드백과 유지보수성 결여 -> 숫자가 가장 적은 이유 예외 케이스 복잡도가 거의 없는 기본 CRUD 프로젝트 통합 테스트 수가 단위 테스트 수와 같거나 많고 엔드 투 엔드 테스트가 없음 단위 테스트는 복잡도 없는 환경에서 유용성 감소 통합 테스트는 여전히 시스템 간 통합 동작 확인에 가치 있음 프로세스 외부 의존성 하나만 연결하는 API (e.g. DB) 엔드 투 엔드 테스트를 더 많이 두는 것이 적합 (환경 상 통합 테스트와 구분 불가) 속도가 상당히 빠를 것이고 유지비도 적음 블랙 박스 테스트 & 화이트 박스 테스트 전략 둘을 조합하되 테스트 작성 시 블랙 박스 테스트 선택하자 화이트 박스 테스트는 구현에 결합 -> 리팩토링 내성 포기할 수는 없음! 테스트 분석 시 화이트 박스 테스트 사용! (e.g. 코드 커버리지 도구) 목과 테스트 취약성 테스트 대역(test double) 모든 유형의 비운영용 가짜 의존성 e.g. 더미, 스텁, 스파이, 목, 페이크 사용 의도에 따라 목과 스텁으로 나뉨 (Mock 프레임워크로 똑같이 인스턴스를 생성) 목(mock) - 목, 스파이 외부로 나가는 상호 작용을 모방하고 검사 상태 변경을 위해 의존성을 호출하는 것 (사이드 이펙트 O) e.g. SMTP 서버로 이메일 발송 작업 CQS 관점에서 명령을 대체 (보통 반환값 X) 구현 목: 목 프레임워크의 도움 받아 생성 스파이: 수동으로 작성한 목 스텁(stub) - 스텁, 더미, 페이크 내부로 들어오는 상호 작용을 모방만 함 입력 데이터를 얻기 위해 의존성을 호출하는 것 (사이드 이펙트 X) e.g. DB로 부터 데이터 검색 CQS 관점에서 조회를 대체 (보통 반환값 O) 구현 더미: 단순 하드코딩 값 (null, 가짜 문자열) 스텁: 더 정교하게 시나리오마다 다른 값 반환하는 의존성 페이크: 스텁과 같지만, 아직 존재하지 않는 의존성을 대체하고자 구현 무분별한 목 사용 지양하기 (feat. 리팩토링 내성 감소) API를 잘 설계하면 단위테스트도 자동으로 좋아짐 식별할 수 있는 동작만 공개하고 구현 세부사항을 비공개함으로써 리팩토링 내성 상승 스텁의 상호작용은 검증하지 말자! (안티패턴) 입력을 제공할 뿐이지 SUT의 최종 결과가 아님 스텁의 상호작용 검증은 내부 구현 세부사항과 결합(overspecifiation) -> 리팩토링 내성 감소 목의 상호작용 검증은 최종 결과 검증 e.g. mock.Verify(x => x.SendGreetingsEmail("user@email.com")) -> O stub.Verify(x => x.GetNumberOfUsers(), Times.Once) -> X 사이드 이펙트가 있는 시스템 간 통신은 목으로 테스트하자! (외부 애플리케이션 통신) 클래스 간 통신에도 목을 쓰는 것은 런던파의 단점 가치 있는 목 테스트 var mock = new Mock<IEmailGateway>() mock.Verify(x => x.SendReceipt("..@x.com","egg",5), Times.Once) 클라이언트 목표 달성에 도움이 되는 연산 잘못된 목 테스트 var storeMock = new Mock<IStore>() storeMock.Verify(x => x.RemoveInventory("egg", 5), Time.Once) 시스템 내 통신(도메인 간 통신)은 클라이언트 목표로 가는 중간 단계 (구현 세부 사항) 애플리케이션을 통해서만 접근할 수 있는 프로세스 외부 의존성은 목 대체 X 모든 공유 의존성을 목으로 대체하는 것은 고전파의 단점 외부 클라이언트 관점에서 접근 불가한 시스템은 구현 세부 사항 e.g. 데이터베이스 식별할 수 있는 동작과 공개 API 모든 제품 코드는 2차원으로 분류할 수 있다. 공개 API (public) & 비공개 API (private) 식별할 수 있는 동작과 구현 세부 사항 식별할 수 있는 동작은 클라이언트가 목표를 달성하는데 도움이 되는 연산(Operation)과 상태(State)를 최소한으로 노출한다. (연산은 계산 수행 혹은 사이드 이펙트를 초래하는 메서드를 의미) 구현 세부사항은 두 가지 중 어떤 것도 하지 않는다. 잘 설계된 API는 식별할 수 있는 동작은 공개 API와 일치하고, 모든 구현 세부 사항은 비공개 API 뒤에 숨어 있다. 만일, 식별할 수 있는 동작을 달성하고자 할 때 클래스에서 호출해야 하는 연산 수가 1보다 크면 해당 클래스는 구현 세부 사항을 유출했을 가능성이 크다. 또한, API를 잘 설계하면 단위테스트도 자동으로 좋아진다. (리팩토링 내성 상승) 장기적으로 캡슐화는 증가하는 복잡성에 대응하고 소프트웨어의 지속적 성장을 가능하게 하는 유일한 방법이다. 헥사고날 아키텍처(Hexagonal Architecture, Alistair Cockburn) - 애플리케이션 서비스 + 도메인 - 도메인 계층 (도메인 지식) - 비즈니스 로직 책임 - 애플리케이션 서비스 계층 (유스케이스) - 외부 환경과의 통신을 조정 (SMTP, 메시지 버스, 서드파티…) - 잘 설계된 API는 프랙탈 특성 존재 - 서로 다른 계층의 테스트도 동일한 동작을 서로 다른 수준으로 검증하는 프랙탈 특성 존재 - 목표(유스 케이스) - 하위 목표 - … Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-13
단위 테스트 (Unit Testing) - 단위 테스트의 목표와 구조
단위 테스트의 목표 단위 테스트의 목표: 소프트웨어 프로젝트의 지속 가능한 성장 버그 없이 주기적으로 리팩토링하고 새로운 기능을 추가할 수 있도록 지원 테스트 없는 프로젝트 초기 개발 속도가 빠름 -> 시간이 갈수록 엔트로피(시스템 내 무질서도) 증가 및 개발 속도 감소 테스트 있는 프로젝트 초반에 상당한 노력이 들어감 -> 프로젝트 후반에도 안정적으로 잘 성장 단위 테스트 적용은 필수이고 논쟁거리가 아님 테스트는 코드베이스의 일부 애플리케이션의 정확성을 보장하는 책임을 가진 코드 현재의 논쟁: 좋은 단위테스트는 어떤 것인가? 개발 주기에 통합되어 있는 것 매 배포 전 테스트 실행 코드베이스에 가장 중요한 부분을 대상으로 하는 것 핵심인 도메인 모델을 다른 것과 구분해 테스트 최소한의 유지비로 최대 가치를 끌어내는 것 고품질 테스트는 동작의 단위를 검증하는 것 (비즈니스 로직 테스트) 식별할 수 있는 동작은 테스트하고 구현 세부사항은 테스트 X 필요 사항 가치 있는 테스트 식별하기 가치 있는 테스트 작성하기 테스트 스위트 품질 측정 방법 커버리지 지표 테스트 스위트가 소스 코드를 얼마나 실행하는지 백분율로 표현 중요한 피드백을 줄 순 있지만 테스트 스위트 품질 측정에 부적합 커버리지가 낮으면 테스트가 충분치 않다는 좋은 증거 시스템의 핵심 부분은 커버리지를 높게 두는게 좋음 하지만 커버리지가 높다고 품질을 보장하지는 못함 모든 결과의 검증을 보장하지 못함 (100%여도 빠져나가는 케이스들이 있음) 외부 라이브러리는 경로 검증 불가 지표로만 보고 목표로 삼아서는 안됨!! 특정 커버리지를 목표로하면 개발자들은 시스템을 속일 방법을 궁리하는 부작용 발생 종류 코드 커버리지(code coverage, test coverage) 코드 커버리지 = 실행 코드 라인 수 / 전체 라인 수 커버리지 숫자는 조작이 가능,,, e.g. return input.Length() > 5 //input='abc', 100% 커버리지 분기 커버리지(branch coverage) 분기 커버리지 = 통과 분기 / 전체 분기수 코드 커버리지를 조금은 보완 e.g. return input.Length() > 5 //input='abc', 50% 커버리지 회귀(=소프트웨어 버그) 코드 수정 후 기능이 의도한 대로 작동하지 않는 경우를 의미한다. 단위 테스트란? 단위 테스트의 속성 작은 코드 조각(Unit)을 검증 빠르게 수행 격리된 방식으로 처리하는 자동화된 테스트 (쟁점) 격리가 무엇인지에 대한 의견 차이가 근본적으로 고전파와 런던파를 가름 단위 테스트 접근 방식에 대한 분파 고전파 (Classical School, Detroit) - 지향 원론적인 접근 추구 상향식 TDD (도메인 모델부터 시작) 상태를 검증 (e.g. 고객이 상점을 통해 구매하면 상점의 재고 차감 여부를 검증) 격리 방식에 대한 관점: 단위 테스트끼리 격리 테스트는 적합한 순서(순차적 or 병렬적)로 실행 가능하며 서로의 결과에 영향 X 공유 의존성에 대해서만 테스트 대역 사용 단위 테스트 간 공유되는 의존성은 테스트 대역으로 교체 싱글톤 객체 같은 경우 각 테스트마다 생성하면 테스트 간 공유 X 설정 파일도 각 테스트마다 생성자 주입하는 방식으로 가능 공유 의존성 = 프로세스 외부 의존성 실무에서 예외 케이스가 거의 없음! 예외 상황: 읽기 전용 외부 API (불변 의존성이므로 공유 의존성 X) 테스트 속도와 안정성을 위해 테스트 대역으로 교체 권장 빠르고 안정적이라면 원칙에 맞춰 그대로 사용해도 괜찮 교체 시 테스트 속도 상승 (단위 테스트의 2번째 요건 충족) 공유 의존성은 대부분 프로세스 외부에 있어 호출이 느림 외부 공유 의존성 테스트는 보통 통합 테스트의 영역 코드 조각 범위 (Unit): 공유 의존성만 없으면 여러 클래스 묶어 테스트 가능 통합 테스트: 단위 테스트 정의를 충족하지 않는 테스트 고전파 관점의 단위 테스트 정의 단일 동작 단위를 검증하고 빠르게 수행하고 다른 테스트와 별도로 처리한다. e.g. 둘 이상의 동작 단위 검증 테스트, 다른 팀 개발 코드와 통합해 검증하는 테스트, 프로세스 외부 의존성, 공유 의존성 장점 단위 테스트 목표 달성에 적합 -> 동작의 단위 검증에 유용 단점 SUT가 올바르게 동작하더라도 협력자에 버그에 있는 경우 테스트 실패 런던파 (London School, Mockist) 런던의 프로그래밍 커뮤니티에서 시작 하향식 TDD (상위 레벨 테스트부터 시작) 상호작용을 검증 (e.g. 목 객체의 메서드가 올바르게 호출되었는지, 호출 횟수는 맞는지…) 격리 방식에 대한 관점: 테스트 대상 시스템에서 협력자를 격리 한 클래스의 의존성을 모두 테스트 대역(test double)으로 대체 (불변 의존성은 그대로) e.g. 인터페이스를 통해 목 객체를 만들고 SUT에 인자로 넘김 코드 조각 범위 (Unit): 단일 클래스 혹은 해당 클래스 내 메서드 통합 테스트: 실제 협력자 객체를 사용하는 모든 테스트 장점 테스트가 실패하면 테스트 대상 시스템이 고장난 것이 확실해짐 (테스트가 세밀) 중요성 떨어짐 (정기적으로 테스트 실행하면 고장난 부분 좁히기 쉬움) 테스트 준비 시 복잡한 의존성 조립을 피하고 대역 하나로 대체 가능 e.g. 한 번에 한 클래스만 테스트 -> 전체 단위 테스트 구조 간단해짐 중요성 떨어짐 (복잡한 의존성은 잘못된 설계이므로 설계를 바꿔야 함) 단점 목을 다루는 것은 불안정함 내포 테스트가 SUT의 구현 세부 사항에 빈번히 결합 테스트 대상 시스템(SUT, System Under Test) & 협력자(Collaborator) SUT는 현재 테스트에 대상이 되는 시스템을 의미한다. 테스트 대상 메서드는 MUT(Method Under Test)라고도 한다. 반면에 협력자는 불변 의존성(값 객체)을 제외한 시스템에 엮여 있는 모든 의존성들을 의미한다. 즉, 일반적인 클래스는 협력자와 값 객체로 2가지 유형의 의존성으로 동작할 수 있다. 의존성 종류 공유 의존성(shared dependency) 테스트 간 공유되고 서로의 결과에 영향을 미칠 수 있는 의존성 e.g. 정적 가변 필드, 데이터베이스 비공개 의존성(private dependency) 테스트 간 공유하지 않는 의존성 프로세스 외부 의존성(out-of-process dependency) 애플리케이션 프로세스 외부에서 실행되는 의존성. 대부분 공유 의존성이지만 아닌 경우도 있다. e.g. 데이터베이스는 외부 의존성이면서 공유 의존성인 반면, 테스트 실행 전 도커 컨테이너로 시작한 데이터베이스는 외부 의존성이면서 비공개 의존성 읽기 전용 API처럼 프로세스 외부 의존성이지만 불변 의존성이어서 공유 의존성이 아님 엔드 투 엔드 테스트 (end-to-end test) 시스템을 최종 사용자 관점에서 검증하는 것을 의미한다. (동의어: UI 테스트, GUI 테스트, 기능 테스트) 엔드 투 엔드 테스트는 통합 테스트의 일부다. 둘 모두 코드가 프로세스 외부 의존성과 함께 어떻게 작동하는지 검증한다. 다만, 엔드 투 엔드 테스트가 일반적으로 의존성을 더 많이 포함한다. (통합 테스트가 프로세스 외부 의존성이 1~2개, 엔드 투 엔드 테스트는 전부 혹은 대다수) 예를 들어, DB, 파일 시스템, 결제 게이트 웨이라는 3가지 프로세스 외부 의존성이 있다면, 보통의 통합 테스트는 완전히 제어 가능하지 않은 결제 게이트 웨이만 테스트 대역으로 대체하는 반면에, 엔드 투 엔드 테스트는 전부 테스트에 포함한다. 통합 테스트와 엔드 투 엔드 테스트의 뚜렷한 경계는 없다. 테스트 버전이 없거나 자동으로 가져오는 것이 불가능한 프로세스 외부 의존성의 경우 엔드 투 엔드 테스트 역시 테스트 대역을 사용해야 한다. 또한, 엔드 투 엔드 테스트는 유지보수 측면에서 가장 비용이 많이 들기 때문에 모든 단위 테스트와 통합 테스트가 통과한 후 빌드 프로세스 후반에 실행하는 것이 좋다. 단위 테스트 구조 AAA 패턴 (Arrage, Act, Assert) 준비, 실행, 검증패턴으로 테스트하는 일반적인 방식 단순하고 균일한 구조를 만들어 가독성과 유지보수성이 향상 Given, When, Then은 비기술자에게 조금 더 읽기 쉬운 점말고 AAA와 차이가 없음 단위 테스트 구조에 대한 지침 한 테스트에 여러 개의 준비, 실행, 검증 구절 -> 여러 테스트로 나눠라! 여러 구절 = 테스트가 여러 개의 동작 단위를 한 번에 검증 = 통합 테스트 테스트 내 if 문 피하자 -> 여러 테스트로 나눠라! 분기가 있다는 것 역시 한 번에 너무 많은 것을 검증한다는 표시 단위 테스트 든 통합 테스트 든 테스트에 분기가 있어서 얻는 이점은 없음 (가독성만 감소) 각 구절의 적정 크기 준비 구절: 세 구절 중 가장 큼 많이 크다면 테스트 내 비공개 메서드, 별도 팩토리 클래스 추출해 재사용 실행 구절: 한 줄 (이상적) 두 줄 이상인 경우 SUT API 설계 문제 의심 -> 캡슐화 지키기 e.g. 테스트 내 Purchase(), RemoveInventory() 각각 실행-> 캡슐화가 깨짐 단, 유틸리티나 인프라 코드는 덜 적용되므로, 절대 두줄 이상 두지 말라고 할 수는 없음! 검증 구절: 여러 검증이 있을 수 있지만 너무 커지는 것을 경계 equals() 정의해 객체 끼리 Assert문 한번에 검증하는 것이 좋음 참고) 종료 구절: 리소스 정리 목적으로 통합테스트에 주로 쓰임 (메서드 추출해 재사용) SUT 구분하기 SUT는 동작에 대한 유일한 진입점 테스트 내 SUT 이름을 sut로 명명해 구분하자 구절 주석 지침 AAA 패턴 따르고 주석 없이 빈 줄로 각각 구절을 3등분해 구분 각 구절 내에 빈 줄이 있다면, 주석 추가하기 (//arrage, //act, //assert) 테스트 픽스처 재사용하기 테스트 픽스처: 단위 테스트를 수행할 때 필요한 초기 상태나 설정 e.g. 계좌 잔고 확인 테스트를 위한 초기 입금 설정 작업 준비 구절 코드 재사용은 좋은 방법 픽스처 재사용 방법 권장: 테스트 클래스에 비공개 팩토리 메서드 두자 (가독성, 재사용성 향상) CustomerTests -> CreateStoreWithInventory(), CreateCustomer() 안티 패턴: 테스트 클래스 생성자에서 픽스처 초기화 e.g. _store.AddInventory(Product.Shampoo, 10) 테스트 간 결합도 상승 및 가독성 감소 테스트마다 재고 변경을 다르게 설정 하고 싶어도 결합되어 어려움 (공유 상태) 생성자 재사용의 유일한 예외: 테스트 대부분에 사용되는 픽스처 기초 클래스를 두고 생성자에서 초기화한 후 개별 테스트 클래스에서 상속해 재사용 e.g. 통합 테스트 시 DB 커넥션 초기화 CustomerTests가 DB 커넥션이 있는 IntegrationTests를 상속받음 단위 테스트 명명법 Best Practice 표현력이 있는 간단하고 쉬운 영어 구문 (엄격하지 않게 표현의 자유 허용) 장황한 표현 지양 e.g. considered X 사실만 서술하고 소망이나 욕구 지양 e.g. should be X -> is O 기초 영문법 지키기 e.g. 관사 도메인에 익숙한 비개발자에게 설명하듯이 이름 짓기 (도메인 전문가, 비즈니스 분석가) 동작으로 이름 짓기 테스트 이름에 메서드 이름 넣지 말기 예외: 유틸리티 코드는 메서드 이름 사용해도 괜찮 (비즈니스 로직 X) 테스트 클래스 이름 지정: [클래스명]Tests 동작 단위 검증의 진입점 역할 해당 클래스만 검증한다는 것이 아님 -> 여러 클래스 걸쳐도 동작을 검증 _ 로 단어 구분 (가독성 향상) e.g Sum_of_two_numbers() Delivery_with_a_past_date_is_invalid 안티 패턴: [테스트 대상 메서드]_[시나리오]_[예상 결과] 타인이 읽기 난해하고 구현 세부사항에 묶임 e.g. Sum_TwoNumbers_ReturnsSum() IsDeliveryValid_InvalidDate_ReturnsFalse() 매개변수화된 테스트 리팩토링 하기 (Parameterized Test) 유사한 테스트를 묶을 수 있는 기능 제공 하나의 동작은 여러 테스트가 필요하고 복잡하면 테스트 수가 급증하므로 관리에 용이 사용 지침 입력 매개변수만으로 테스트케이스 판단이 가능하면, 하나의 테스트 메서드 사용 매개변수만으로 판단이 어렵다면 긍정 테스트 케이스와 부정 테스트 케이스 나누기 동작이 너무 복잡하면 매개변수화된 테스트 사용말고 모두 개별 테스트로 두기 e.g. 가장 빠른 배송일이 오늘부터 이틀 후가 되도록 작동하는 배송 기능 4가지(어제, 오늘, 내일, 모레) 테스트가 필요하지만 유일한 차이점은 배송 날짜 Parameterized Test로 묶기 하나의 테스트 메서드 두기 Can_detect_an_invalid_delivery_date() [(-1, false), (0, false), (1, false), (2, true)] 긍정 테스트와 부정 테스트 나누기 (boolean 매개변수 제거 효과) 긍정: The_soonest_delivery_date_is_two_days_from_now() 2 부정: Detects_an_invalid_delivery_date() [-1, 0, 1] 검증문 라이브러리 사용하기 쉬운 영어로 구성된 이야기 패턴으로 테스트 가독성 향상 유일한 단점은 프로젝트에 의존성 추가하는 것 Assert.equal(30, result) -> result.Should().Be(30) Reference 단위 테스트 (생산성과 품질을 위한 단위 테스트 원칙과 패턴)
Software Engineering
· 2024-11-11
<
>
Touch background to close