테스트 주도 개발(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);
}
}
처음 원했던 기능이 제대로 동작함을 확인했다.
하지만 몇 가지 문제점이 보인다.
사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직
제어 계층(Control Layer)에 비즈니스 로직이 노출되어있음 - 책임 분리
모든 문제를 해결해서 최종적으로
/get/bmi
로 신장과 체중을 포함한 Get방식의 요청을 받으면
BMI지수 혹은 값을 제대로 입력하세요 라는 문자열을 반환하도록 것이다.
String height = null;
String weight = null;
신장과 체중을 null로 설정하고 테스트를 진행해보자.
예상하기에 NullPointerException
이 나올 거라 생각된다.
값을 null이 아닌 ““라고 입력하면 NumberFormatException
이 발생할 것이다.
왜냐하면 테스트 코드에서 Double.parseDouble("")
코드가 실행 될 텐데
이러면 이곳에서 double 타입의 값을 반환하지 못하게 되기 때문이다.
String height = "";
String weight = "";
테스트 코드를 리팩토링한다.
사용자가 아무런 값을 입력하지 않았을 경우 “값을 제대로 입력하세요” 라는 문구가 출력되게끔 할 것이다.
우선 두 가지 경우의 수가 있다.
값이 null 인 경우
값이 ““인 경우
입력값이 어떻게 들어올지 확정할 수 없으므로
두 경우 모두 “값을 제대로 입력하세요”라는 문자열을 리턴해야 한다.
값이 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 리터럴이 필요하다는 로그가 발생한다.
사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직✔- 제어 계층(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"'.
FirstControllerTests
의 getBmi()
가 실행되며
BmiCalculatorService
의 calcBmi(String height, String weight)
를 호출했고
이 코드는 이미 검증된 코드이기 때문에 별 문제없이 테스트 통과가 될 것이다.
“값을 제대로 입력하세요” 라는 값이 나올 거라 예상했고,
실제로도 같은 값이 나오고 있음을 확인할 수 있다.
또한 계층을 나눠 책임을 분리시켰다.
이제 사용자는 UI에 신장과 체중을 입력하면 자신의 BMI를 얻어낼 수 있을 것이고
둘 중 하나라도 값을 입력하지 않으면 “값을 제대로 입력하세요”라는 메시지를 확인할 수 있게 되었다.
그리고 사실, 이런 간단한 로직은 프론트단에서 Javascript
로 처리하는 게 훨씬 편했을 것이다.
아무튼 각설하고 결론을 내보자면,
사용자가 아무런 값을 입력하지 않았을 때의 예외상황에 대한 처리 로직✔제어 계층(Control Layer)에 비즈니스 로직이 노출되어있음 - 책임 분리✔
이렇게 개발을 할 경우 서버를 계속 재기동하지 않아도 되므로 코드의 결과 또한 빠르게 확인 해 볼 수 있다.
또한 계속해서 테스트 케이스를 변경해가며 코드를 검증하기 때문에 더욱 탄탄한 코드를 만들 수 있다.
또한, 테스트 코드를 만들면서 대부분의 구현또한 작성하기 때문에
테스트 코드를 작동시킬 실제 구현코드를 만드는데 드는 시간은 사실상 거의 없다고 봐도 무방하다.
결론적으로 개발하는데 드는 시간이 단축되면서도 더욱 탄탄한 코드를 작성할 수 있게 되는 것이다.