테스트 주도 개발(Test-Driven Development, TDD) - 4

테스트 주도 개발(Test-Driven Development, TDD) - 4

테스트 주도 개발(Test-Driven Development, TDD)에 대한 학습

 

// file: 'FirstControllerTest.class'
public class FirstControllerTest extends AbstractMockMvcTests {
    @Test
    public void init() throws Exception {
        mvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello! World!"));
    }

    @Test
    public void getBmi() throws Exception {
        DecimalFormat df = new DecimalFormat("#.##");
        String height = "180";
        String weight = "70";
        // BMI = 체중(kg) / (신장(m) * 신장(m))
        double bmi = Double.parseDouble(weight) / Math.pow(Double.parseDouble(height) / 100, 2);
        String result = df.format(bmi);
        mvc.perform(get("/get/bmi")
                .param("height", height)
                .param("weight", weight))
                .andExpect(content().string(result));
    }
}

 

// file: 'FirstController.class'
@RestController
public class FirstController {
    @GetMapping("/")
    public String init() {
        return "Hello! World!";
    }

    @GetMapping("/get/bmi")
    public String calcBmi(@RequestParam("height") double height, @RequestParam("weight") double weight) {
        DecimalFormat df = new DecimalFormat("#.##");

        // BMI = 체중(kg) / (신장(m) * 신장(m))
        double bmi = weight / Math.pow(height / 100, 2);
        return df.format(bmi);
    }
}

 

처음 원했던 기능이 제대로 동작함을 확인했다.

하지만 몇 가지 문제점이 보인다.

 

  1. 사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직

  2. 제어 계층(Control Layer)에 비즈니스 로직이 노출되어있음 - 책임 분리

 

모든 문제를 해결해서 최종적으로

/get/bmi로 신장과 체중을 포함한 Get방식의 요청을 받으면

BMI지수 혹은 값을 제대로 입력하세요 라는 문자열을 반환하도록 것이다.

 

String height = null;
String weight = null;

 

신장과 체중을 null로 설정하고 테스트를 진행해보자.

예상하기에 NullPointerException이 나올 거라 생각된다.

 

 

값을 null이 아닌 ““라고 입력하면 NumberFormatException이 발생할 것이다.

왜냐하면 테스트 코드에서 Double.parseDouble("") 코드가 실행 될 텐데

이러면 이곳에서 double 타입의 값을 반환하지 못하게 되기 때문이다.

 

String height = "";
String weight = "";

 

 

테스트 코드를 리팩토링한다.

사용자가 아무런 값을 입력하지 않았을 경우 “값을 제대로 입력하세요” 라는 문구가 출력되게끔 할 것이다.

우선 두 가지 경우의 수가 있다.

 

  1. 값이 null 인 경우

  2. 값이 ““인 경우

 

입력값이 어떻게 들어올지 확정할 수 없으므로

두 경우 모두 “값을 제대로 입력하세요”라는 문자열을 리턴해야 한다.

값이 null이라면 비교 연산자(==)를 이용하여 동일 비교를 한다.

 

String height = "";

if(height == null) // true

 

이해가 안된다면 Call By Value, Call By Reference 에 대해 공부해보자.

 

값이 “” 라면 equals()를 이용하여야 한다.

 

String height = "";

if("".equals(height)) // true

 

이때 약간 주의해야 할 점이 있다.

 

if(height.equals(""))

 

이와 같이 코드를 작성할 경우

height가 ““이라면 true가 반환될 것이다.

하지만 height가 null이라면?

null에는 equals()가 정의되어있지 않기 때문에 NullPointerException이 발생할 것이다.

 

// 정리

if(height.equals("")) // NullPointerException 발생위험

 

따라서 다음과 같이 리팩토링한다.

 

if ((height == null || "".equals(height)) || (weight == null || "".equals(weight)))

 

신장과 체중 둘중 하나라도 입력이 되지 않으면 해당 조건문에서 필터링될 것이다.

최종 코드를 보자.

 

// file: 'FirstControllerTest.class'
public class FirstControllerTest extends AbstractMockMvcTests {
    @Test
    public void init() throws Exception {
        mvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello! World!"));
    }

    @Test
    public void getBmi() throws Exception {
        DecimalFormat df = new DecimalFormat("#.##");
        String result;
        String height = "180";
        String weight = "";

        if ((height == null || "".equals(height)) || (weight == null || "".equals(weight))) {
            result = "값을 제대로 입력하세요";
        }
        else {
            // bmi = 체중(kg) / (신장(m) * 신장(m))
            double bmi = Double.parseDouble(weight) / Math.pow(Double.parseDouble(height) / 100, 2);
            result = df.format(bmi);
        }
        mvc.perform(get("/get/bmi")
                .param("height", height)
                .param("weight", weight))
                .andExpect(content().string(result));
    }
}

 

테스트를 돌려본다.

 

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.1)

2021-01-14 19:02:28.457  INFO 2040 --- [    Test worker] c.s.s.controller.FirstControllerTests    : Starting FirstControllerTests using Java 11.0.8 on Changhoon-Han with PID 2040 (started by Han in D:\Project\springboot)
2021-01-14 19:02:28.458  INFO 2040 --- [    Test worker] c.s.s.controller.FirstControllerTests    : No active profile set, falling back to default profiles: default
2021-01-14 19:02:29.290  INFO 2040 --- [    Test worker] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2021-01-14 19:02:29.562  INFO 2040 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2021-01-14 19:02:29.563  INFO 2040 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2021-01-14 19:02:29.564  INFO 2040 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 1 ms
2021-01-14 19:02:29.586  INFO 2040 --- [    Test worker] c.s.s.controller.FirstControllerTests    : Started FirstControllerTests in 1.293 seconds (JVM running for 2.209)
2021-01-14 19:02:29.863  WARN 2040 --- [    Test worker] .w.s.m.s.DefaultHandlerExceptionResolver : Resolved [org.springframework.web.method.annotation.MethodArgumentTypeMismatchException: Failed to convert value of type 'java.lang.String' to required type 'double'; nested exception is java.lang.NumberFormatException: empty String]

MockHttpServletRequest:
      HTTP Method = GET
      Request URI = /get/bmi
       Parameters = {height=[180], weight=[]}
          Headers = []
             Body = null
    Session Attrs = {}

Handler:
             Type = com.study.springboot.controller.FirstController
           Method = com.study.springboot.controller.FirstController#calcBMI(double, double)

Async:
    Async started = false
     Async result = null

Resolved Exception:
             Type = org.springframework.web.method.annotation.MethodArgumentTypeMismatchException

ModelAndView:
        View name = null
             View = null
            Model = null

FlashMap:
       Attributes = null

MockHttpServletResponse:
           Status = 400
    Error message = null
          Headers = []
     Content type = null
             Body = 
    Forwarded URL = null
   Redirected URL = null
          Cookies = []

Response content expected:<값을 제대로 입력하세요> but was:<>
필요:값을 제대로 입력하세요
실제   :

 

체중을 비워놓았기 때문에

“값을 제대로 입력하세요” 라는 String 리터럴이 필요하다는 로그가 발생한다.

 

  1. 사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직
  2. 제어 계층(Control Layer)에 비즈니스 로직이 노출되어있음 - 책임 분리

 

이제 두 번째 문제점을 해결하면서 테스트 코드가 통과되게끔 코드를 리팩토링 해보자.

비즈니스 계층(Business Logic Layer)에 새로운 클래스를 작성할 것이다.

새로운 비즈니스 로직을 작성하기 전에 테스트를 먼저 작성하자

 

// file: 'BmiCalculatorServiceTest.class'
public class BmiCalculatorServiceTest extends SpringbootApplicationTests {
    @Autowired
    private BmiCalculatorService bmiService;

    @Test
    public void calcBmi() {
        DecimalFormat df = new DecimalFormat("#.##");
        String result;
        String height = "180";
        String weight = "72";
        if((height == null || "".equals(height)) || (weight == null || "".equals(weight))) {
            result = "값을 제대로 입력하세요";
        }
        else {
            double bmi = Double.parseDouble(weight) / Math.pow(Double.parseDouble(height) / 100, 2);
            result = df.format(bmi);
        }
        assertEquals(result, bmiService.calcBmi(height, weight));
    }
}

 

AbstractMockMvcTests를 상속받으면 테스트 코드가 지나치게 무거워 질 수 있다.

MockMvc 테스트를 할 것이 아니기 때문이다.

SpringbootApplicationTests를 상속받아 Bean만 호출할 수 있게끔 설정을 해준다.

 

신장과 체중을 입력받아 BMI지수를 반환하되,

신장과 체중 둘중 하나라도 입력되지 않을 경우

“값을 제대로 입력하세요” 라는 String 리터럴을 반환하게 할 것이다.

이대로 실행하면 컴파일 에러가 발생할 것이다.

아직 처리로직이 없기 때문이다.

우선 Stub을 작성해서 컴파일이 되게끔 만든다

 

// file: 'BmiCalculatorService.class'
@Service
public class BmiCalculatorService {
    public String calcBmi(String height, String weight) {
        return "";
    }
}

 

이제 컴파일이 되며 테스트가 실행되고 실패할 것이다.

 

// file: 'BmiCalculatorService.class'
@Service
public class BmiCalculatorService {
    public String calcBmi(String height, String weight) {
        DecimalFormat df = new DecimalFormat("#.##");
        if ((height == null || "".equals(height)) || (weight == null || "".equals(weight))) {
            return "값을 제대로 입력하세요";
        }
        else {
            double bmi = Double.parseDouble(weight) / Math.pow(Double.parseDouble(height) / 100, 2);
            return df.format(bmi);
        }
    }
}

 

테스트 코드의 명세대로 본격적인 비즈니스 로직을 작성하고

(사실상 이전 컨트롤러에 작성했던 테스트 코드와 거의 동일하다)

테스트를 실행해본다.

 

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.1)

2021-01-15 12:21:53.081  INFO 15240 --- [    Test worker] c.s.s.service.BmiCalculatorServiceTest   : Starting BmiCalculatorServiceTest using Java 11.0.8 on Changhoon-Han with PID 15240 (started by Han in D:\Project\springboot)
2021-01-15 12:21:53.083  INFO 15240 --- [    Test worker] c.s.s.service.BmiCalculatorServiceTest   : No active profile set, falling back to default profiles: default
2021-01-15 12:21:54.145  INFO 15240 --- [    Test worker] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2021-01-15 12:21:54.467  INFO 15240 --- [    Test worker] c.s.s.service.BmiCalculatorServiceTest   : Started BmiCalculatorServiceTest in 1.569 seconds (JVM running for 3.229)
대상 VM에서 연결 해제되었습니다, 주소: 'localhost:13529', 전송: '소켓'
2021-01-15 12:21:54.738  INFO 15240 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
BUILD SUCCESSFUL in 4s
4 actionable tasks: 2 executed, 2 up-to-date
오후 12:21:54: 작업 실행이 완료되었습니다 ':test --tests "com.study.springboot.service.BmiCalculatorServiceTest"'.

 

모든 문제를 해결하였으니 FirstController를 수정한다.

 

// file: 'FirstController.class'
@RestController
public class FirstController {
    private BmiCalculatorService calc;

    public FirstController(BmiCalculator calc) {
        this.calc = calc;
    }

    @GetMapping("/")
    public String init() {
        return "Hello! World!";
    }

    @GetMapping("/get/bmi")
    public String calcBmi(@RequestParam("height") String height, @RequestParam("weight") String weight) {
        return calc.calcBmi(height, weight);
    }
}

 

수정된 FirstController의 코드

 

// FirstControllerTests.class
public class FirstControllerTests extends AbstractMockMvcTests {
    @Test
    public void init() throws Exception {
        mvc.perform(get("/"))
                .andExpect(status().isOk())
                .andExpect(content().string("Hello! World!"));
    }

    @Test
    public void getBmi() throws Exception {
        DecimalFormat df = new DecimalFormat("#.##");
        String result;
        
        //Test-Case
        String height = "180";
        String weight = "";
        
        if ((height == null || "".equals(height)) || (weight == null || "".equals(weight))) {
            result = "값을 제대로 입력하세요";
        }
        else {
            double bmi = Double.parseDouble(weight) / Math.pow(Double.parseDouble(height) / 100, 2);
            result = df.format(bmi);
        }
        
        mvc.perform(get("/get/bmi")
                .param("height", height)
                .param("weight", weight))
                .andExpect(content().string(result));
    }
}

 

FirstControllerTests의 코드에서 위처럼 신장이나 체중값을 비우고

getBmi()를 실행한다.

 

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v2.4.1)

2021-01-14 19:16:38.516  INFO 5768 --- [    Test worker] c.s.s.controller.FirstControllerTests    : Starting FirstControllerTests using Java 11.0.8 on Changhoon-Han with PID 5768 (started by Han in D:\Project\springboot)
2021-01-14 19:16:38.519  INFO 5768 --- [    Test worker] c.s.s.controller.FirstControllerTests    : No active profile set, falling back to default profiles: default
2021-01-14 19:16:39.523  INFO 5768 --- [    Test worker] o.s.s.concurrent.ThreadPoolTaskExecutor  : Initializing ExecutorService 'applicationTaskExecutor'
2021-01-14 19:16:39.808  INFO 5768 --- [    Test worker] o.s.b.t.m.w.SpringBootMockServletContext : Initializing Spring TestDispatcherServlet ''
2021-01-14 19:16:39.808  INFO 5768 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Initializing Servlet ''
2021-01-14 19:16:39.808  INFO 5768 --- [    Test worker] o.s.t.web.servlet.TestDispatcherServlet  : Completed initialization in 0 ms
2021-01-14 19:16:39.826  INFO 5768 --- [    Test worker] c.s.s.controller.FirstControllerTests    : Started FirstControllerTests in 1.511 seconds (JVM running for 2.48)
2021-01-14 19:16:40.167  INFO 5768 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
BUILD SUCCESSFUL in 4s
4 actionable tasks: 2 executed, 2 up-to-date
오후 7:16:40: 작업 실행이 완료되었습니다 ':test --tests "com.study.springboot.controller.FirstControllerTests.getBMI"'.

 

FirstControllerTestsgetBmi()가 실행되며

BmiCalculatorServicecalcBmi(String height, String weight)를 호출했고

이 코드는 이미 검증된 코드이기 때문에 별 문제없이 테스트 통과가 될 것이다.

“값을 제대로 입력하세요” 라는 값이 나올 거라 예상했고,

실제로도 같은 값이 나오고 있음을 확인할 수 있다.

또한 계층을 나눠 책임을 분리시켰다.

 

이제 사용자는 UI에 신장과 체중을 입력하면 자신의 BMI를 얻어낼 수 있을 것이고

둘 중 하나라도 값을 입력하지 않으면 “값을 제대로 입력하세요”라는 메시지를 확인할 수 있게 되었다.

그리고 사실, 이런 간단한 로직은 프론트단에서 Javascript로 처리하는 게 훨씬 편했을 것이다.

 

아무튼 각설하고 결론을 내보자면,

  1. 사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직
  2. 제어 계층(Control Layer)에 비즈니스 로직이 노출되어있음 - 책임 분리

 

이렇게 개발을 할 경우 서버를 계속 재기동하지 않아도 되므로 코드의 결과 또한 빠르게 확인 해 볼 수 있다.

또한 계속해서 테스트 케이스를 변경해가며 코드를 검증하기 때문에 더욱 탄탄한 코드를 만들 수 있다.

또한, 테스트 코드를 만들면서 대부분의 구현또한 작성하기 때문에

테스트 코드를 작동시킬 실제 구현코드를 만드는데 드는 시간은 사실상 거의 없다고 봐도 무방하다.

결론적으로 개발하는데 드는 시간이 단축되면서도 더욱 탄탄한 코드를 작성할 수 있게 되는 것이다.

 


© 2022. All rights reserved.