목차
- ☆ 표시가 붙은 부분은 스터디 중 나온 얘기 혹은 제 개인적인 생각이나 제가 이해한 방식을 적어놓은 것으로, 책에 나오지 않는 내용입니다. 따라서 책에서 말하고자 하는 바와 다를 수 있습니다.
☆ 스터디 킥오프 (테스트 관련 랜덤 팁)
이하 내용은 책 내용 스터디 들어가기 전에 스터디 팀원들끼리 공유한 내용이다.
[ Octoping925님의 발표 자료 : https://github.com/Octoping925/TestCodePresent ]
⚈ 실제 구현부와 테스트쪽의 패키지, 클래스명 구조를 동일시 하는게 중요하다.
⚈ GWT, AAA
- Given : 특정한 값을 가지고
- When : 로직을 실행했을 때
- Then : 잘 나왔는지 검증한다.
- AAA : Assignment(=Given) / Act(=When) / Assert(=Then). 용어 차이이므로 어느쪽 쓸건진 취향인듯함.
⚈ @ParameterizedTest 를 사용하면 인자 넣어서 테스트가능.
⚈ @SpringBootTest 사용 시 autowired로 주입받을 수 있다. 다만 이 경우 모든 bean을 등록해야 하므로 오래걸린다.
⚈ 원하는 bean만 등록해서 가능하다. 하지만 지정한 클래스가 다른 또다른 테스트가 존재한다면(ApplicationContext가 똑같으면 다른 클래스의 테스트여도 그걸 재사용하지만 모킹 빈을 쓰든, 특정 몇 개만 올린 테스트든 ApplicationContext가 달라지면 그 Application Context를 위해 새 스프링을 띄운다.) 스프링이 여러번 실행되어 비효율적이다. (@SpringBootTest만 쓴 클래스가 100개 있다치면은 요건 하나가지고 돌려씀)
⚈ 또한 이하와 같은 테스트의 경우엔 DB에 실제로 데이터가 들어가게되는 문제가 있다. 따라서 처음 실행시엔 성공하는데, DB에 들어가면 실패하게 된다. @AfterEach로 지워줘도 되지만, 테스트 코드에서 @Transactional을 넣어주면 rollback이 기본이다. 롤백 안되게 하려면 @Rollback(false)도 추가한다.
⚈ 최종 코드
⚈ 하지만 어쨌든 운영DB에서 실험하는건 안좋다. 테스트용 DB를 따로 두자(h2 같은 메모리 db). test쪽에 resouces에 application.properties를 추가해서.
⚈ 위 내용 관련된 추가설명
세미나 중 DB 테스트 자체 보다는 Mock으로 처리하는게 낫다는 의견도 있었다. 원래 컨트롤러 테스트 따로, 서비스 테스트 따로, 레포지토리 테스트 따로 진행. 그리고 각각 테스트 할 때에는 예를 들어 컨트롤러테스트 할 때에는 컨트롤러가 잘 작동하는건지만 보고 싶은 거기 때문에 서비스, 레포지토리는 모킹해버림. 서비스 테스트도 레포지토리에 잘 저장하는지는 확인 안 하고, 그냥 서비스의 비즈니스 로직이 잘 돌아가는지만 테스트합니다 (레포지토리는 모킹할테구요)
⚈ 이메일서비스에서 정말 메일이 날아가는게 싫다. 이런 경우 모킹 해줘야 한다. 이하 코드는 "emailService에 sendEmail 실행 시 doAnswer 안쪽의 작업을 해줘라." 라는 의미이다. 단 이 경우 스프링은 또 하나가 더 올라갈 것이다. (MockBean이 아닌 emailService, MockBean인 emailService가 다르므로)
⚈ MockBean을 안쓸려고 이번엔 stub. 생성자 주입으로 해야 stub 가능.
⚈ 스파이를 추가해 이메일이 한번만 날아갔는지 확인
⚈ 일요일엔 경험치가 2배인 코드를 보자.
⚈ 위의 경우 일요일엔 실패하고 나머지 날엔 성공할 것이다.
- 문제점 : huntMonster는 exp뿐 아니라, '시간'이라는 숨겨진 입력값을 받고 있기 때문에 given을 제대로 설정하지 못했다. 그러니 테스트 하기 좋지 못한 구조이다.
⚈ 인자로 받아주도록 처리하면 해결됨.
⚈ private은 테스트하지 않고, private을 사용하는 public을 테스트하자.
⚈ gradle에서 빌드 시 테스트 코드가 제품이 딸려가진 않는다.
[ 이하 추가 내용 관련 : https://github.com/nahwasa/springboot-test-study 에서 branch 1, 2 관련 내용임. ]
⚈ 스프링부트에서 JUnit에 대해 아무런 설정도 없는데 위 octoping님 설명처럼 사용이 가능한 이유 : 그래들 디펜던시 보면 스프링부트 스타터에 이미 포함되어 있음.
⚈ 위에보면 JUnit이 아니고 jupiter라고 되어있는데, JUnit은 테스트 명세라 보면 되고 그 구현체로 Vintage와 Jupiter같은게 있음. Vintage는 JUnit 3,4용이고 Jupiter는 JUnit 5 용임.
⚈ 스프링부트 2.2.x부터 JUnit5가 기본적으로 채택되었고, 그 미만 버전을 쓸 일이 없으므로 우린 JUnit5로 스터디 하면 되고, 구현체로 Jupiter 써서 한다고 보면 됨. 참고로 4랑 5는 어노테이션 등 차이가 있음. (After -. AfterEach 처럼)
⚈ assert 함수로 테스트 시 마지막 인자로 테스트 실패 시 띄울 메시지를 넣을 수 있다. 이 때 String과 Supplier 두 가지 형태로 넣을 수 있는데, 이하와 같이 연산의 결과를 출력하고자 하는 경우(아래에선 String '+' 연산), 그냥 String 형태로 넣을 경우 해당 코드 실행 시 무조건 추가 연산이 일어난다. 실패 시에만 추가 연산이 일어나고 싶다면 Supplier 형태로 넣으면 되는데, 그냥 람다식으로 넣으면 알아서 Supplier 형태로 들어간다.
assertEquals(StudyStatus.INIT, study.getStatus(), () -> "Study 생성시 초기값은 " + StudyStatus.INIT + " 이여야 함.");
⚈ 여러 테스트를 한꺼번에 진행하고 싶은 경우, 함수를 아예 나누는 방법도 있지만 동일한 함수 내에서 한꺼번에 실행하고 싶을 수 있다. 이 경우 위와 같이 그냥 넣게되면 중간에 실패하는 테스트가 발생 시 해당 함수는 종료된다. 따라서 assertAll을 사용해서 여러개를 넣어주면 된다. 이 경우 하나가 실패하더라도 모든 테스트가 실행되며, N개 중 몇 개가 실패했는지도 알려준다.
⚈ 위 내용 관련된 팀원의 추가 팁 : assertSoftly는 assertAll과 동일한 기능을 하지만 추가로 에러가 난 부분이 몇 번째 줄인지도 알려주므로 더 유용하다.
테스트 주도 개발 책 1장 이전 내용
☆ 1장 들어가기 전 내용들 중 개인적으로 좋았던 내용을 정리했습니다.
⚈ 품질에 대한 책임은 그 누구보다도 작업자에게 맡겨야 한다.
⚈ 빨리 테스트를 통과시키려고, 혹은 프로그램을 빨리 작성하려고 너무 조바심 내지 마세요. TDD를 쫒아가려고 하지 마시고, TDD가 자신을 따라오게 하세요.
⚈ TDD의 특성상 완성된 프로그램 코드를 보거나, 간단한 메뉴얼 정도로는 TDD를 익힐 수 없습니다. 그 과정을 하나하나 따라가야 합니다.
⚈ "분명 엄청나게 좌절할 것입니다." (☆ ㄹㅇㅋㅋ)
⚈ 켄트 백(저자) : "저는 테스트 없이 프로그래밍하면 확신이 덜 생기고 시간은 더 듭니다." / "코드를 어떻게 테스트해야 할지 알아내기 전까지는 결코 만족하지 않습니다. 저는 이런 실천을 '끈기'라고 부르겠습니다."
- ☆ 내가 이 스터디를 연 목적은 개인적으로 정말 시간이 없어도 테스트코드 짜면서 진행하는게 빠른지에 대한 확신이 없기 때문이다. 아직은 시간없는데 테스트코드까지 짜면 당연히 느리지 않을까 생각이 든다. 남이 말해봤자 아직 이해가 잘 안되는걸로보아 직접 확인해봐야할듯하다. 직접 확인해보려면 어느정도 능숙하게 다룰 수 있어야 하므로 공부해서 직접 확인해보기 위해 스터디를 열었다.
⚈ 테스트 주도 개발의 궁극적인 목표 : 작동하는 깔끔한 코드 (clean code that works)
⚈ TDD의 주문
- 빨강 : 실패하는 작은 테스트를 작성한다. 처음에는 컴파일조차 되지 않을 수 있다.
- 초록 : 빨리 테스트가 통과하게끔 만든다. 이를 위해 어떤 죄악을 저질러도 좋다.
- 리팩토링 : 일단 테스트를 통과하게만 하는 와중에 생겨난 모든 중복을 제거한다.
1장. 다중 통화를 지원하는 Money 객체
[ 작업해야 할 테스트 목록 만들기 ]
⚈ 아래와 같은 보고서가 있다고 하자.
⚈ 책에서 얘기하는 다중 통화를 지원하는 보고서를 만들려면 통화 단위를 추가해야하고, 환율도 명시해야 한다.
⚈ 어떤 테스트가 있어야 새로운 보고서가 완성됐다는걸 확신할 수 있을까?
- 통화가 다른 두 금액을 더해서 주어진 환율에 맞게 변한 금액을 결과로 얻을 수 있어야 한다.
- 어떤 금액(주가)을 어떤 수(주식의 수)에 곱한 금액을 결과로 얻을 수 있어야 한다.
[ 이야기를 코드로 표현 ]
(오퍼레이션이 외부에서 어떻게 보이길 원하는지 말해주는 이야기를 코드로 표현한다.)
⚈ 곱하기를 먼저 다루려 한다. 객체를 만들면서 시작하는게 아니라 테스트를 먼저 만들어야 한다.
- 작은 단계로 시작하자.
- 간단한 곱셈 테스트의 예
public class MoneyTest {
@Test
@DisplayName("5달러를 2배하면 10달러여야 한다.")
void dollar_multiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, five.amount);
}
}
⚈ 아직은 컴파일조차 되지 않는다.
[ 새로운 할일은 할일 목록에 추가하고 넘어가자 ]
⚈ 생각나는 문제들은 적어 놓고 계속 진행하자. 지금 우리에겐 빨리 실패하는 테스트를 성공하시는걸 하고 싶을 뿐이다.
5달러 + 10CHF = 10달러 (환율이 2:1일 경우)
5달러 * 2 = 10달러 (1장에서 진행하는 것)
amount를 private으로 만들기
Dollar의 side effect?
Money 반올림?
[ 스텁 구현을 통해 테스트를 컴파일 ]
⚈ 실행은 안 되더라도 컴파일은 되게 만들고 싶다.
- ☆ 인텔리제이의 경우 위와 같이 추천 action이 뜨므로 더 쉽게 작성 가능하다.
- Dollar 클래스가 없음
public class Dollar {
}
- 생성자가 없음
public class Dollar {
public Dollar(int amount) {
}
}
- times(int) 메서드가 없음
public class Dollar {
public Dollar(int amount) {
}
public void times(int multiplier) {
}
}
- amount 필드가 없음
public class Dollar {
public int amount;
public Dollar(int amount) {
}
public void times(int multiplier) {
}
}
⚈ 이제 컴파일은 가능하지만 당연히 테스트는 실패한다.
- 이것도 일종의 진척이다. 이제 실패에 대한 구체적인 척도가 생겼다.
- 우리의 문제는 '다중 통화 구현'에서 '이 테스트를 통과시킨 후 나머지 테스트들도 통과시키기'로 변형되었다.`
- 훨씬 간단하다. 범위도 훨씬 적어서 걱정이 줄었다.
- ☆ 문제 해결을 위해 사용하는 저장소는 장기 기억이 아닌 단기 기억이다. 문제 해결을 위해 필요한 요소수가 단기 기억의 용량을 초과하는 순간 문제 해결능 력은 급격히 떨어진다(인지 부조화). 오브젝트 책에서 추상화를 설명하는 문장이지만 이 상황에도 적용될 듯 하다.
[ 끔찍한 죄악을 범하여 테스트를 통과시키기 ]
⚈ 테스트 주기
- 작은 테스트를 하나 추가한다.
- 모든 테스트를 실행해서 테스트가 실패하는 것을 확인한다.
- 조금 수정한다.
- 모든 테스트를 실행해서 테스트가 성공하는 것을 확인한다.
- 중복을 제거하기 위해 리팩토링 한다.
⚈ 일단 성공시켜보자!
public class Dollar {
public int amount = 10;
public Dollar(int amount) {
}
public void times(int multiplier) {
}
}
- ☆ 로직상 어무런 의미가 없어보이지만, 당연히 아무튼 성공한다.
- 이제 위 테스트 주기의 1~4를 진행한 것이다.
[ 점진적으로 일반화 ]
⚈ 중복이 테스트에 있는 데이터와 코드에 있는 데이터 사이에 존재한다.
- amount = 10 = 5*2 이다.
- 5와 2는 사실 테스트쪽에서 넘어온 값이므로 중복이다.
⚈ 객체의 초기화 단계에 있는 설정 코드를 times() 메서드 안으로 옮겨보자.
public class Dollar {
public int amount;
public Dollar(int amount) {
}
public void times(int multiplier) {
amount = 5 * 2;
}
}
- 테스트는 여전히 통과한다. (☆ 중복은 해결되지 않았지만, 아무튼 약간이라도 바뀌었다.)
- TDD의 핵심은 이런 작은 단계를 밟아야 한다는 것이 아니라, 이런 작은 단계를 밟을 능력을 갖추어야 한다는 것이다.
⚈ 정말 작은 단계로 작업하는 방법을 배우면, 저절로 적절한 크기의 단계로 작업할 수 있게 될 것이다.
⚈ 계속 진행하자.
- '5'는 생성자에서 넘어오는 값이므로 그걸 amount에 두자.
- '2'는 multiplier의 값이므로 리터럴 값을 이 인자로 대체하자.
public class Dollar {
public int amount;
public Dollar(int amount) {
this.amount = amount;
}
public void times(int multiplier) {
amount *= multiplier;
}
}
⚈ 이제 $5 x 2 = $10 테스트에 완료 표시를 할 수 있게 됐다.
5달러 + 10CHF = 10달러 (환율이 2:1일 경우)5달러 * 2 = 10달러
amount를 private으로 만들기
Dollar의 side effect?
Money 반올림?
2장. 타락한 객체
⚈ 일반적인 TDD 주기
- 테스트를 작성한다. 올바른 답을 얻기 위해 필요한 이야기의 모든 요소를 포함시켜라.
- 실행 가능하게 만든다. 빨리 초록 막대를 보는 것은 모든 죄를 사해준다. 하지만 아주 잠시 동안만이다.
- 올바르게 만든다. 직전에 저질렀던 죄악을 수습하자.
⚈ 우리의 목적 : 작동하는 깔끔한 코드를 얻는 것. '작동하는'을 먼저 해결하고 '깔끔한 코드' 부분을 해결하자.
⚈ 할일 목록
5달러 + 10CHF = 10달러 (환율이 2:1일 경우)5달러 * 2 = 10달러
amount를 private으로 만들기
Dollar의 side effect? (2장에서 하려는 것)
Money 반올림?
⚈ 1장에서 통과한 테스트의 문제점
- Dollar에 대해 연산을 수행한 후에 해당 Dollar의 값이 바뀌는 점
- 즉, 아래와 같이 테스트하고 싶다. -> 현재는 times()를 처음 호출한 이후에 five는 더 이상 5가 아니다.
public class MoneyTest {
@Test
@DisplayName("5달러를 2배하면 10달러여야 한다.")
void dollar_multiplication() {
Dollar five = new Dollar(5);
five.times(2);
assertEquals(10, product.amount);
five.times(3);
assertEquals(15, product.amount);
}
}
⚈ times()에서 새로운 객체를 반환하게 해보자.
- 그럼 five는 항상 5이다.
- Dollar의 인터페이스를 수정해야 하고, 테스트도 수정해야 한다. -> 문제될 건 없다. 어떤 구현이 올바른가에 대한 우리의 추측이 완벽하지 못한 것과 마찬가지로 올바른 인터페이스에 대한 추측 역시 절대 완벽하지 못하다.
public class MoneyTest {
@Test
@DisplayName("5달러를 2배하면 10달러여야 한다.")
void dollar_multiplication() {
Dollar five = new Dollar(5);
Dollar product = five.times(2);
assertEquals(10, product.amount);
product = five.times(3);
assertEquals(15, product.amount);
}
}
⚈ 당연히 아직은 컴파일조차 안된다.
⚈ 우선 컴파일은 되게 하자.
public class Dollar {
public int amount;
public Dollar(int amount) {
this.amount = amount;
}
public Dollar times(int multiplier) {
amount *= multiplier;
return null;
}
}
- 물론 NullPointerException이 뜰 것이다.
- 그래도 한 걸음 나아간 것이다.
⚈ 테스트를 통과하기 위해 올바른 금액을 갖는 새 Dollar를 반환하자.
public Dollar times(int multiplier) {
return new Dollar(amount * multiplier);
}
- 이제 잘 된다!
⚈ 할일 목록 하나 더 해결!
5달러 + 10CHF = 10달러 (환율이 2:1일 경우)5달러 * 2 = 10달러
amount를 private으로 만들기Dollar의 side effect?
Money 반올림?
⚈ 최대한 빨리 초록색(테스트 성공)을 보기 위해 취할 수 있는 전략
- 가짜로 구현하기 : 상수를 반환하게 만들고 진짜 코드를 얻을 때까지 단계적으로 상수를 변수로 바꾸어 간다. -> 1장에서 했었던거.
- 명백한 구현 사용하기 : 실제 구현을 입력한다. -> 2장에서 한게 이것. ☆ 자기가 아는 명백한 방식으로 구현하라는 것.
- 모든 일이 자연스럽게 잘 진행되고 내가 뭘 입력해야 할지 알 때는 명백한 구현을 계속 더해 나간다. -> 예상치 못한 빨간 막대(테스트 실패)를 만나면 가짜로 구현하기 방법을 사용하면서 올바른 코드로 리팩토링한다. -> 다시 자신감을 되찾으면 명백한 구현 사용하기 모드로 돌아온다.
'Study > 테스트 주도 개발' 카테고리의 다른 글
TDD, Mock, SOLID 얘기 - 도시 가스 요금 계산 (0) | 2023.01.29 |
---|---|
[TDD] 스터디 4주차 (25~28장 정리) (0) | 2023.01.20 |
[TDD] 스터디 3주차 (테스트 반복, 순서 지정, Extension 관련) (0) | 2023.01.10 |
[TDD] 스터디 2주차 (JUnit 관련 내용) (0) | 2023.01.09 |
댓글