본문 바로가기
Kotlin

fixture monkey 로 예외 발생 테스트

by ybs 2021. 12. 15.
반응형

fixture monkey 는 테스트 객체 라이브러리다. fixture 를 자동으로 만들어준다. 사용 예제를 하나 보자. 아래 Order 객체가 있다.

import lombok.Data;

@Data   // lombok getter, setter
public class Order {
	@NotNull
	private Long id;

	@NotBlank
	private String orderNo;

	@Size(min = 2, max = 10)
	private String productName;

	@Min(1)
	@Max(100)
	private int quantity;

	@Min(0)
	private long price;

	@Size(max = 3)
	private List<@NotBlank @Size(max = 10) String> items = new ArrayList<>();

	@PastOrPresent
	private Instant orderedAt;

	@Email
	private String sellerEmail;
}

 

fixutre monkey 로 Order 객체를 만들면 아래와 같이 랜덤한 값들로 채워진다.

@Test
fun sampleTest() {
    for (i:Int in 1..5) {
        println(fixture.giveMeOne(Order::class.java))
    }
}
Order(id=1791, orderNo=Z:::gx2P:s7<"dDXeB, productName=, quantity=74, price=48, items=[?E, V, G`], orderedAt=2020-12-18T14:01:18.092Z, sellerEmail=SZ5{`9aZciQZtLaxJ|3~=9P@k.pJAj5HwNCMlOU2aR0Gelq.Nr1Kr8DtlzT71FEoFJ.fgLIobSoi5HQmRz2rsB4MmphmJNsIX1Y.HootI0TbEhbGFU6tK.ksKE3pmrnOzkAXcwTluhzA-bYYf2a8zsEKUW6QUDUNctEZA2q-ZTKxA.07us2lS.VVt4MEd.r4LbSFKf9T60-V.CQP1nkXBC3JpM0ZCzlrqLqIPJhmMdAfjrctqweDLKSsdg.a.OTlITLSa47l2.vMxRBEua8g)
Order(id=34, orderNo=f, productName=s(ZPS1], quantity=74, price=6796303258064044416, items=[}, 4], orderedAt=2020-12-19T14:21:03.093Z, sellerEmail=V-3C@MEdrEzwr4.ua8g1IgsZyzXeWJr.Gz6jTFh-luzdJ.PHqHGfAorEUBfc2VSkgz0R.kSzDrNPjGR0f0JkhAeK1SLg.XD5fdB0jlbvOT6rfRJvfLNsSS2a2Aq5sHl-aXK5XvTMso.4HCHQ3KnKkOxIWKWZ1ylDScjfHt.FvBr7jbzvl5LrEbNKnOEjuOeFoc.O.cdf3nx3mgLcjG.lzi5tdeocuK.cPLbM2GnSnHOyb.AbCsQ4ultDfn.1T)
Order(id=7, orderNo=BFd,oe)QNBv, productName=), quantity=9, price=4233741, items=[!], orderedAt=2020-12-18T13:57:42.426Z, sellerEmail="af(V*"@z.bSFKf9T6.Q.P1nkXBC.0ZC.LqIPJhmMdAfjrctqweDLKSsdg9.cKuO)
Order(id=17214906761180, orderNo=D`k:~#US, productName=(, quantity=27, price=3820489488235044924, items=[5>Ej, 8by0, (9xl,], orderedAt=2020-12-19T12:31:28.677Z, sellerEmail=0@[95.247.26.1])
Order(id=-12413378131691, orderNo=5J0DK0B"n0I>%\3=, productName=nZJw[vC, quantity=96, price=235133537, items=[KB], orderedAt=2021-06-10T19:36:08.496Z, sellerEmail=}A=tLf/}OovfZ%D*Mx$Ba9.Z3ZDI&~&Rcz%l0aUZ=bAa4ZxFm^^ED*zv@lOhRf9UWrkcKOrb.jjFbtRyUX18U-OjDx.Et5B07BzbU39zr8n6B1WoyvBPrf.wlC2aSc.m1b.jPRA4EcbnIybiWyELV.z3KByINgip8fKpuWVMsQ0Uc4SZgEhP.PSutPECwO5BgIR.lVmhpQScJwqbg2ouPQEZKJMO.wjr4BwE3e.z1STigPwZYQKvNWdrLn5PdBglmj0Xtu.aIBlBUYdyPPlKuydX7B.Mv8JjFs4.91XXW)

 

테스트하고 싶은 코드는 아래와 같다. check 메서드에서, 파라미터로 전달받은 type 이 서로 일치 하지 않으면 예외를 발생시키도록 작업했다. 그리고 의도한대로 예외가 발생하는지 테스트하고 싶다.

 

cf) 프로덕션 코드는 자바, 테스트 코드는 코틀린으로 만들었다.

@Service
@RequiredArgsConstructor
public class TypeCheckService {

	public void check(TypeCheckSource source, Type type) {
		if (source.getType() != type) {
			throw new RuntimeException("ybs");
		}
	}
}

 

@Value
public class TypeCheckSource {
	long id;
	Type type;
}

 

public enum Type {
	TYPE_1,
	TYPE_2,
	TYPE_3,
	TYPE_4;
}

 

다음으로 테스트 코드를 보자. ParameterizedTest 는 하나의 테스트 메서드로 여러개의 파라미터에 대해서 테스트 할 수 있다. 그래서 Type enum 4개의 값을 다 테스트 돌릴 수 있고, type 파라미터로 전달된다.

 

이제 typeCheckService.check(source, type) 결과를 확인하기 위해선 핵심이 되는 source 객체를 셋팅해야 한다. 자세한 내용은 뒤에서 설명하겠다.

class TypeCheckTest : SpringContextTest() {

    @ParameterizedTest
    @EnumSource(value = Type::class, names = ["TYPE_1", "TYPE_2", "TYPE_3", "TYPE_4"])
    fun typeNotMatchesThrows(
        type: Type,
        @Autowired typeCheckService: TypeCheckService
    ) {
        val source = fixture.typeCheckDomainFixture(
            type = type
        )
            .apply {
                typeCheckSourceBuilder.set(
                    "type",
                    Arbitraries.of(Type::class.java)
                        .filter { it != type }.sample()
                )
            }
            .typeCheckSource

        thenThrownBy { typeCheckService.check(source, type) }
            .isExactlyInstanceOf(RuntimeException::class.java)
    }
}

 

fixture 객체는 SpringContextTest 추상 클래스에서 생성했다.  

@SpringBootTest(
    classes = [FixtureToyApplicationTests::class]
)
@ActiveProfiles("test")
abstract class SpringContextTest {

    @Autowired
    lateinit var objectMapper: ObjectMapper

    lateinit var fixtureMonkeyBuilder: FixtureMonkeyBuilder

    protected val fixture: FixtureMonkey by lazy {
        this.fixtureMonkeyBuilder.build()
    }

    @BeforeEach
    fun setUp() {
        this.fixtureMonkeyBuilder = fixtureMonkeyBuilder(this.objectMapper)
    }
}

 

fixtureMonkeyBuilder 는 아래와 같이 만들었는데 JsonMappers.OBJECT_MAPPER 는 일반적인 ObjectMapper 객체다. 그리고 registerGroup(BuilderGroup) 부분은 이번 테스트하고는 상관없어서 다음번에 설명하겠다. 일단은 BuilderGroup 이름의 빈껍데기 클래스를 만들어놓는다.

fun fixtureMonkeyBuilder(
    objectMapper: ObjectMapper = JsonMappers.OBJECT_MAPPER
): FixtureMonkeyBuilder = KFixtureMonkeyBuilder()
    .defaultGenerator(JacksonArbitraryGenerator(objectMapper))
    .registerGroup(BuilderGroup::class.java)

 

이제 다시 테스트코드로 돌아와서 typeCheckDomainFixture 를 살펴보자.

val source = fixture.typeCheckDomainFixture(
    type = type
)
    .apply {
        typeCheckSourceBuilder.set(
            "type",
            Arbitraries.of(Type::class.java)
                .filter { it != type }.sample()
        )
    }
    .typeCheckSource

 

typeCheckDomainFixture 는 TypeCheckDomainFixture data class 객체 를 리턴하는데 typeCheckSourceBuilder 를 by lazy 로 만든다. 그리고 테스트코드에서 Arbitrary filter를 이용해, 전달받은 type enum 과 다른 enum 값으로 채워서 항상 예외가 발생하게 만든다.

fun FixtureMonkey.typeCheckDomainFixture(
    type: Type
) = TypeCheckDomainFixture(
    type = type
)

data class TypeCheckDomainFixture(
    val type: Type
) {
    val typeCheckSourceBuilder: ArbitraryBuilder<TypeCheckSource> by lazy {
        fixtureMonkeyBuilder()
            .build()
            .giveMeBuilder(TypeCheckSource::class.java)
            .set("id", 1)
            .set("type", TYPE.TYPE_1)
    }

    val typeCheckSource: TypeCheckSource
        get() = typeCheckSourceBuilder.sample()
}

 

by lazy 에 대해서 조금만 더 얘기해보자. typeCheckSource 가 호출되어 typeCheckSourceBuilder.sample() 이 실행되야 fixture monkey 라이브러리가 TypeCheckSource 객체에 값을 채워준다. 지금 테스트 코드가 하나만 있어서 필요성이 체감 안되지만 여러 테스트에서 typeCheckSource 를 같이 쓴다고 생각했을 때 공통적인 부분을 by lazy 로 묶어서 일종의 싱글톤처럼 관리하고자 했다.

 

 

마지막으로 테스트 하는것에 비해서 만들어진게 많다고 느낄 수 있는데, 다른 테스트에서도 공통적으로 사용하기 위해서 구조를 먼저 만들다보니 그렇게 됐다. 다른 테스트들을 또 만들면서 DomainFixture 를 채워나갈 예정이다.

 


lombok @Value 애노테이션을 delombok 해보면 아래와 같다.

@Value
public class TypeCheckSource {
	long id;
	Type type;
}

 

final 클래스이고 멤버변수들도 private final 이다. 생성자로 주입받고 getter 만 존재한다. 즉 Immutable 이 보장된다.

public final class TypeCheckSource {
	private final long id;
	private final Type type;

	public TypeCheckSource(long id, Type type) {
		this.id = id;
		this.type = type;
	}

	public long getId() {
		return this.id;
	}

	public Type getType() {
		return this.type;
	}

	public boolean equals(final Object o) { 생략 }

	public int hashCode() { 생략 }

	public String toString() { 생략 }

 

그래서 그냥 테스트를 돌리면 아래와 같은 에러를 만나게 된다.

Cannot construct instance of `com.fixturetoy.TypeCheckSource` 
(no Creators, like default constructor, exist): 
cannot deserialize from Object value (no delegate- or property-based Creator)

 

json data 를 TypeCheckSource 객체로 deserialize 해야하는데 적절한 생성자를 찾지 못했다는 에러다. 3가지 해결책이 있고 그중에서 @ConstructorProperties 방식을 사용했다. 

@Value
public class TypeCheckSource {
	long id;
	Type type;

	@ConstructorProperties({"id", "type"})
	public TypeCheckSource(long id, Type type) {
		this.id = id;
		this.type = type;
	}
}

 

하지만 위와 같이 직접 코딩하는 방식 대신 lombok 을 이용했다. 프로젝트 루트 lombok.config 파일에 lombok.anyConstructor.addConstructorProperties=true 설정을 추가했다. 이 설정을 true로 하면 생성자에 @ConstructorProperties 을 자동으로 붙여준다.

 

그런데 한가지 주의할점이 있다. @Value 를 통해 만들어진 생성자를 안쓰고 직접 만든 생성자를 쓸 경우 롬복은 @ConstructorProperties 을 붙여주지 않는다. 그래서 직접 만든 생성자를 사용해야 하는 경우에는 lombok.anyConstructor.addConstructorProperties=true 설정이 있더라도 @ConstructorProperties 를 직접 추가해줘야 한다.

@Value
public class TypeCheckSource {
	long id;
	Type type;

	// 이렇게 직접 생성자 추가하면 롬복이  @ConstructorProperties 추가 안해줌
	public TypeCheckSource(long id, Type type) {
		this.id = id;
		this.type = type;
	}
}
반응형