비밀번호 검증용 Custom validation 어노테이션을 만들고 테스트까지
728x90
반응형

스프링부트로 만든 서비스에서 다음과 같은 조건을 만족하려고 한다.

  • 전제 : 스프링부트 + 코틀린(Kotlin)
  • API에 받는 RequestBody의 모델에 비밀번호 필드를 포함한다.
  • 비밀번호 필드는 Custom validation 어노테이션을 통해 검증을 한다.
  • 정상적으로 동작하는지 단위테스트를 만든다.
    • 해당 테스트에는 @BeforeAll, @AfterAll을 사용한다.

 

먼저 테스트를 작성해보자

Custom validation 어노테이션을 검증하기 위한 유닛테스트를 만들어야 하기에 Validator를 사용해서 검증해야 한다.

internal class UserPasswordModelTest {

    lateinit var validatorFactory: ValidatorFactory

    lateinit var validatorFromFactory: Validator

}

 

validatorFactory와 validatorFromFactory는 한번만 초기화를 하면 되기 때문에 @BeforeAll, @AfterAll을 사용하면 된다. Kotlin에서는 테스트 생명주기가 함수단위로 되어 있다. 그래서 이 2개의 어노테이션을 사용하기 위해서는 static 메소드 호출이 가능하도록 companion object와 JvmStatic을 사용해야 한다. 하지만 개인적으로는 TestInstance를 통해 테스트 생명주기를 변경하는 방식을 더 선호한다.

 

@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class UserPasswordModelTest {

    lateinit var validatorFactory: ValidatorFactory

    lateinit var validatorFromFactory: Validator


    @BeforeAll
    fun init() {
        validatorFactory = Validation.buildDefaultValidatorFactory()
        validatorFromFactory = validatorFactory.validator
    }

    @AfterAll
    fun close() {
        validatorFactory.close()
    }
}

 

테스트 코드를 작성해보자. 먼저 정상적인 패스워드를 입력했을 때를 확인하는 테스트 코드이다.

@Test
fun `PasswordValid 어노테이션 - 비밀번호 입력 정상 조건 검증`() {
    val model = UserPasswordModel(
        password = "1q2w3e4r%%",
        confirmPassword = "1q2w3e4r%%",
        token = "correct_token"
    )
    val violations = validatorFromFactory.validate(model)
    Assertions.assertThat(violations).isEmpty()
}

 

다음은 비정상적인 패스워드를 검증하기 위한 테스트 코드이다. 여러가지 경우를 확인해볼 필요가 있어서 ParameterizedTest를 사용했다. 테스트 함수의 인자에는 @MethodSource에 들어갈 함수의 응답값의 필드와 일치해야 한다. 아래 경우에는 세부 테스트명을 message 인자로 두고, 실제 검증할 인자(UserPasswordModel)를 그 다음에 넣었다.

@ParameterizedTest(name = "{index}: {0}")
@MethodSource("invalidParameters")
fun `PasswordValid 어노테이션 - 비밀번호 입력 비정상 조건 검증`(
    message: String,
    model: UserPasswordModel
) {
    val violations = validatorFromFactory.validate(model)
    Assertions.assertThat(violations).isNotEmpty
    if (violations.isNotEmpty()) {
        violations.forEach {
            it?.let {
                println(it.message)
            }
        }
    }
}

val validUserPasswordModel = UserPasswordModel(
    password = "1q2w3e4r%%",
    confirmPassword = "1q2w3e4r%%",
    token = "correct_token"
)

fun invalidParameters() = listOf(
    Arguments.of(
        "최소 글자수 미달 (8자 이상)",
        validUserPasswordModel.copy(
            password = "2w3e$$"
        )
    ),
    Arguments.of(
        "부족한 조합(영문)",
        validUserPasswordModel.copy(
            password = "12341234%%"
        )
    ),
    Arguments.of(
        "부족한 조합(숫자)",
        validUserPasswordModel.copy(
            password = "abcde$%^$%^"
        )
    ),
    Arguments.of(
        "부족한 조합(특수기호)",
        validUserPasswordModel.copy(
            password = "1q1q1q1q1q"
        )
    )
)
주의할 것이 있다. 위의 경우에는 TestInstance의 생명주기를 변경해서 위와 같이 사용할 수 있는 것이다. 만약 TestInstance를 별도로 세팅하지 않았을 경우에는 companion object와 @JvmStatic를 사용해야 한다.

 

테스트 코드 작성이 완료되었다. 이대로 테스트를 실행해보면 정상 확인 테스트는 성공이되고 비정상 확인 테스트는 실패가 된다. 정상 확인 테스트가 성공하는 것에 의문이 들 수 있다. violations 변수는 실패정보를 담는 ConstraintViolation 객체 형태를 띄고 있다. 이 객체에 정보가 있는지 여부로 성공과 실패를 구분하는데 검증할 로직이 없기에 실패정보 자체가 생성될 수 없다. 그래서 성공을 띄게 된다. 뒤에서 테스트를 수정하여 디테일하게 확인해보도록 하자.

 

 

모델 생성

위에서 나타난 UserPasswordModel을 만들자.

data class UserPasswordModel(
    val password: String,
    val confirmPassword: String,
    val token: String
)

password 필드 위에 Custom validation 어노테이션을 추가해볼 것이다.

 

 

Custom validation 어노테이션

커스텀 어노테이션 만드는 방식과 유사하다. 여기에 ConstraintValidator를 얹히면 된다.

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [PasswordValidator::class])
annotation class PasswordValid(
    val message: String = "비밀번호는 영문, 숫자, 특수기호를 모두 포함한 8글자 이상이어야 합니다.",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = [],
)
주의할 점!! 위에서 등장한 3가지 인자는 필수값이다. javax.validation의 @interface Constraint를 보면 주석에 필요한 인자 설명과 예시도 나와있다. 직접 사용하지 않는다고 제외해서는 안된다.

 

실제 검증하는 로직이 있는 PasswordValidator를 구현해보자. ConstraintValidator 인터페이스를 구현하면 된다. 아래 value는 커스텀 어노테이션이 붙혀질 필드의 값이다. 이 경우에는 password 필드값이 value가 된다.

class PasswordValidator : ConstraintValidator<PasswordValid, String> {

    override fun isValid(value: String?, context: ConstraintValidatorContext?): Boolean {
        if (value == null) {
            return false
        }
        val pattern = Pattern.compile("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*\\W).{8,99}$")

        return pattern.matcher(value).find()
    }

}

 

비밀번호 입력 조건은 아래와 같다.

  • 숫자, 영문, 특수기호를 모두 조합해야 한다
  • 8글자 이상, 99자 이하이어야 한다

이를 만족하는 정규식을 만들고, 패턴에 매치가 되는지 확인(find 함수)하여 비밀번호를 검증한다. 이렇게해서 Custom validation 어노테이션을 완성했다. 이제 사용하고 실제로 테스트를 동작시켜보자.

 

 

사용해보자

먼저 Model에 적용한다. 아래처럼 어노테이션을 붙이면 끝이다.

data class UserPasswordModel(
    @PasswordValid
    val password: String,
    @PasswordValid
    val confirmPassword: String,
    val token: String
)

 

처음에 작성한 테스트를 동작시켜보자. 아마 2개의 테스트 모두 성공으로 나타날 것이다. 정상 테스트를 아래처럼 수정해보자.

@Test
fun `PasswordValid 어노테이션 - 비밀번호 입력 정상 조건 검증`() {
    val model = UserPasswordModel(
        password = "1q2w3e4r", // <--- 특수기호가 없다
        confirmPassword = "1q2w3e4r%%",
        token = "correct_token"
    )
    val violations = validatorFromFactory.validate(model)
    Assertions.assertThat(violations).isEmpty()
}

// 결과
java.lang.AssertionError: 
Expecting empty but was: [ConstraintViolationImpl{interpolatedMessage='비밀번호는 영문, 숫자, 특수기호를 모두 포함한 8글자 이상이어야 합니다.', propertyPath=password, rootBeanClass=class com.smartfoodnet.fnpartner.user.password.model.UserPasswordModel, messageTemplate='비밀번호는 영문, 숫자, 특수기호를 모두 포함한 8글자 이상이어야 합니다.'}]

이로서 정상 테스트와 비정상 테스트가 제대로 작성되었음을 확인할 수 있다.  아. 뿌듯.

 

 

참고자료
- Kotlin-Spring Boot Junit에서 @BeforeAll, @AfterAll이 호출되지 않는 이유
- Spring에서 Custom validation 테스트하기
- Kotlin + Junit5 에서 BeforeAll, AfterAll 사용하기
- 자바 비밀번호 정규식 패턴(Pattern) 메소드
- Spring validation(with kotlin)
- Spring Boot에서 Custom Valid Annotation 만들기
- Validation 어디까지 해봤니?

.

728x90
반응형