인프런 워밍업 스터디 클럽 2기 백엔드(클린코드, 테스트코드) 3주차 발자국
이 블로그 글은 박우빈님의 강의를 참조하여 작성한 글입니다.
어느덧 벌써 워밍업 클럽이 막바지로 가고 있는 것 같다. 워밍업 클럽을 참여 전의 나보다 많이 성장했는가를 항상 발자국 쓸때 돌이켜 물어보는 것 같다. 과연 성장을 했을끼? 나는 당당히 성장을 하였다고 생각을 한다. 해당 스터디를 통해 나의 생활도 지식도 성장이 되었다 생각하며 해당에 대한 물음은 워밍업 클럽 수료 후에 다시 되물음을 해보겠다.
이번 주차에서는 이제 Readable Code 강좌가 완강이 되고 Practical Testing 강좌를 시작하는 주차다. 이번주도 열심히 달려본 내역들을 작성해보겠다.
강의소개
이 강좌는 테스트가 처음이거나 테스트 코드는 들어봤거나 작성하려고 시도를 해본 경험이 있는등 테스트가 궁금한 모든 분들을 위해 나온 강의이다. 나도 해당 테스트를 어떻게 하면 잘 작성할지가 궁금하여 이 강좌를 듣게 되고 해당 워밍업 클럽을 참여하게 된 이유이기도 하다.
테스트를 작성하는 역량은 채용시장에서 주니어 개발자에게 기대하는 요소 중 하나다. 채용시 구현과제 등에서 테스트 작성여부, 테스트 코드 구현방식을 확인한다. 또한 소프트웨어의 품질을 보장하는 방법으로 그 중요성을 알고 있는지도 확인을 하기도 한다고 한다.
이번 강좌에서는 다음과 같은 목표를 두고 학습을 진행한다고 한다.
📚 목표
1. 테스트 코드가 필요한 이유
2. 좋은 테스트 코드란 무엇일까?
3. 실제 실무에서 진행하는 방식 그대로 테스트를 작성해가면서 API를 설계하고 개발하는 방법
4. 정답은 없지만 오답은 존재한다. 구체적인 이유에 근거한 상세한 테스트 작성 팁
벌써부터 많은 기대를 품으며 다음 강의로 바로 가봐야 겠다.
어떻게 학습하면 좋을까?
효과적인 학습을 하기 위해 가장 먼저 선행되어야 하는 것은 바로 무엇을 모르는지 아는 것이다. 무엇을 모르는 지 아는것은 찾아볼 수 있게 된다는 것이다.
우리는 학습을 하면서 이 부분은 완벽히 아는 부분, 이 부분은 반만 아는 부분, 이 부분은 처음 들어보는 부분으로 구분된다. 그래서 강좌에서 함계 학습한 키워드와 추가 학습을 위한 키워드를 분리하여 키워드 기반으로 정리를 해주신다고 하니 많은 기대를 가지며 다음 강의부터 본격적으로 달려 볼 예정이다.
테스트는 왜 필요할까?
기술 학습에 있어서 '왜?'가 중요하다. 테스트하면 생각나는게 무엇일까? 나는 처음 테스트코드를 볼때 굳이 해야하나? 개발시간만 더 늘릴뿐일텐데라는 생각을 하였다.
그런데 만약 테스트코드가 없이 실제 인간이 수동으로 테스트를 하면 매우 큰 문제들을 야기할 수 있다. 인간은 실수의 동물이기 때문이다. 또한 만약 기능을 개발할때 기존 기능을 건들게 된다면 기존 기능도 다시 테스트를 하는 시간낭비가 발생한다.
✅ 테스트 작성을 안하면?
1. 커버할 수 없는 영역 발생
2. 경험과 감에 의존
3. 늦은 피드백
4. 유지보수 어려움
5. 소프트웨어에 대한 신뢰가 떨어딘다.
테스트 코드를 작성하지 않으면?
변화가 생기는 매 순간마다 발생할 수 있는 모든 case를 고려해야한다.
변화가 생기는 매 순간마다 모든 팀원이 동일한 고민을 해야한다.
빠르게 변화하는 소프트웨어의 안정성을 보장할 수 없다.
테스트코드가 병목이 된다면?
프로덕션 코드의 안정성을 제공하기 힘들어진다.
테스트 코드 자체가 유지보수하기 어려운 새로운 짐이 된다.
잘못된 검증이 이루어질 가능성이 생긴다.
올바른 테스트 코드
자동화 테스트로 비교적 빠른 시간 안에 버그를 발견할 수 있고 수동 테스트에 드는 비용을 크게 절약할 수 있다.
소프트웨어의 빠른 변화를 지원한다.
팀원들의 집단 지성을 팀 차원의 이익으로 승격시킨다.
가까이 보면 느리지만 멀리보면 빠르다.
샘플 프로젝트 소개 & 개발 환경 안내
해당 테스트 섹션에서는 카페 키오스크 시스템을 만들면서 테스트 학습을 할 예정이다.
🛠 개발환경
- IntelliJ Ultimate
- Vim(Plugin)
프로젝트 세팅
인텔리제이를 활용하여 스프링부트 프로젝트를 생성하고 build.gradle의 의존성 정리를 하였다.
수동테스트 VS. 자동화된 테스트
요구사항
주문목록에 음료 추가/삭제 기능
주문목록에 전체 지우기
주문목록 총 금액 계산하기
주문 생성하기
해당 부분을 토대로 콘솔기반 비즈니스 로직을 작성하였고 테스트 강의이니 해당 로직을 테스트 하기 위해 이 중 음료 추가에 대한 로직을 아래와 같이 작성했다.
@Test
void add() {
CafeKiosk cafeKiosk = new CafeKiosk();
cafeKiosk.add(new Americano());
System.out.println(">>> 담긴 음료 수: " + cafeKiosk.getBeverages().size());
System.out.println(">>> 담긴 음료: " + cafeKiosk.getBeverages().get(0).getName());
}
위의 코드를 봤을 때 이렇게 테스트코드를 짜면 안된다고 직감을 했을 것이다. 왜냐하면 일단 최종단계에서 사람이 개입하고 어떤게 맞고 어떤게 틀리는지 모른다는 것이다. 또한 이 테스트는 100% 성공하는 케이스이기 때문에 뭔가 테스트라고 하기에 모호한것 같다.
JUnit5로 테스트하기
단위테스트
작은 코드 단위를 독립적으로 검증하는 테스트
검증속도가 빠르고 안정적이다.
Junit5
단위 테스트를 위한 테스트 프레임워크
AssertJ
테스트 코드 작성을 원활하게 돕는 테스트 라이브러리
풍부한 API, 메서드 체이닝 지원
해당 지식을 기반으로 우리가 이전 시간에 작성한 아메리카노부분과 카페머신의 대한 단위 테스트를 AssertJ를 이용하여 작성해보는 시간을 가졌다.
테스트 케이스 세분화하기
스스로에게 질문해보자. 암묵적이거나 아직 드러나지 않은 요구사항이 있는지를 확인해보자. 그리고 해피케이스와 예외케이스를 둘다 생각하며 항상 경계값 테스트를 해보자.
경계 값은 범위(이상, 이하, 초과, 미만), 구간, 날짜등을 일컫는다.
그래서 우리는 음료에 여러잔을 담는 기능을 개발하고 해당 부분의 해피케이스에 관한 테스트를 작성했다. 또한 예외 케이스를 생각해 로직을 작성하고 해당 예외케이스에 대한 로직을 작성하게 되었다.
테스트하기 어려운 영역을 분리하기
테스트하기 어려운 영역은 다음과 같다.
관측할 때마다 다른 값에 의존하는 코드
현재 날짜/시간, 랜덤 값, 전역변수/함수, 사용자 입력 등
외부세계에 영향을 주는 코드
표준출력, 메세지 발송, 데이터베이스 기록
그래서 우리는 실습으로 주문을 생성할때 가게 영업시간이 아닐시, 주문을 못하게 하는 상황의 로직을 작성했고 테스트코드 작성 시 문제가 생겼다. 내가 현재 새벽에 테스트코드를 돌렸고 영업시간 전이기에 테스트코드가 실패한것이다. 결국 이 부분은 날짜를 파라미터로 받게 변경하여 해결하였다.
📚 순수함수
- 같은 입력에는 같은 결과
- 외부세상과 단절된 형태
- 테스트하기 쉬운 코드
TDD: Test Driven Development
프로덕션 코드보다 테스트 코드를 먼저 작성하여 테스트가 구현과정을 주도하도록 하는 방법론
Red: 실패하는 테스트 작성
Green: 테스트 통과하기 위한 최소한의 코딩
Refactor: 구현코드 개서느 테스트 통과 유지
피드백
TDD는 빠르게 피드백을 자동으로 받을 수 있다.
선 기능 후 테스트 작성
테스트 자체의 누락 가능성
특정 테스트 케이스(해피 케이스)만 검증할 가능성이 크다.
잘못된 구현을 다소 늦게 발견할 가능성이 있다.
선 테스트 후 기능 작성
복잡도가 낮은 테스트 가능한 코드로 구현할 수 있게 된다.
유연하며 유지보수가 쉬운
쉽게 발견하기 어려운 엣지 케이스를 놓치지 않게 해준다.
구현에 대한 빠른 피드백 가능
과감한 리팩토링이 가능
클라이언트 관점에서의 피드백을 주는 Test Driven
테스트는 []다.
테스트는 무엇일까? 테스트는 문서라고 볼 수 있다.
프로덕션 기능을 설명하는 테스트 코드 문서
다양한 테스트 케이스를 통해 프로덕션 코드를 이해하는 시각과 관점을 보완
어느 한 사람이 과거에 경험했던 고민의 결과물을 팀 차원으로 승격시켜서 모두의 자산으로 공유할 수 있다.
DisplayName을 섬세하게
@DisplayName
을 사용하여 테스트 명을 구체화할때 명사의 나열보단 문장으로 작성하는 것이 좋다. 또한 테스트 행위에 대한 결과를 기술하는데 도메인 용어를 사용하여 매서드 자체의 관점보다 도메인 정책 관점으로 한층 추상화된 내용을 담는것이 좋다. 마지막으로 테스트의 현상을 중점으로 기술하지 말자. 예를 들어 ~실패라기 보단 도메인의 내용을 담는것이 좋을 것 같다.
BDD(Behavior Driven Development) 스타일로 작성하기
TDD에서 파생된 개발방법
함수단위의 테스트에 집중하기보다 시나리오에 기반한 테스트 케이스(TC) 자체에 집중하여 테스트
개발자가 아닌 사람이 봐도 이해할 수 있을 정도의 추상화 수준(레벨)을 권장
Given / When / Then
Given: 시나리오 진행에 필요한 모든 준비 과정(객체, 값, 상태 등)
When: 시나리오 행동 진행
Then: 시나리오 진행에 대한 결과 명시 및 검증
어떤환경에서(Given) 어떤 행동을 진행했을 때(When) 어떤 상태 변화가 일어난다.(Then)라는것을 토대로 @DisplayName을 상세히 적을 수 있다.
미션
이번 미션은 저번에 Readable Code에서 진행했던 마지막 과제 프로젝트인 '지뢰찾가', '스터디카페'중에 1개의 프로젝트를 가지고 테스트 코드를 작성해보는 시간을 가졌다. 조건은 BDD스타일로 3개이상의 클래스 총 7개 이상 테스트를 작성하는 것이었지만 나는 한층 공부한다는 마음으로 테스트 커버리지 툴인 jacoco를 가지고 스터디 카페부터 진행을 하였다. 그 결과 테스트 커버리지 98%라는 결과를 가지게 되었다. 그리고 조금 더 욕심이 나서 지뢰찾기도 일부 클래스를 진행하였다. 이번 미션을 해보면서 어려웠고 힘들었지만 테스트 작성에 많이 익숙해진 경험을 가지게 되었다.
레이어드 아키텍쳐(Layered Architecture)와 테스트
레이어드 아키텍쳐에서는 아래와 같이 구성되어 있다.
- Persentation Layer
- Business Layer
- Persistence Layer
이렇게 레이어를 나눈 이유는 관심사의 분리때문일 것이다. 책임을 나눔으로서 유지보수성을 쉽게 가져가기 위함이다.
🙋🏻 테스트 하기 어려워보여요!
그렇게 보일 수는 있겠지만 앞선것과 기조는 비슷하다. 즉, 테스트하기 어려운 걸 분리하여 테스트하고자 하는 영역을 집중하며 명시적이고 이해할 수 있는 문서형태로 테스트 작성하는 것은 어떤 아키텍쳐든 동일하다.
A와 B라는 모듈이 있다고 하자. 이 두 모듈을 더했을 때 뭐가 나올까? AB? BA? C? 누구도 예측하기 힘들다. 그래서 우리는 통합 테스트의 필요성이 느껴질 것이다.
통합 테스트
여러 모듈이 협력하는 기능을 통합적으로 검증하는 테스트
일반적으로 작은 범위의 단위테스트만으로는 기능 전체의 신뢰성 보장X
풍부한 단위 테스트 & 큰 기능 단위를 검증하는 통합 테스트의 조화가 필요.
Spring / JPA 훑어보기 & 기본 엔티티 설계
Spring
스프링을 애기하면 먼저 라이브러리와 프레임워크의 차이를 묻는다. 라이브러리 같은 경우는 내 코드가 주최가 된다. 즉, 필요한 기능이 있다면 외부에 끌어와서 사용을 하는데 이게 라이브러리다. 반면 프레임워크는 이미 프레임(동작환경)이 있고 내 코드가 주최가 아니고 내 코드는 수동적으로 이 안에 들어가서 역할을 하는데 이게 프레임워크다.
스프링을 애기하면 나오는 주요 3가지가 존재한다. IoC, DI, AOP다.
- IoC(Inversion of Control): 객체의 생성과 의존성 관리를 프레임워크에 위임하는 개념.
- DI(Dependency Injection): 의존성 주입을 통해 객체 간 결합도를 낮추고 확장성과 테스트 용이성을 향상시킴.
- AOP(Aspect-Oriented Programming): 횡단 관심사(공통 기능)를 분리하여 코드 중복을 줄이고 모듈성을 개선.
ORM
객체지향과 RDB 페러다임이 다름.
이전에는 개발자가 객체의 데이터를 한땀한땀 매핑하여 DB에 저장 및 조회
ORM을 사용함으로써 개발자는 단순 작업을 줄이고 비즈니스 로직에 집중.
JPA
Java진영의 ORM 기술 표준
인터페이스이고 여러 구현체가 있지만 보통 Hibernate를 많이 사용
반복적인 CRUD SQL을 생성 및 실행해주고 여러 부가 기능들을 제공
편리하지만 쿼리를 직접 작성하지 않기 때문에 어떤식으로 쿼리가 만들어지고 실행되는지 명확하게 이해하고 있어야 함
Spring 진영에서는 JPA를 한번 더 추상화한 Spring Data JPA제공
QueryDSL과 조합하여 많이 사용
Persistence Layer 테스트
요구사항이 다음과 같다고 하자.
키오스크 주문을 위한 상품 후보 리스트 조회하기
상품의 판매 상태: 판매중, 판매보류, 판매금지
판매중, 판매보류인 상태의 상품을 화면에 보여준다.
id, 상품번호, 상품타입, 판매상태, 상품이름, 가격
이 요구사항을 바탕으로 우리는 엔티티설계부터해서 컨트롤러까지 즉, Presentation Layer, Business Layer, Persistence Layer까지 전반적으로 한 사이클을 돌면서 코드를 작성해보고 확인까지 진행해보았다. 그럼 이제 repository부분부터 테스트를 해보자.
우리는 given-when-then 패턴으로 테스트 코드를 작성했다. 여기서 살펴볼 것은 @SpringBootTest
와 @DataJpaTest
이다. 이 둘의 비슷하지만 차이점을 살펴보면 @SpringBootTest는 모든 부분의 의존성들을 주입시켜주지만 @DataJpaTest는 JPA관련된 부분만 주입을 시켜준다. 따라서 @DataJpaTest가 더 가볍다. 하지만 우빈님께서는 @SpringBootTest를 선호하신다고 하신다. 그 이유에 대해서는 추후에 말씀주신다고 하셨다.
그럼 Persistence Layer 역할에 대해 정리하면 아래와 같다.
Data Access 역할
비즈니스 가공로직이 포함되어서는 안된다. Data에 대한 CRUD에만 집중한 레이어여야 한다.
ex) QueryDSL이나 별도 DAO를 사용하면서 비즈니스 로직이 침투할 가능성이 있을 수 있으니 이 점을 생각하면서 작성해야 할 것 같다.
Business Layer 테스트
비즈니스 레이어에 대한 역할을 살펴보면 아래와 같다.
비즈니스 로직을 구현하는 역할
persistence layer와의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킨다.
트랜잭션을 보장해야한다.
그래서 우리는 새로운 요구사항을 통해 해당 비즈니스 레이어에 대한 테스트 코드를 작성해보는 시간을 가졌다. 새로운 요구사항은 아래와 같다.
요구사항(1)
상품번호 리스트를 받아 주문 생성하기
주문은 주문상태 주문등록시간을 가진다.
주문의 총 금액을 계산한다.
위의 요구사항으로 우리는 주문 엔티티를 설계하고 연관관계를 기존에 만든 상품 엔티티와 다대다 관계를 맺기 위해 중간 엔티티를 설계하고 각각으로 연관관계를 맺어두었다. 그리고 주문생성 로직과 테스트코드를 작성하는 시간을 가졌다.
다음으로 우리가 작성한 테스트들을 동시에 돌려보았다. 하지만 실패되는 테스트를 보게되었다. 우리가 작성한 비즈니스 레이어 테스트가 전부 성공하는게 아니라 일부 실패가 되는 경우가 있다. 하지만 이 전에 Persistence Layer에 작성한 테스트를 동시 실행해보면 그것은 괜찮았다. 차이는 @SpringBootTest
와 @DataJpaTest
두 어노테이션 차이였다. 두 어노테이션을 타고 들어가서 확인하면 @Transactional
어노테이션 유무 차이였다. 그래서 실패되는 비즈니스 레이어에 트랜잭션 어노테이션을 붙여주면 될 것 같아 보였지만 우빈님께서는 tearDown 메서드를 만드셔서 데이터를 클리닝하는 작업을 해주셨다. 그 이유는 추후에 말씀주신다고 하셨다.
요구사항(2)
주문 생성 시 재고 확인 및 개수 차감 후 생성하기
재고는 상품번호를 가진다.
재고와 관련 있는 상품타입은 병음료, 베이커리다.
새로운 요구사항으로 재고 개념이 도입되었다. 그래서 해당 엔티티를 설계후 개수 차감 로직을 작성하였다. 여기서 위에서 언급한 수동으로 tearDown 메서드로 삭제를 하나하나 해주냐 아니면 @Transactional
어노테이션을 붙여주냐였다. 처음에 우리는 로직을 작성하고 해당 로직을 테스트할때 @Transactional
어노테이션을 붙이지 않고 tearDown 메서드를 만들고 실행하였고 결과는 실패하였다. 정상적으로 재고가 감소가 안 된 것이다. 그래서 해당 로그와 쿼리를 보니 update 쿼리가 안 나간것이다. 원래 @Transactional
어노테이션을 붙이면 커밋종료시점에 더티체킹으로 update 쿼리가 발생한다. 하지만 지금은 우리가 수동으로 감소하는 전략을 하였기에 더티체킹 기능이 활성이 안 된 것이다.
🙋🏻 그러면 왜 insert쿼리는 잘 나간거에요?
jpa repository를 타고 들어가보면 crud repository를 확인할 수 있다. 해당 구현체를 보면 save 메서드에
@Transactional
어노테이션이 잘 붙어져 있다. 이것은 delete도 마찬가지다.
그래서 우리는 추후 살펴볼 것들이 있어 tearDown 메서드는 두고 @Transactional
을 product 코드에 적용하기로 했다. 그리로 우빈님께서 이런 경우를 대비해 테스트에는 @Transactional
을 붙이고 실질적으로 본 코드에는 안 붙이고 release하는 경우도 있으니 한번 생각하고 써야한다고 말씀을 주셨다.
추가적으로 재고감소 로직은 동시성 이슈가 날 수 있는 대표적엔 케이스다. 지금은 키오스크가 1대밖에 없다 가정했지만 2대 이상이라면 동시성 이슈가 터질 것이다. 그래서 optimistic lock / pessimistic lock등을 고민해서 해결을 해야한다. 이 부분도 나중에 한번 더 스스로 공부해봐야겠다.
댓글을 작성해보세요.