이번 포스팅은 최근에 계속 공부했던 테스트 코드를 진행했던 프로젝트들에 적용해보면서 생긴 일과 개념을 종합적으로 정리하려한다. 무엇이 문제였던건지, 트러블 슈팅은 어떻게 했는지에 대해 살펴보고 연관된 지식을 조금 더 파고들어가보도록 하자.
테스트 코드를 작성하다보면 Stubbing 이란 것을 분명, 단어의 의미를 알든 모르든 하고 있었을 것 이다. 그렇다면 Stubbing 의 정확한 의미는 뭘까?
💡 Stubbing : 특정 객체, 메서드, 함수 등의 동작을 제어하는 것
사실 개념만 봐서는 정확히 무엇인지에 대해 유추해낼 수 없으니, 예시를 보면서 확실하게 이해해보자.
@MockBean
private S3Service s3Service;
given(s3Service.saveProfileImage(any(MultipartFile.class)))
.willReturn("updateImage");
해당 코드는 BDDMokito를 이용하여 Stubbing을 한 예시이다. 간단하 코드이기에 크게 설명할것은 없지만 간단하게 살펴보자면, @MockBean 으로 S3Service가 mocking 되어있고 해당 객체의 saveProfileImage 메서드가 호출될때 "updateImage"를 return 한다는 것 이다.
여기서 중요하게 짚어볼것은, 동작을 제어했다는 것 이다. 내가 테스트에서 원하는 결과를 얻기위해 객체나 메서드를 제어하여 테스트가 의도한대로 흘러가게끔 만들었다.
만약, 게시글을 작성할때 S3Service가 작동하여 이미지를 업로드한다 했을때 테스트에서는 실제로 S3에 저장이 되지않고 return 값을 필요로한다 하면 Stubbing 을 통해 테스트를 제어하여 내가 의도한대로 작성하는 것 이다.
이러한 기법을 사용하여 테스트를 작성하여 내가 원하는 의도와 결과를 만들어 낼 순 있겠지만 과도하게 Stubbing 을 하게 되면 실제 프로덕션 코드와 간극이 생기거나 테스트 케이스가 복잡하고 가독성이 떨어지는 등 테스트에 안좋은 영향을 끼칠 수 있다. 즉, Stubbing 은 실제 프로덕션 코드에 영향을 미치지 않고 테스트 코드의 품질이 나빠지지 않는 선에서 적당히 사용하는 것이 좋다.
위에서 Stubbing을 하였을때 @MockBean을 선언한 것을 알 수 있다. 그러면 이제 이런 의문이 들 수 있다. @Mock은 안될까? @Spy는 뭘까? 일단 개념부터 살펴보자.
@Mock : 특정 클래스 또는 인터페이스에 대한 mock 객체를 생성하는데 사용
@MockBean : Spring Application Context에 mock 객체를 등록하고, 해당 타입의 모든 @Autowired 필드에 자동으로 주입
@Spy : 실제 객체를 사용하지만, 특정 메서드에 대한 호출을 추적하거나 행동을 변경하는 데 사용
역시 개념만으로는, 이해하기 힘들 수 있기에 조금 더 자세하게 얘기해보자.
일단 @Mock이 대체 뭘까? 특정 클래스, 인터페이스에 대한 mock 객체를 생성하는게 뭘까? 우리는 앞서 말한 Stubbing에 대해 생각해보자면, 우리는 테스트가 의도한대로 흘러가게끔 제어를 한다고 하였다. 즉, 제어를 하기위해 가짜 객체(mock)을 생성한다고 생각하면 된다.
빈 껍데기뿐인 가짜 객체를 생성하여 해당 객체를 우리 입맛대로 정의하면 되는 것 이다. 그러면 @SpringBootTest를 달고 @Mock을 통해 S3Serivce 객체를 만들어 해당 객체의 메서드가 실행될때 return 값을 제어할 수 있을까? 정답은 '아니요' 다. 여기서 우리는 @MockBean에 대해 알아 볼 수 있다.
@MockBean은 우리가 원하는 클래스, 인터페이스 등을 mock 객체로 생성하는 것과는 다르지 않다. 하지만 @MockBean의 경우 Spring Application Context에 mock 객체를 등록하는 과정이 추가된다. 이 말이 무슨 말이냐 하면 Spring Context가 관리하는 빈을 Mock으로 바꿀 수 있으므로, 전체 Spring Application Context 내에서 해당 빈의 행동을 제어할 수 있다는 것 이다.
이게 무슨소리인가 싶다면 @Mock과 @MockBean의 차이점에 대해 이해하면 확실하게 이해할 수 있다.
우리가 @SpringBootTest 어노테이션으로 테스트 코드를 작성한다고 가장해보자. 그렇다면 실제로 Spring이 돌아가면서 Spring context가 Bean을 관리하게 되는데 해당 테스트 코드 내에서 @Mock으로 S3Service를 mocking하여 행동을 제어하려 해본다 한들, 해당 mock은 코드 내부에서 mocking된 클래스이지 Spring Context가 관리하는 것이 아니다.
즉, @Autowired 필드에 주입되지 않은 객체이기에 실제 테스트하려는 로직에 영향을 끼치지 못한다는 것이다. 그에비해 @MockBean은 mock 객체를 등록하고, 해당 타입의 모든 @Autowired 필드에 자동으로 주입한다. 그렇다면, 이 mock 객체는 spring Context가 관리하게 되고 우리는 해당 Bean의 행동을 제어할 수 있다.
결국은, 프로덕션 코드에서 Spring이 직접 돌아가면서 테스트를 하기 위해서 Spring이 해당 Mock 객체를 관리할 수 있게 -> Mock 객체가 테스트 하려는 로직에 영향을 받게 하기 위해서 @MockBean을 통해 Mock 객체를 생성, 등록하는 것 이다.
그러면, @Mock은 관리도 안되는데 쓸모가 있나요? 라는 의문이 들 수 있겠지만 @Mock은 더 작은 단위테스트에서 유리하다. @SpringBootTest로 테스트를 하는 것이 아닌 더 작은 단위 테스트라고 생각해보자. 우리는 어떠한 Service 객체의 프로덕션 코드가 잘 수행되는지 확인만 하기위해 서버를 실행하지 않는다고 가정하였을대 해당 서비스에 Repository가 구현되어 있다고 생각해보자.
이 Repository의 값을 제어할 것 인데 서버를 돌리지 않는 가벼운 단위테스트 이기에 Bean 객체로 등록할 이유가 없다. 즉 @Mock을 사용해 Repository 객체를 mocking 하여서 호출값을 제어해주면 된다.
결국은 @MockBean과 @Mock은 자신이 무엇을 테스트하는지, 테스트 하려는 상황과 목적에 따라 두가지 어노테이션을 잘 고민하여서 객체를 Mocking 하고 자신의 테스트 코드에서 제어해주면 된다.
@Spy는 앞서 말한 @Mock, @MockBean 과는 다르게 실제 객체를 사용한다. 실제 객체를 사용한다는 점이 두 어노테이션과 가장 큰 차이점이지만, 제어가 가능해진다는 점은 같다. @Spy의 활용이 빛이 나는 순간은 활용하려는 객체가 실제 메서드의 호출도 필요하면서 특정 메서드의 호출을 제어하고 싶을 때 사용할 수 있다.
예를들어 프로덕션 코드에서 S3Service의 메서드가 각각 두번 호출된다고 가정해보자. 이 메서드가 테스트에서 하나는 실제로 호출되어야 하지만 다른 하나는 우리가 제어를 해주어야 한다고 하면 이때 @Spy 객체를 사용하여 자신이 원하는 메서드만 제어해줄 수 있다는 것 이다.
엥 그러면 @Spy가 최고 아니야? 라고 할 수 있지만 이는 좋은 것 만은 아니다. 해당 어노테이션은 실제 메서드를 호출하기에 DB, 파일 시스템에 직접적인 변경에 영향을 줄 수 있게 된다. 또한, 작은 단위테스트에서는 테스트 대상이 외부 서비스에 의존하지 않아야 하는데 @Spy를 사용하게 되면 의존하게 된다. 결국은 제대로 mocking이나 stubbing이 되지 않기에 상황에 맞춰 유연하게 사용하여야 한다.
@Mock, @MockBean, @Spy는 자신이 무엇을 테스트 하려하고 어떤 단위의 테스트인지, 목적은 무엇인지에 대해 깊게 고민하고 해당 상황에 맞게끔 개발자의 역량으로 사용해야 한다. 결국은 테스트도 비용이다. 얼마나 정확하고 효율적인 테스트 코드를 짜는 것은 개발자에게 달려있기에 열심히 고민하여 객체를 mocking하고 Stubbing 해야한다.
단일책임에 관한 얘기를 해서 그러다보니 Service는 Controller를 몰라야 한다는 것을 보았었는데, 예시로 컨트롤러와 서비스가 같은 Request를 사용하는 것은 서비스가 거대해질수록 영향을 받게 될 수 있기에 분리해주는 것이 좋다. 라는 것을 보았는데 이에 덧붙여 Service는 Controller 에 대해 아예 모르게 해야 한다는 것이였다.
그래서 기존에 사용하던 DTO를 Reuqest / Response로 분리함과 더불어 Request / ServiceRequest로 한번 더 분리 해주었다. 내가 당연하다고 생각했던 것들이 오히려 코드에 안좋은 영향을 끼칠 수 있다고 생각한 계기였다.
사실 이번에 진행했던 프로젝트와 진행하고 있는 프로젝트에 테스트 코드를 작성하게 되면서 TDD가 얼마나 중요한 것 인지 깨닫게 되었다.
일단 첫번째로 느낀점은, TDD를 하지 않고 작성한 코드는 서비스의 기능과 로직에만 중요하게 구성된 만큼 테스트를 하기 좋지 않은 코드라는 것 이다. 프로덕션 코드에 이미 고정이 되어버린 값들이 있다면 테스트 코드는 분명 고정된 값이 아닌 경우도 테스트 해야하는데 프로덕션 코드에 고정이 되어있다보니 테스트 하기 어려워 지는 것 이다.
과연 이뿐만일까? 클래스는 단일 책임을 유지하는 것이 좋은데, 이러한 단일책임이 무너지는 코드가 발견되고 이는 테스트를 어렵게 만들고 있었다. 결국 TDD를 하지 않다보니 실제 프로덕션 코드의 품질도 낮아지는 현상도 보면서 기능이 테스트를 만드는 것이 아닌 테스트가 기능을 만들어야 한다는 생각이 많이 들었다.
두번째로는 놓치지 않는다는 것 이다. 물론 기능을 작성하고 테스트 코드를 작성해도 어떤 곳에서 트러블이 일어나는지 발견할 수는 있다. 하지만, 이는 결과를 테스트 한다는 생각에 휩싸여서 여러 실패 케이스에 대해 크게 고민하기 힘들어진다는 것 이다. 사람이란 어쩔 수 없는 것..
그래서 TDD를 하자는 것 이다. 프로덕션 코드를 작성하기 전에 테스트 코드를 먼저 작성하면서 결과는 물론 발생할 수 있는 상황을 유연하게 풀어나갈 수 있다. 테스트 코드를 클라이언트 관점에서 작성할 수 있는 좋은 방법이다. 구현한 기능이 잘 돌아가는 것 뿐만 아니라 여러 상황에 잘 대처할 수 있는것이 개발자의 좋은 역량이다.
Embedded Redis 는 실제 Redis 서버를 별도로 설치하지 않고 애플리케이션 내에서 Redis를 실행할 수 있게 해주는 라이브러리이다. 해당 라이브러리를 사용하면 Redis 서버 없이도 Redis의 기능을 테스트할 수 있게 해준다.
이는 실제 Redis로부터 독립적인 테스트 -> 프로덕션에 영향을 미치지 않게 테스트를 할 수 있게된다. 추가적으로 배포할때 CI를 사용한다면 Redis가 CI에서 필요하지 않고 애플리케이션에서 내부적으로 Redis를 테스트 할 수 있기에 필요한 환경들을 구축할 필요도 없다.
Redis는 분명 테스트를 해야한다. 통합 테스트에서 mocking 을 하였다 한들 더 작은 단위테스트 에서 실제로 저장은 잘 되는지, 값이 원하는대로 호출되는지 테스트해야하기 때문에 필수인 테스트를 조금 더 효율적이고 간단하게 할 수 있는 방법이다.
implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2'
해당 의존성을 추가하고 Config 클래스를 하나 만들어서 기본적인 것들을 설정해주면 된다. 필자의 경우 Config 클래스에
@Profile("test")
어노테이션을 통해 yml에 profile 정의해둬서 test 일때만 EmbeddedRedis가 돌아가게끔 구성해줬다. 그리고 기존에 사용중이던 RedisConfig 반대로 @Profile("!test") 를 설정해두어 실제 서버가 돌아갈때 실제 Redis가 돌아가게끔 구현해주었다.
테스트 코드를 작성하고 배포하기 위해 테스트의 기본적인 세팅을 위해 data.sql을 deploy로 생성해주고 있었다. 근데 분명 배포를 위한 data.sql이 잘 만들어지는 것은 bash의 cat을 통한 로그를 찍어 확인하고 있는데 data.sql에 관한 익셉션이 계속 발생하는 것 이다. 읽어보니 데이터를 삽입할수가 없다고 어쩌구(...)
웃긴건 로컬에서는 너무 잘돌아가서 대체 뭘까 라는 생각이 들었다.
Caused by: org.springframework.jdbc.datasource.init.UncategorizedScriptException at ScriptUtils.java:305
Caused by: java.lang.IllegalArgumentException at Assert.java:289
결국 온갖 삽질을 하다가 data.sql 에서 문제가 되는 점을 찾았다.
> INSERT INTO authority (authority_field) VALUES ('ROLE_USER');
> INSERT INTO `authority` (`authority_field`) VALUES ('ROLE_USER');
문제는 백틱이였다. 웃긴건 백틱을 사용해도 로컬에선 잘됐는데 giuhub Action 에서는 자꾸 먹통을 일으켰다는점... 이것때문에 시간을 꽤나 날려먹었다. 모두들 이런 어이없는 실수는 하지말자. 나만 할 것 같다.
아무튼 테스트 코드를 공부하고 적용해보면서 여러 지식을 터득해가는 것 같다. 이는 단순히 테스트코드에서 뿐만 아니라 실제 프로덕트 코드에도 영향을 미치기 때문에 좋은 지식을 얻어가는 것 같다. 그리고 다음 포스팅은 드디어 Spring RestDocs에 대해 작성하려 한다.
예전부터 써보겠다 했지만 테스트코드에 대한 자신감이 없어 적용은 커녕 시도조차 못하고 있었는데, 최근 테스트코드도 계속 작성해보고 실제로 restDocs를 적용시키는 연습을 계속 하다보니 이제 포스팅을 해도 되겠다 싶어서 작성해보려 한다.
근데 확실히 세팅이 어렵다. 근데 세팅 한번만 제대로 해두면 쉽게 할 수 있으니 여따 적어놓고 잊지않으려 한다. 그러면 이번 포스팅은 여기서 이만!