이번 면접 라이브 코딩 중에는 이런 문구가 있었다.
테스트 환경을 구성하면 더 좋습니다.
테스트 환경을 구성하지는 못했지만, 그래도 기능 구현을 하면서 테스트를 어떻게 작성하면 좋을지를 계속 고민했는데, 그 과정에서 문제가 되는 사항이 있었다.
참가자의 오답률에 따라 오답이 발생합니다.
간단하게 요구사항을 설명하자면, 뭔가 게임을 하는데 참가자가 오답을 말할 확률이 존재하고 이 확률에 따라 정답 또는 오답 처리를 해야한다는 것이다. 나는 아래와 같이 코드를 작성했다.
public class Player {
private final String name;
private final double wrongAnswerRate;
Player(String name, double wrongAnswerRate) {
this.name = name;
this.wrongAnswerRate = wrongAnswerRate;
}
public boolean doSomething() {
double answer = Math.random() * 100;
return answer >= wrongAnswerRate;
}
public String getName(){
return this.name;
}
}
Player#doSomething() 메서드는 참가자가 오답을 말할지, 정답을 말할지를 결정하는 메서드다. 참가자 인스턴스를 만들 때, 참가자의 오답률을 입력받는데, Math.random()으로 발생시킨 난수보다 오답률이 작거나 같으면 오답처리한다. Math.random()은 0.0 ~ 0.999... 까지의 난수를 발생시키기 때문에 대략적으로 0 ~ 100까지의 숫자를 얻을 수 있다. 따라서 확률 계산이 가능하다.
문제는 이 코드를 작성하면서, 이걸 도대체 어떻게 테스트해야할지 감을 못잡았다는 것이다. 이동욱님의 테스트하기 좋은 코드 시리즈의 두 번째 글인 제어할 수 없는 코드에 해당하는 문제다. 난수 값을 기반으로 답을 반환하니, 내가 뭔가 값을 조작해서 원하는 결과가 나오게 할 수가 없는 것이다.
또한 지금 느끼는 건, 이름과 오답률에 대한 검증 로직도 필요하다고 느껴진다. 이름의 경우에는 공백이 되면 안된다. 오답률의 경우에는, 0보다 작거나 100보다 커서는 안된다. 확률이니 말이다. 위의 두 부분을 리팩토링해보자.
일단 쉬운 부분인 이름과 오답률을 검증하는 로직을 생성하자. 이름과 오답률을 검증하는 위치는 여러 곳이 나올 수 있다.
- Player에서 자체적으로 검증한다.
- 검증을 담당하는 Validator 객체를 만든다.
- Name / WrongAnswerRate을 각각 클래스로 만들고 자체적으로 검증하게 만든다.
이 중 1번은 제외하는 것이 바람직하다고 생각한다. Player객체에 너무 많은 책임이 몰리게 된다. 2번도 굳이 싶은데, 만약 2번의 방식을 사용한다면 아래와 같은 코드가 나오게 될 것이다.
class Player {
private final String name;
private final double wrongAnswerRate;
Player(String name, double wrongAnswerRate) {
this.name = name;
this.wrongAnswerRate = wrongAnswerRate;
}
public boolean doSomething() {
double answer = Math.random() * 100;
return answer >= wrongAnswerRate;
}
public String getName(){
return this.name;
}
}
class NameValidator{
public static void validateName(String name) throws IllegalArgumentException {
if(name.isBlank()){
throw new IllegalArgumentException("이름은 공백이면 안됩니다.");
}
}
}
class WrongAnswerRateValidator{
public static void validateWrongAnswerRate(double wrongAnswerRate) throws IllegalArgumentException{
if(wrongAnswerRate < 0 || wrongAnswerRate > 100){
throw new IllegalArgumentException("오답률은 0이상 100이하여야 합니다.");
}
}
}
public class Main{
public static void main(String[] args) {
String name = "testName";
double wrongAnswerRate = 1.5;
try{
NameValidator.validateName(name);
WrongAnswerRateValidator.validateWrongAnswerRate(wrongAnswerRate);
}catch (IllegalArgumentException e){
System.out.println(e.getMessage());
// 이후 종료 or 다른 함수 수행
}
Player player = new Player(name, wrongAnswerRate);
boolean isPlayerMakeAnswer = player.doSomething();
}
}
3번의 방법을 사용하면 위와 비슷하지만 NameValidator 대신 Name에 validate로직이 추가된 코드가 나올 것이다. 별 차이는 없다. 다만, Player의 필드에 들어갈 멤버 타입이 String -> Name 으로, double -> WrongAnswerRate으로 바뀔 것이다.
만일 두 코드가 별 차이가 없다면, 타입을 통해 해당 객체가 무엇인지를 명확히 나타내는 편이 더 좋다고 생각한다. String playerName; 보다는 Name playerName; 이 좀 더 낫다. 그래서 3번의 방식으로 리팩토링을 진행할 것 같다.
import java.util.Scanner;
class Player {
private final Name name;
private final WrongAnswerRate wrongAnswerRate;
Player(Name name, WrongAnswerRate wrongAnswerRate) {
this.name = name;
this.wrongAnswerRate = wrongAnswerRate;
}
public boolean doSomething() {
double answer = Math.random() * 100;
return answer >= wrongAnswerRate.getWrongAnswerRate();
}
public String getName() {
return this.name.getName();
}
}
class Name {
private final String name;
public Name(String name) {
validateName(name);
this.name = name;
}
private void validateName(String name) throws IllegalArgumentException {
if (name.isBlank()) {
throw new IllegalArgumentException("이름은 공백이면 안됩니다.");
}
}
public String getName() {
return this.name;
}
}
class WrongAnswerRate {
private final double wrongAnswerRate;
public WrongAnswerRate(double wrongAnswerRate) {
validateWrongAnswerRate(wrongAnswerRate);
this.wrongAnswerRate = wrongAnswerRate;
}
private void validateWrongAnswerRate(double wrongAnswerRate) throws IllegalArgumentException {
if (wrongAnswerRate < 0 || wrongAnswerRate > 100) {
throw new IllegalArgumentException("오답률은 0이상 100이하여야 합니다.");
}
}
public double getWrongAnswerRate() {
return this.wrongAnswerRate;
}
}
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
Name playerName = getName(sc);
WrongAnswerRate playerWrongAnswerRate = getWrongAnswerRate(sc);
Player player = new Player(playerName, playerWrongAnswerRate);
boolean isPlayerMakeAnswer = player.doSomething();
}
private static WrongAnswerRate getWrongAnswerRate(Scanner sc) {
try{
double wrongAnswerRate = sc.nextDouble();
return new WrongAnswerRate(wrongAnswerRate);
}catch (IllegalArgumentException e){
System.out.println(e.getMessage());
return getWrongAnswerRate(sc);
}
}
private static Name getName(Scanner sc) {
try{
String name = sc.nextLine();
return new Name(name);
}catch (IllegalArgumentException e){
System.out.println(e.getMessage());
return getName(sc);
}
}
}
각설하고, 이번 포스팅의 핵심은 어떻게 난수를 테스트 할 것이냐는 것이다. 이 문제를 해결하기 위해선 왜 난수를 테스트하려고 하는가?에서 시작하는게 맞는 것 같다.
테스트가 불가능한 난수 로직이 들어간 코드를 테스트하려는 이유는 해당 로직이 문제의 핵심 영역이기 때문이다. 게임을 하기 위해서는 참가자가 정답을 말할지 오답을 말할지를 결정해야한다. 그것이 확률에 근거한 오답이기 때문에, 이것을 테스트하기 어렵다. 그럼, 테스트하기 어려운 부분은 다른 부분에 빼면 되지 않을까?
결국 테스트하기 어려운 이유는 핵심 로직 내에서 난수를 만들기 때문이다. 난수를 만드는 부분은 다른 객체가 담당하면, 핵심 로직인 게임에서 오답을 말할지를 결정하는 것은 만들어진 난수만 사용하면 된다. 그러면 테스트 로직을 작성할 때는, 만들어진 값만 넣어주면 되기 때문에 테스트하기가 쉬워진다. 이제 코드를 리팩토링해보자.
package tesgt;
import java.util.Scanner;
class Player {
private final Name name;
private final WrongAnswerRate wrongAnswerRate;
Player(Name name, WrongAnswerRate wrongAnswerRate) {
this.name = name;
this.wrongAnswerRate = wrongAnswerRate;
}
public boolean doSomething(double answerRate) {
return answerRate >= wrongAnswerRate.getWrongAnswerRate();
}
public String getName() {
return this.name.getName();
}
}
class AnswerRateGenerator {
public static double createPlayerAnswerRate(){
return Math.random() * 100;
}
}
public class Main {
public static void main(String[] args) {
double answerRate = AnswerRateGenerator.createPlayerAnswerRate();
boolean isPlayerMakeAnswer = player.doSomething(answerRate);
}
}
로직은 바뀌지 않았다. 다만, Player#doSomething에 만들어진 정답률을 넣어주기 때문에 테스트하기도 쉽다. 일단은 테스트가 가능하게 수정하는 것이 우선이라 이름은 대충 지었다.
class PlayerTest {
@Test
void test(){
Player player = new Player(new Name("name"), new WrongAnswerRate(10.0));
double testRate = 5;
assertThat(player.doSomething(testRate).isEqualTo(false);
double testRate = 20;
assertThat(player.doSomething(testRate).isEqualTo(true);
}
}
이런 식으로 테스트가 가능하다.
이렇게 이번 면접에서 작성했던 코드를 리팩토링해보았다. 난수를 테스트하려면 어떻게 해야할까에서 리팩토링을 시작했는데, 제어할 수 없는 코드는 다른 자리로 던져버리고, 대신 핵심 로직에는 테스트하기 쉽게 작성하는 방식으로 코드를 작성할 수 있게 된다. 마찬가지로 다른 제어하기 어려운 코드들을 테스트 할 때도 이런 방식을 사용하면 될 것이다.
'Programming > Interview' 카테고리의 다른 글
HTTPS는 왜 쓸까? : HTTPS 동작 과정 알아보기 (0) | 2024.03.04 |
---|---|
JWT 돌아보기 (0) | 2024.02.20 |
String vs StringBuffer vs StringBuilder (0) | 2024.01.19 |
0.1 + 1.1 != 1.2 (0) | 2024.01.18 |
스프링 진영에서의 DI (0) | 2023.09.08 |
댓글