개요
이전 글들()에서 E2E, 통합, 유닛(단위) 테스트가 뭔지, 그리고 상태 검증과 행위 검증과 Mock에 대해 알아보았다.
이 글에서는 SNS 웹 서비스를 가정하고, 유닛 테스트와 통합 테스트, 그리고 E2E 테스트를 직접 테스트 코드를 작성해보면서 어떤 지점을 고려해서 작성해야하는지 알아보고자 한다.
이 글에서 사용되는 예제는 Java 코드로 이뤄집니다.
테스트 작성에 유의할 점
테스트 작성에 앞서서, 유의할 점들에 대해 알아보자.
테스트의 비용을 고려하기
다만, 테스트 작성 자체의 난이도가 낮지 않고 품이 많이 든다는 점에서 정작 중요한 프로덕트의 구현 속도에 영향을 줄 수 있다. 이러한 지점을 잘 생각해서 ‘지금 필요한 테스트인지’를 고민해보는 것이 중요하다.
예를 들어 하나하나 모든 케이스를 검증하는 것이 프로덕트 동작의 꼼꼼함을 체크한다는 점에서는 좋을 수 있으나, 코드가 살짝 변해도 여러 테스트 케이스가 기존처럼 통과하지 않을 수 있다는 점(변경에 취약)이나, 작성 자체의 스트레스가 높고 어려울 수 있다는 점이 있을 것이다.
또, 당장 프로덕트를 막 만들고 있는 상황이라면 자주 바뀌는 정책이나 명세에 의해 코드에 변경이 생기고, 이전에 작성한 테스트 코드가 있다면 이를 또 변경해야하는 상황이 자주 발생하게 된다. 덩달아 테스트 코드를 작성하느라 구현 속도에 영향이 생기게 된다. 어떻게 알았냐면...
결국 우리가 테스트를 작성하는 것은 프로덕트를 위한 일임과 동시에 우리의 업무임으로 완급 조절이 필요하다. 너무 당연한 것들은 테스트를 하지 않는 방법도 있을 수 있다. 이전에 다른 시니어분이 ‘초보일 때에는 최대한 많은 테스트를 작성해서 커버리지(테스트가 코드에 대해 다루는 범위)를 높여라’라는 말씀을 했는데, 이리저리 테스트를 작성해는 것이 결국 어느 정도까지 테스트 케이스를 작성해야할지에 대해 알아가는 과정이 되었다.
개인적인 생각으로 E2E 테스트와 같은 직접적인 사용자 입장에서의 동작 검증을 하는 테스트가 가장 우선되며 필수적이라고 생각되고, 그 이외의 통합, 단위 테스트는 코드 품질과 안정성에 좀 더 초점이 맞춰져 있다고 생각한다. 프로덕트의 성격(짧은 시간 내에 해야하는지, 지속적으로 유지보수해야하는지, 구현이 우선인지.. 등)에 따라 함께 협업하는 팀원들과 서로의 의견을 통일해서 일관되게 작성하는 것이 좋다고 생각한다.
테스트의 목적을 생각하기
테스트 작성은 매우 고되다. 반복되는 테스트 케이스의 작성과 잠깐의 변화로 다시 굴러가지 않아 빨간 불이 들어오는 테스트들에 스트레스를 받기 쉽다.
그럼에도 불구하고 테스트를 작성해야하는 이유는 자명하다. 코드가 변경이 되어도 기존과 같이 동작하는지 확인할 수 있고, 개발한 후에 일일히 찍어보는 비용을 줄일 수 있기 때문이다. 작성이 고되어도 잘 짠 테스트 코드가 앞으로의 내가 할 일을 줄여주는 것이다. 한편, 이것말고도 테스트 작성이 중요한 이유가 있다.
객체지향 프로그래밍이 코드의 유지보수를 위해서라면 테스트 자동화는 프로덕트의 안정성과 다른 사람들의 프로덕트와 코드에 대한 이해를 돕는 명세라고 생각한다.
신입사원으로 들어가면 테스트 코드 작성을 시킨다는 얘기를 들어본 적이 있다. 결국 테스트 코드를 작성하면 자연스럽게 해당 프로덕트의 비즈니스 로직(어떠한 방식으로 정책이 코드로 구현되는가)에 대한 이해가 생길 수 밖에 없으므로 자연스럽게 회사에 적응할 수 있다는 것이다.
비슷한 맥락에서, 잘 짜여진 테스트 코드는 다른 사람이 보았을 때 이 서비스(코드)가 어떠한 방식으로 동작하는지 알 수 있게된다. 자연스레 이후에 내가 아닌 다른 사람이 내 코드를 보았을 때 이해가 안 될 부분들도 (주석도 있겠지만)테스트 케이스의 흐름을 보고 파악할 수 있는 것이다.
결국 나를 위해서도, 이후의 다른 사람을 위해서도, 유지보수를 위해서도 테스트는 필요하다.
그러므로 힘들어도 꾹 참고 테스트를 작성하자.
JUnit과 Mockito
이 글에서는 자바의 대표적인 테스트 라이브러리인 JUnit과 JUnit의 가독성을 높이는 AssertJ, 모킹 라이브러리 Mockito와 Mockito의 가독성을 높이는 BDDMockito를 이용하려고 한다.
자바가 아니더라도 각 언어 별로 편리하게 테스트를 작성할 수 있는 라이브러리를 제공하고 있을 것이니 걱정하지 말자.
TMI : BDD?
AssertJ를 이용한 단위 테스트 예시
@Test
@DisplayName("SomeObject는 get()을 하면 Something이라는 문자열을 반환한다.")
void someObjectWillReturnSomeThing() {
//given
SomeObject object = new SomeObject();
//when
String result = object.get();
//then - Assertions는 AssertJ의 테스트 유틸 클래스이다.
String expected = "Something";
Assertions.assertThat(result).isEqualTo(expected);
}
Java
복사
AssertJ, BDDMockito를 이용한 외부 이미지 업로드 API에 의존하는 통합 테스트 예시
@Test
@DisplayName("ImageManager는 File을 받으면 이미지를 업로드하고, URL을 반환한다")
void someObjectWillReturnSomeThing() {
//given
ImageUploadExternalApi mockImageUploader = mock(ImageUploadExternalApi.class);
ImageManager imageManager = new ImageManager(mockImageUploader);
File imageFile = new File(어쩌구_저쩌구);
String imageUrl = "어쩌구_저쩌구.jpg";
given(mockImageUploader.uploadImage(image)).willReturn(imageUrl); // 원하는 값을 뱉도록 낑군다고해서 stubbing이라고도 한다.
//when
String result = imageManager.uploadImageFile(imageFile);
//then
BDDMockito.then(mockImageUploader).should(times(1)).uploadImage(imageFile); // 모킹한 이미지 업로드 API는 시나리오에 있는 File을 한번 업로드 호출해야 한다.
Assertions.assertThat(result).isEqualTo(expected); // Assertions는 AssertJ의 테스트 유틸 클래스이다.
}
Java
복사
Mock의 유의사항
•
낮은 리팩터링 내성
당장 위의 단위 테스트와 비교해보아도 기본적으로 세팅해줘야하는 부분들이 꽤 많은데, 이 부분들이 전부 실제 코드에 의존적이다. 테스트하려는 ImageManager가 uploadImageFile을 할 때, ImageUploadExternalApi의 uploadImage를 사용한다는 점이 드러나고, 어떤 매개변수, 인자를 받는지(File)도 드러난다. 결국 ImageManager의 로직이 변경되면 자연스레 이 테스트 또한 변경되어야 하므로, 리팩터링(코드 변경)에 대한 내성이 낮다.
그럼에도 해당 메서드에서 File의 전달부터 URL 반환까지 이어지는 인자의 전달과 메서드 호출의 흐름을 검증할 수 있다는 점과 그 부분들이 테스트에서 드러난다는 점에서 mock을 이용한 단위 테스트가 갖는 의의는 ‘코드 흐름이 드러난다’는 점과 ‘해당 객체는 의존하는 객체가 잘 만 해주면 문제 없음’을 나타낼 수 있다.
•
거짓 음성
거짓 음성은 간단히 설명하자면 ‘의사 선생님이 아무 이상 없다고 했는데 사실 병에 걸린 경우’라고 말할 수 있다. (거짓 양성은 병에 걸렸다고 했는데 사실 안 걸린 경우)
위 글에서 나타난 케이스처럼, 실제 코드에서는 반환 값에 대한 오류가 발생함에도 테스트 코드에서 mock된 객체는 ‘반드시 우리가 지정한 값을 반환한다’는 점에서 오류가 묵인될 수 있고, 이는 거짓 음성으로 이어진다.
결국 제대로 검증해야 할 테스트가 제대로 역할을 하지 못하게 되는 경우가 생길 수 있다. 이를 주의하면서 mock을 이용해야한다.
결국 작성하는 사람은 편하고, 이후에 고치는 사람은 불편하고..
간단한 SNS 서비스를 가정해보자
사용자가 게시글을 작성할 수 있고, 간단한 문구와 사진을 업로드 할 수 있는 서비스의 테스트를 작성한다고 해보자.
•
User(사용자) 엔티티
class User {
Long id;
String name; // 이름
LocalDateTime createdAt; // 생성 시각
}
Java
복사
•
Post(게시글) 엔티티
class Post {
Long id;
String content; // 게시글의 내용
LocalDateTime createdAt; // 생성 시각
User user; // 게시글의 작성자
List<PostImage> images; // 게시글에 있는 이미지
}
Java
복사
•
PostImage(게시글에 첨부되는 이미지) 엔티티
class PostImage {
Long id;
Integer index; // 해당 게시글의 몇 번째 이미지인지
String imageUrl; // 이미지 URL
Post post; // 이 이미지가 속하는 게시글
}
Java
복사
TMI : 엔티티?
단위 테스트 구현
통합 테스트 구현
•
고민해볼 사항
E2E 테스트 구현
•
고민해볼 사항
◦
Test용 시나리오를 담은 Docker Container DB를 이용한다?
→ 우선 외부 DB에 테스트가 의존한다는 것 부터 잘못될 여지가 많다. 테스트는 최대한 외부 환경과 독립적으로 실행될 수 있어야 한다. 만약 위 방식을 채택한다면, 테스트 환경의 변화, 정책의 변화시에 그때그때마다 업데이트하고 이를 모든 팀원이 업데이트 해야하는 문제가 생긴다. 심지어 어떤 데이터가 어떤 상태의 시나리오를 나타내는지도 써있는게 아니어서, 데이터를 직접 까보면서 변수로 라벨링을 해야하는 문제가 생길 수 있다. 어떻게 알았냐면..
→ 해결 : 테스팅용 in-memory DB를 그때그때 데이터 스키마에 맞춰서 구성, create-drop을 통해 사용하고 폐기한다.
◦
그렇다면 Repository를 이용해 시나리오를 작성해야할까?
E2E Test에서 repository는 어플리케이션의 동작을 위한 객체이지 테스팅의 대상이 되는 객체가 아니다. - E2E에서 사용되는 테스트 대역들은 시나리오 작성을 위해 용이하게 사용할 수 있으면서 동시에 저수준의 JPA의 API인 EntityManager를 이용하는 것 뿐이다. 실질적으로 HTTP 요청에 따라 Reponse가 나타나든, 혹은 DB에 변경(데이터의 생성, 변경, 삭제)사항이 있는지 확인하는 것이 테스트의 전부이다. 즉, repository의 동작과는 관련이 없는 것이다.
무엇보다 중요한 것은 E2E Test에서는 repository의 save든 findBy..든 해당하는 메서드가 어떻게 동작하는지 상관이 없다는 것이다. ← 영향을 받게되면 안 된다는 얘기이다. 만약 soft delete를 사용하는 경우에 find에 where절로 deletedAt IS NULL이 기본적으로 적용되어있다고 하자. 그렇다면 테스트를 위한 메서드를 프로덕션에 사용하는 repository에 작성할 것인가? && 검증하고 작성해야하는 모든 종류의 시나리오를 위해 테스트에 repository를 덕지덕지 주입할 것인가?
그럼에도 불구하고, 편의성을 위해서 Repository를 사용해도 크게 상관은 없을 것 같다. 애초에 service에 비해 repository 또한 자주 바뀌지는 않는 편이니. 다만 구조적으로 멀리 보고 접근 했을 때 위와 같이 생각해볼 수는 있을 것 같다.