kok202
스프링 테스트 코드 전환기 시작 (3/3): 테스트 작성이 왜 어려웠을까

2021. 10. 10. 03:34[개발] 기록/스프링 테스트 코드 전환

테스트 작성의 어려움

방향도 정했겠다. 단위 테스트는 어떻게 늘려가면 좋을까요? 진짜 간략하게 현재 프로젝트 코드에 대해 설명드리자면 아래와 같이 작성되어 있었습니다. 

@Service
public class ServiceA {
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  public void functionA () {
    A a = repositoryA.find();
    B b = repositoryB.find();
    C c = repositoryC.find();
    // 정말 간단한 비즈니스 로직
    repositoryA.save(a);
 }

그럼 이런 서비스 레이어의 코드를 테스트하려면 어떻게 해야할까요? 

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {
  ServiceA.class, RepositoryA.class, RepositoryB.class, RepositoryC.class})
public class ServiceATest {

  @Autowired
  private ServiceA serviceA;
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  @Test
  public void functionATest(){
    // given 
    A a = new A();
    B b = new B();
    C c = new C();
    repositoryA.save(a);
    repositoryB.save(b);
    repositoryC.save(c);

    // when
    serviceA.functionA();
    
    // then
    assert(...);
 }
}

환장할 노릇입니다. 이 테스트 코드의 문제는 크게 3가지라고 봅니다.

 

문제점

1. SpringBootTest를 통해 의존하는 클래스를 나열해주어야 하는데, 테스트 코드를 작성하는데 일일히 이걸 다 추적해줘야한다. (자동으로 하는 설정도 몇개 있는 것으로 아는데, 이 설정은 프로젝트가 커지고 복잡해져서 그런지 저희 케이스에는 안먹혔습니다.)

2. 정말 간단한 테스트를 하고 싶었을 뿐인데 A,B,C 를 만들어서 DB 에 데이터를 세팅해줘야 했습니다. 

3. repository.save가 마음에 안듭니다. 만약 B는 serviceB를 통해서만 save 해야한다는 정책이 있다면 어떡하죠? serviceB 도SpringBootTest에 추가하고 serviceB.save() 한 다음 serviceA를 테스트할 건가요?

@RunWith(SpringRunner.class)
@SpringBootTest(classes = {
  ServiceA.class, ServiceB.class, RepositoryA.class, RepositoryB.class, RepositoryC.class})
public class ServiceATest {

  @Autowired
  private ServiceA serviceA;
  @Autowired
  private ServiceB serviceB;
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  @Test
  public void functionATest(){
    // given 
    A a = new A();
    B b = new B();
    C c = new C();
    repositoryA.save(a);
    serviceB.save(b); // serviceA.functionA를 테스트하려했는데 왜 save 로직도 검증 로직에 추가 되는거죠...?
    repositoryC.save(c);

    // when
    serviceA.functionA();
    
    // then
    assert(...);
 }
}

그렇다면 serviceA의 functionATest 를 하려했는데 serviceB.save 가 들어가는게 맞을까요? 그리고 이렇게 작성된 테스트를 단위 테스트라고 볼 수 있을까요? Block I/O와 DB를 사용해야하는데요? 테스트를 위한 HBase를 사용하는 방법이 있을 수도 있겠지만 결국 중형 테스트에 속합니다.

 

문제는 비즈니스 로직의 위치

정확한 정답이 무엇인지는 모르겠지만, 일단 제가 얻은 해답은 비즈니스 로직의 위치가 잘못되었다는 겁니다. 비즈니스 로직이 서비스 레이어에 있기 때문에 DB에 데이터를 저장해주어야 했습니다. 만약 도메인 객체가 비즈니스 로직을 갖고 있었다면 어땠을까요? 예를들어 functionA가 A클래스가 갖고 있었다면 이렇게 테스트를 작성할 수 있었을 겁니다.

@Service
public class ServiceA {
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  public void functionA () {
    A a = repositoryA.find();
    B b = repositoryB.find();
    C c = repositoryC.find();
    a.functionA(b, c); // 비즈니스 로직을 A 도메인의 메소드로 옮기고 serviceA.functionA가 a.functionA를 실행하는 역할만 해준다면.
    repositoryA.save(a);
 }
}

public class ATest {

  @Test
  public void functionATest(){
    // given 
    A a = new A();
    B b = new B();
    C c = new C();

    // when
    a.functionA(b, c); // 테스트 코드를 간단하게 할 수 있습니다.
    
    // then
    assert(...);
 }
}

훨씬 간결해집니다. 테스트 코드가 무엇을 테스트하려는지 명확하게 보이고 거추장스러운 어노테이션도 전부 사라졌습니다. 물론 비즈니스 로직외의 ServiceA의 functionA를 테스트하고 싶다면 고통스러운 어노테이션 지옥을 다시 겪어야합니다. 하지만 이렇게 함으로써 적어도 비즈니스 코드를 테스트하는 것이 간단해졌으며 테스트 코드가 소형 테스트가 되었습니다!

이는 OOP 관점에서도 첫 번째 방식의 코드가 OOP스럽게짜여지지 않았음을 의미합니다. 그리고 부끄럽게도 실제 코드들을 보면 객체들이 getter와 setter로 데이터를 서비스 클래스에게 전달하고 비즈니스 로직은 서비스 클래스가 처리하는 경우가 많습니다.

 

비즈니스 로직을 도메인 어디에도 두기 애매하다면?

좋습니다. 여기까지는 좋다 이겁니다. 하지만 아시다시피 개발을 하다보면 비즈니스 로직을 a 혼자서 처리할 수 없는 경우가 있습니다. 만약 코드가 이랬다면 어떻게 해야할까요?

@Service
public class ServiceA {
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  public void functionA () {
    A a = repositoryA.find();
    B b = repositoryB.find();
    C c = repositoryC.find();
    D d = a.functionA(b, c);
    b.functionB(d);
    coordinate(c, d);
    repositoryA.save(a);
 }
 
 private int coordinate(c, d) {
    // c나 d 둘다 들고 있기 애매한 함수
 }
}

coordinate 은 정말 어느 도메인에서도 들고 있는게 부자연스러운 함수라고 칩시다. 누군가 ServiceA 같은 중재자가 나타나서 c 와 d 를 중재해줘야합니다. 그럼 functionA를 테스트하기 위해서든지 coordinate 을 테스트하기 위해서든지 ServiceATest가 필요할까요? 여기에 대한 해답은 DDD Start! 의 도메인 서비스라는 개념에서 얻을 수 있었습니다. 

 

@Service
public class ServiceA {
  @Autowired
  private RepositoryA repositoryA;
  @Autowired
  private RepositoryB repositoryB;
  @Autowired
  private RepositoryC repositoryC;

  public void functionA () {
    A a = repositoryA.find();
    B b = repositoryB.find();
    C c = repositoryC.find();
    DomainServiceA domainServiceA = new DomainServiceA();
    domainServiceA.functionA(a,b,c);
    repositoryA.save(a);
 }
}

public class DomainServiceA {
  public void functionA(a, b, c) {
    D d = a.functionA(b, c);
    b.functionB(d);
    coordinate(c, d);
    repositoryA.save(a);
 }
 
  public int coordinate(c, d) {
    // ...
  }
}

public class ATest {

  @Test
  public void functionATest(){
    // given 
    A a = new A();
    B b = new B();
    C c = new C();

    // when
    a.functionA(b, c);
    
    // then
    assert(...);
 }
}

public class BTest {

  @Test
  public void functionATest(){
    // given 
    B b = new B();
    D d = new D();

    // when
    b.functionB(d);
    
    // then
    assert(...);
 }
}

public class DomainServiceATest {
  @Test
  public void functionATest() {
    // given 
    A a = new A();
    B b = new B();
    C c = new C();
    
    // when 
    DomainServiceA domainServiceA = new DomainServiceA();
    domainServiceA.functionA(a,b,c);
    
    // then
    assert(...);
 }
 
  @Test
  public int coordinateTest() {
    // given 
    C c = new C();
    D d = new D();
    
    // when 
    DomainServiceA domainServiceA = new DomainServiceA();
    domainServiceA.coordinate(c, d);
    
    // then
    assert(...);
  }
}

서비스를 대신하는 중재자인 도메인 서비스를 만든다는 아이디어입니다! 이 간단한 아이디어를 8개월동안 고민한 지금에서야 깨달았습니다.

 

솔직히 저도 답이 무엇인지는 모르겠습니다. 하지만 여기까지 얻은게 제가 얻은 인사이트입니다. 프로젝트가 처음부터 DDD 접근 방식으로 설계가 되었다면 좋았겠지만 이미 성숙해지고 있는 프로젝트를 완전히 갈아엎는 것은 불가능합니다. 결국 좋은 아이디어를 차용해다 현재 프로젝트에 맞는 방식에 얹어가며 저희만의 방법을 찾아야 합니다. 그리고 지금 이야기한 방식은 적어도 현재의 프로젝트에 테스트를 보강할 수 있는 방식이라는 판단이 들었습니다. 프로젝트 리더분의 허락을 받은 상태이며 한번 조금씩 개선해볼까합니다.

 

아직 해결되지 않은 많은 고민들도 있습니다. 여전히 중형 테스트는 어떻게 작성해야할지 오리무중이며 대형 테스트를 할 때는 서비스간의 연결관계를 생각하면 막막합니다. 하지만 지금 당장 시급해보이는 것은 소형 테스트의 부재입니다. 소형 테스트를 쌓고 중형 테스트와 대형 테스트는 그때가서 하나씩 해결해보려합니다. 애초에 이 시리즈의 포스팅 목적이 이런 고민의 흔적을 남겨보고 싶은 것이기도 하기 때문입니다.

 

그러한 고로 테스트 전환기 시작합니다.