엔티티의 id를 테스트에서 어떻게 세팅해야 할까?

홍혁준·2023년 8월 8일
2

문제 상황

현재 Spring boot와 Spring Data Jpa를 사용하여 프로젝트를 진행하고 있었습니다.
그러다가 하나의 문제에 직면했는데요 바로 다음과 같습니다.

아래와 같은 Participant와 Member라는 Entity가 있습니다.

@Entity
@Getter
public class Participant{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id
	
	@ManyToOne
	@JoinColumn(nullable = false)
	private Member member;

	@ManyToOne
	@JoinColumn(nullable = false)
	private Event event;

	public void validOwner(final Member member){
		if(this.member.isSameMember(member)){
		    throw new EventException(EvetExceptionType.PARTICIPANT_NOT_ONWER);
		}
	}
	/* 다른 코드들*/
}

@Entity
@Getter
public class Member{

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id

	/* 다른 모드들*/
	public boolean isSameMember(final Member member){
		return this.getId().equals(member.getId());
	}
}

위와 같이 작성하고 진행하던 중 validateOwner를 테스트하는 중에 문제가 생겼습니다.

validateOwner를 테스트하기 위해선, member의 Id가 세팅되어 있어야 한다는 문제였죠.

service 레이어의 통합테스트에선 문제가 되지 않습니다.
영속성 컨텍스트를 이용해서, Id 값을 세팅해줄 수 있기 때문이죠.

하지만, Entity 단위테스트에선 어떻게 해야 할까요?

다양한 시도들

프로덕션 코드에서는 Entity 객체의 Id를 세팅하는 방법은 오로지 영속성 컨텍스트를 이용한 방법뿐입니다.

다른 방법으로는 세팅할 수 없죠. 하지만, 단위테스트에서는 영속성 컨텍스트를 동작시키진 않습니다.

즉 다른 방식으로 세팅해야 한다는 것이었죠.

Id를 세팅하기 위해서 다양한 방법을 시도해봤습니다.

Id를 받는 constructor를 정의

가장 간단한 방법입니다. Entity 클래스에 Id를 파라미터로 받는 생성자를 정의하면 됩니다.

@Entity
@Getter
public class Member{

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id

	public Member(final Long id){
		this.id = id;
	}
	
	/* 다른 모드들*/
	public boolean isSameMember(final Member member){
		return this.getId().equals(member.getId());
	}
}

장점

  • 간단하게 구현할 수 있다.

단점

  • 프로덕션에서도 의도치 않게, 생성자를 사용할 수도 있다.

이 방법의 가장 강한 강점은 만들기 쉽다는 겁니다. 그냥 생성자를 정의하면 되죠.

하지만, 가장 큰 단점 또한 갖고 있습니다. 테스트를 위해서 프로덕션의 코드를 변경했을 뿐 아니라, 프로덕션에서 잘못 쓸 위험 또한 갖고 있죠.

실제로 DB에 영속화되지 않은 값인데, id값을 갖는 객체가 생길수도 있다는 단점이 너무 커서 이 방법을 가장 먼저 떠올렸지만 사용하지 않았습니다.

접근제어자로 생성자를 호출할 수 있는 곳을 한정지으려고 해봤지만,
Member Entity를 의존하는 Participant Entity의 패키지가 서로 달랐기에, 문제가 됬습니다.

EntityProxy 정의

두 번째 방법은 EntityProxy를 정의하는 방법입니다. proxy를 정의하는 방법은 크게 두 가지가 있죠.
상속과 합성입니다.

상속

상속으로 구현한다면 다음과 같이 할 수 있을 것입니다.

@Entity
@Getter
public class Member {

	/* 다른 코드들*/
	protected void setId(final Long id){
		this.id=id;
	}
}


public class MemberProxy extends Member {

  public MemberProxy(final Long id) {
    //실제로 Member 생성자엔 다양한 파라미터가 들어갔으나 설명을 위해 생략
    super();
    super.setId(id);
  }
}

MemberProxy 객체는 테스트 패키지에 정의해두고 사용할수도 있겠죠.

장점

  • 구현이 비교적 쉽다.
  • 구현된 MemberProxy는 테스트 패키지에서만 존재하므로, 프로덕션에 큰 영향을 끼치진 않는다.
  • 상속했기에, Member가 쓰이는 곳 어디에서든 쓰일 수 있다.

단점

  • 프로덕션에 protected setter를 만들어야 한다.

상속을 이용한 방법 또한 생성자를 이용한 방법보단 덜하더라도, 프로덕션에 영향이 가긴 합니다.
비록 protected로 제한을 해두긴 하였으나, 어느정도 개방했다는 점에서 별로 좋게 느껴지진 않습니다.

합성

합성으로 구현한다면 다음과 같이 할 수 있을 것입니다.

@Entity
@Getter
public class Member{

	/* 다른 코드들*/
}


public class MemberProxy {

  private final Long id;
  private final Member member;

  public MemberProxy(final Long id) {
    this.id = id;
    this.member = new Member();
  }

  public Long getId(){
    return id;
  }
}


participant.validateOwner(memberProxy); -> 타입 불일치로 오류

합성으로 구현하면, 프로덕션에 가는 영향이 없긴 하지만, 타입 불일치로 pairticipant.validateOwer에서 에러가 발생합니다.

그래서 둘을 적절히 혼합하는 방법을 생각해봤고 아래처럼 구현해봤습니다.

최종 프록시

public class MemberProxy extends Member {

  private Long id;

  public MemberProxy(final Long id) {
    //실제로 Member 생성자엔 다양한 파라미터가 들어갔으나 설명을 위해 생략
    super();
    this.id = id;
  }

  @Override
  public Long getId(){
	return id;
  }
}

장점

  • 프로덕션에 영향이 하나도 가지 않는다.
  • 테스트를 위해 생성한 클래스이기에 편하게 조작할 수 있다.

단점

  • 테스트를 진행할 때마다 매번 새로운 클래스를 정의하고 이를 관리해야 한다.

위 방법처럼 하면 드디어 프로덕션에 하나도 영향을 가지 않고 테스트를 진행할 수 있습니다.
하지만, 매번 단위테스트를 할 때마다 프록시 클래스를 정의해야 한다는 단점이 있죠.

Mockito Spy

프록시 객체를 정의하는 방법이 프로덕션에 영향이 안 가고 좋긴하나, 매번 새로운 클래스를 정의해야 한다는 번거로움이 있습니다.

어떻게 간편화 할 수 없을까? 고민하다가, 가짜 객체를 쉽게 만들어주는 Mockito를 떠올렸습니다.

단순 mock 객체를 만드는 건 문제가 있습니다. 저희가 원하는 건 id값에 대한 부분이지, 다른 메서드까지 모킹할건 아니었으니까요.

만약 Mockito.mock으로 member 객체를 생성한다면, member에서 메서드가 추가될 때마다 스터빙을 하는 코드를 작성해야 할 것이고, 이는 오히려 프록시 객체를 만드는 것보다 더 많은 소요를 발생시킬 것입니다.

Mockito의 spy는 기존 객체를 그대로 들고 있으면서 특정 메서드만 스터빙 할 수 있는 객체입니다.
이를 이용하면 다음과 같이 정의할 수 있죠.

class ParticipantTest {

  private Member member;
  private Event event;

  @BeforeEach
  void setUp() {
  //각각의 fixture들은 id값이 세팅되지 않은 객체를 반환합니다.
    member = Mockito.spy(TestFixture.memberFixture());
    event = Mockito.spy(TestFixture.eventFixture());
    when(event.getId()).thenReturn(1L);
    when(member.getId()).thenReturn(2L);
  }
}

이렇게 spy를 이용하면 별도의 테스트를 위한 클래스를 만들 필요도 없기에 유지보수 할 필요도 없고, Id값을 원하는 방식으로 세팅할 수 있습니다.

장점

  • 별도의 클래스 파일을 만들고 관리할 필요가 없다.
  • 생성이 매우 간편하다.

단점

  • 아직 잘 모르겠다.

결론

Jpa 를 사용하다 보니 도메인도 Jpa에 맞춰서 코드를 작성해야 했습니다.
그러다보니 Jpa에 도메인이 의존적으로 바뀌었고(Id),
Jpa가 쓰이지 않는 단위테스트에서 어떤식으로 테스트를 해야 프로덕션에 영향이 덜가면서 신뢰할 수 있는 테스트를 작성할 수 있나 많은 고민을 했습니다.

다양한 방법을 시도해본 결과, spy를 이용한 방법이 젤 좋다고 생각했습니다.

다른 좋은 방법이 생각나지 않는 한 Jpa를 사용하는 프로젝트에서 다음과 같이 값을 세팅하여 프로젝트를 진행할 것 같습니다.

Jpa를 사용하는 프로젝트가 아니면 다르게 엔티티를 생성할테니 id를 받는 생성자를 열어둘 것 같습니다.

다 쓰고나서 보니, 너무 JPA 의존적인 개발 방식인 것 같네요.

상황에 따라서 잘 취사선택해야할 것 같습니다.

글에 대한 조언 또는 지적은 언제나 환영입니다. 댓글로 편하게 남겨주세요

profile
끊임없이 의심하고 반증하기

2개의 댓글

comment-user-thumbnail
2023년 8월 8일

많은 것을 배웠습니다, 감사합니다.

답글 달기
comment-user-thumbnail
2024년 10월 15일

spy 단점: 테스트 시간이 비교적 오래 걸린다.
이지 않을까요? ㅎㅎ 세심한 비교글 감사합니다!

답글 달기