오늘은 아래의 것들을 하기로 생각했었다.
1. 테스트 코드에서 입력값을 받을 수 없는 이슈 해결하기
2. 테스트 코드 작성 시작하기
1번은 해결했고, 2번은 진행 중이다. 테스트 코드에서도 테스트 자체에서도 참 고민할 것이 많았다. 이제 이틀차인데 새롭게 배우는게 이전보다 훨씬 많은 것 같아서 좋았다. 역시 실전에서 부딪혀 봐야 빨리 배운다.
| System.in과 System.out에 대한 테스트
위 포스팅에서 많은 도움을 받았다. 알고 보니 우테코 3기 선배분의 게시글이였다.
간단하게 요약하자면, System.in
은 사용자의 입력을 InputStream
으로 담는다. Scanner
에 들어갈 이 InputStream
을 내가 원하는 값으로 미리 세팅해줘서 원하는 값을 입력한 것과 같은 효과를 내게 한다.
이렇게 해서 입력 이슈는 해결이 됐다.
어떤 기능부터 테스트 코드를 작성할까 고민을 많이 했다. 일단은 단위 테스트니, 구현하기 쉽고 상대적으로 다른 기능과 독립적인 기능인 '입력받은 수 검증' 기능부터 구현하기로 했다. 마침 제일 처음 시작한 것이 입력과 관련된 이슈니, 입력에 관한 테스트 코드부터 작성하기로 했다.
내가 생각한 검증되어야 할 조건들은 다음과 같다.
구현하기 쉬운 순으로,
3자리 -> 숫자 -> 서로 다른 문자 -> 게임 시작/종료 -> 예외처리
순으로 만들었다. 게임 시작/종료는 앞의 3가지 조건과 다르게 독립적으로 사용되므로, 후순위로 미뤘다.
맨 처음으로 고민한 것이, 입력값을 String
으로 설정할 지,int
로 설정할지였다. '숫자니 int
로 해야하지 않을까?' 고 생각하기도 했지만, 나는 String
타입으로 받기로 결정했다. 그 이유는 다음과 같았다.
Scanner
로 입력을 받을 때 내가 원하는 대로 받을 수 있어서 장점이 더 많은 것으로 받으면 된다. 숫자라고 무조건 int
타입으로 받으라는 법은 없다.그래서 사용자에게 받는 입력과 나중에 컴퓨터가 랜덤 생성할 값은 String
타입으로 통일하기로 했다.
일단은 테스트 부분은 이런 식으로 작성했었다. 그런데 너무 길어 보여서 개선하고자 했다. 테스트 메서드에서 입력값과 hasThreeDigits()
, isNumber
, areDigitsUnique
등 메서드를 입력하는 부분 말고는 동일했기에, 이 부분을 개선하고자 하였다. 필요한 것은 메서드를 매개변수로 어떻게 전달할 것인가? 였다.
검색하던 도중 Supplier<T>
라는 함수형 인터페이스가 있고, 이를 이용하면 메서드를 매개변수로 넘길 수 있다는 사실을 발견했다. Supplier<T>
는 매개변수 없이 단순히 무엇인가를 반환하는 get()
추상메서드가 존재한다. 검증 메서드들이 전부 boolean
타입이니, 사진처럼 Supplier<Boolean>
타입의 메서드들을 넘기는 assertTest
메서드를 작성했다.
그런데, 기존의 코드와 비교하니 그리 달라진 점이 없어보였다. 가독성의 관점에서 봤을 때, 딱 맨 뒤의 isEqualTo()
부분만 없어졌다. 그래서 좀 더 개선해보고 싶었다.
검색에 검색을 하다 이번엔 Predicate<T>
라는 함수형 인터페이스를 발견했다. Predicate<T>
는 인자를 받아 boolean 값을 반환하는 함수형 인터페이스로, test() 메서드를 가지고 있다. 이 test() 메서드는 전달한 인자가 충족되면 true를 반환한다. 이를 이용해 테스트 메서드에서 바로 검증 로직을 전달하게 했다.
코드를 설명하자면, assertTest()
메서드 내에서 testMethod.test(input)
을 호출하면 전달된 메서드 참조가 실행되고, 참조된 메서드의 실행 결과(boolean
타입)을 가져온다. 그리고 이 값을 assertThat()
으로 검증한다.
참고로 아직 람다식을 숙달하지 못해서 이 한 줄을 작성하는 데 1시간이 걸렸다. 하지만 결과물이 꽤... 예쁘고 깔끔하지 않은가? 나는 대만족이다.
Predicate<T>
가 어떻게 메서드를 전달할 수 있는가?메서드 참조가 가능한 근본적인 이유는 해당 메서드의 형태가 대상 함수형 인터페이스의 추상 메서드의 형태와 일치하기 때문이다. Predicate<String>
의 test()
메서드는 String을 매개변수로 받고 boolean
을 반환하는데, 검증 메서드 들도 전부 String(input) 을 매개변수로 받고 boolean
을 반환한다.
예외처리 기능에 관해서도 고민을 많이 했다.
IllegalArgumentException
을 어떻게 발생시킬 것인가?1번은 view의 영역에서 해결하기로 했다. 우테코 커뮤니티에 관련 토론도 올라왔었는데, 핵심 로직에 관련된 것이거나 특별하지 않은 일반적인 예외처리(여기서는 잘못된 입력)은 해당 view해서 해결하는 것이 맞다고 생각했다.
2번은 IllegalArgumentException
이외에 다른 예외가 발생할 수 있을까를 고민했기 때문이다. 이 IllegalArgumentException
예외는 사용자가 '잘못된 입력'을 할 때 발생해야 하고, 이 '잘못된 입력' 이라는 것은 결국 내가 기준을 세운 로직에 따라 처리되는 것이기 때문에 다른 예외가 자연발생할 일은 없다고 생각 되었다. 그래서 그냥 작성한 검증 코드에 if문으로 IllegalArgumentException
를 발생시켰다.
우테코에서 랜덤 숫자 생성을 위한 라이브러리를 제공했기 때문에, 구현은 금방 했다.
다만 indent depth 를 1로 유지하기 위해 예제 코드와는 다르게 메서드 분리를 했다.
이번 과제에 그런 요구사항은 없었지만, 저번 기수 후기들을 보니 indent depth를 줄이는 요구사항이 있었고, 지금부터 indent depth 줄이는 메서드 분리를 연습해서 나쁠게 없다고 생각했기 때문이다.
이건 아직 진행중이다. 일단 가장 많이 고민을 한 것이,
split()
등을 사용해서) 문자열을 한 글자씩 분리할 것인가?였다.
1번은 indexOf()
, charAt()
등을 사용해서 로직을 만들 수도 있을 것 같아서 고민이 되었고,
2번은 좀 복잡했다. 로직을 잘못 짜면 볼 카운트와 스트라이크 카운트가 중복으로 증가할 수 있다고 생각했다. 이중 for문을 사용해 하나씩 검증하면 로직이 만들어지긴 할 것 같은데, indent depth는 늘리기 싫었다. 메서드 분리를 하더라도 가독성만 나아지고 복잡도가 O(n^2)
인 저성능의 로직이 될 것 같았다. 그래서 이중 for문을 사용하지 않는 방법을 고민하다 하루가 끝났다.
그래도 진전이 많았다. 배운것도 많아서 좋은 2일차였지 않나 싶다.
대충 감을 잡고 보니, 전체 완성까지는 그렇게 많은 시간이 걸리지는 않을 것 같다.
다만 과제의 난이도가 높아지면 이 회고글을 매일 작성하기 어려워질수도 있겠다는 생각을 했다. 시간이 지난 후 회고글을 쓰면 기억에 남는게 많이 없어서 매일 쓰기로 했는데, 아무래도 회고 글 보다는 과제 해결이 먼저이지 않겠는가. 그래도 가능한 회고글은 매일 쓰고 싶다. 이전에 썼던 회고글을 생각해 보면, 매일 쓰면 텍스트 양 자체가 다르다...
단위 테스트를 할 때, 연관된 테스트라면 관련된 코드를 호출해도 괜찮은가? 에 대한 답. 정답은 No 다.
각각의 테스트는 독립적으로 수행되어야 하며, 다른 테스트 로직의 코드를 호출한다면 그것은 더 이상 순수한 단위 테스트가 아니다. 호출한 다른 코드에 문제가 있을 경우 _이 테스트도 실패할 수 있기 때문이다.
-> 이런 의문을 가졌던 이유는, 사용자의 입력을 받아오기만 하는 InputViewTest
와, 숫자의 유효성을 검증하는 InputNumberValidatorTest
가 매우 깊은 연관이 있었기 때문이다. 숫자의 유효성을 검증하려면 일단 입력을 받아와야 하니까. 하지만 그것이 옳지 않다는 것을 알고 InputViewTest
와 InputNumberValidatorTest
각각 입력값을 주었다.
테스트 시 생성자 호출해서 모든 테스트를 진행하게 하면 어떤가? 에 대한 답. 정답은 No 다. 생성자도 생성자의 역할(책임)이 있다는 사실! 일반적으로 생성자의 주 역할은 객체의 초기화인데, 복잡한 로직이나 검증 작업을 생성자에서 직접 수행하는 것은 객체 지향 설계 원칙과 맞지 않고, SRP 원칙을 위반한다. 그리고 테스트 시에는 검증 메서드를 독립적으로 수행하는 것이 테스트 결과를 알기에 좋다!
클래스의 목적을 달성하기 위해 내부에 다양한 책임을 가진 메서드들을 만드는 것이 SRP 위반인가? 에 대한 답. 정답은 No 이다.
내가 객체지향에 오늘까지 가졌던 오해가, SRP를 지키기 위해선 클래스 내부의 메소드들도 정해진 책임만을 수행해야 한다는 것이었다.
그래서 고민했던 것이, RandomNumberGenerator
클래스는 '중복되지 않는 랜덤 숫자를 생성하는' 것이 하나의 책임이다. 그런데 여기서 중복을 검사하는 로직이 과연 이 클래스의 멤버로 적합한가- 라는 의문이 생겼다. 만약 checkForDuplicates()
라는 중복 검사 메서드를 추가한다면 '중복을 검사하는 역할' 과 '랜덤 숫자를 생성하는 역할' 이라는 두 가지 역할을 한 객체가 수행하는 것이 돼서 SRP 원칙을 위반하는 것이 아닌가에 대한 고민을 했다.
그래서 관련된 자료를 찾고 공부했는데, 결론은 객체는 책임을 충실히 수행하기 위해 내부적으로 다양한 메소드(여기서는 checkForDuplicates
)나 로직을 사용할 수 있다. checkForDuplicates
는 이 클래스의 책임을 돕기 위한 도우미 메서드로 볼 수 있고, 외부로부터 보는 기능(즉, public
메서드로 노출되는 기능)과 내부 구현이 어떻게 되어있는지는 별개의 문제이다. 라는 것이다. 이 checkForDuplicates
메서드는 private
이니 외부로 노출될 일도 없고, 이것이 '중복되지 않는 랜덤 숫자를 생성하는' 책임과 잘 어울리니 SRP를 위반하고 있다고 볼 수 없는 것이다. 결국에 객체지향의 협력의 관점에서 다른 객체들은 RandomNumberGenerator
객체의 내부 동작 원리를 알 필요 없이 '중복되지 않는 랜덤 숫자를 넘겨달라' 고 요청할 뿐이고, RandomNumberGenerator
는 확실히 '중복되지 않는 랜덤 숫자'를 넘겨주니까.
도메인 중심 네이밍
클래스 네이밍을 고민하던 중, mvc 패턴을 사용할 때의 네이밍과 사용하지 않을 때의 네이밍에 차이가 있는 것을 발견했다. 이전 기수의 클래스 네이밍을 보는데, mvc패턴을 사용한 분께서
ComputerNumber (게임을 위한 숫자 랜덤 생성)
PlayerNumber (사용자 숫자 생성)
이런 식으로 네이밍을 한 것을 보았다. 돌이켜보니 mvc 패턴을 사용한 다른 분들도 (야구 게임이 아니더라도) Player
, Order
, Coffee
이런 식으로 네이밍을 했었다. 이는 도메인 중심 네이밍으로, 소프트웨어의 코드와 모델이 비즈니스 도메인의 용어와 구조를 직접적으로 반영하도록 하는 것이라고 한다. 비즈니스 전문가와 개발자 간의 의사소통이 원활해지는 장점이 있다고 한다. 그래서 나도 이것을 써야 하나 고민했는데, 나 혼자 작업하는 거니 Clean Code의 원칙대로 직관적인 클래스 네이밍을 하기로 결정했다.
테스트 코드에서 입력값을 받는 법
Supplier<T>
, Predicate<T>
의 사용법
클래스 네이밍은 명사 형태, 메서드 네이밍은 동사 형태를 사용하는 것이 좋다. 그 밖에 네이밍에 관한 지식들
| [Git] Revert와 Reset으로 커밋 삭제하기 | IntelliJ Reset, Revert 사용하기
| Git | git 커밋 컨벤션 설정하기
| git 브랜치 전략에 대해서
| 좋은 코드를 위한 자바 변수명 네이밍
| 좋은 코드를 위한 자바 메서드 네이밍
| 유닛 테스트 네이밍 컨벤션
| else 예약어를 쓰지 않는다
| [Java] Predicate란?
| [Java/자바] - Supplier interface