[Spring] Part 1-3-1. 나만의 MVC 프레임워크 만들기 | 테스트 코드란?
2023.01.09- -
1. 테스트코드 실습
테스트코드 실습에서는 자바 단위 테스팅 프레임워크인 JUnit5를 사용하도록 할 것입니다
JUnit5에 대해서 추가적인 학습을 원하시는 분들을 위해 아래 공식 문서 링크를 첨부해 두었습니다.
또한 테스트 코드 가독성을 높여주기 위한 자바 라이브러리인 AssertJ 또한 사용할 것인데요, 이 또한 관련 링크를 아래 첨부해 두었습니다.
테스트 코드 작성에 앞서서 테스트 코드를 작성해야 하는 이유는 무엇일까요?
그 이유는 개발자마다 각자의 이유가 존재하겠지만 통상적으로 이야기 하는 테스트코드의 용도 및 장점은 다음과 같은 것들이 존재합니다.
1. 문서화 역할
2. 코드에 결함을 발견하기 위함
3. 리팩토링 시 (심리적) 안전성 확보
4. 테스트 하기 쉬운 코드를 작성하다 보면 더 낮은 결합도의 설계를 얻을 수 있음
이 내용들은 이후에 실제 코드를 살펴보며, 어떤 부분이 몇 번에 해당하는 것인지 설명을 추가하도록 할 것입니다.
테스트 코드를 학습하다 보면, TDD라는 용어를 많이 들어보셨을텐데요.
TDD란 Test Driven Development의 약자로써 테스트 주도 개발을 의미합니다. 즉, 프로덕션 코드보다 테스트 코드를 먼저 작성하는 개발 방법인 것입니다.
- 기능 동작을 검증 (메소드 단위)
그래서 TFD(Test First Development)라고도 불리는데, 사실 이것들 보다 이후에 있는 리팩토링 과정이 더욱 중요합니다.
이와 달리, BDD라는 개발 방식은 Behavior Driven Development 즉, 행위 주도 개발로써 시나리오를 기반으로 테스트 코드를 작성하는 방식을 말합니다.
여기서 하나의 시나리오는 Given, When, Then 구조를 가집니다.
보통 개발을 할 때에는 TDD와 BDD를 혼용하여 진행한다고 합니다. 이 부분 또한 실습 코드를 통해 더 자세히 알게 될 것 입니다.
비밀번호 유효성 검증기
요구사항
- 비밀번호는 최소 8자 이상 12자 이하여야 한다.
- 비밀번호가 8자 미만 또는 12자 초과인 경우 IllegalArgumentException 예외를 발생시킨다.
- 경계조건에 대해 테스트 코드를 작성해야 한다.
실습에 앞서서 프로젝트를 새로 만들어 보시면 아시겠지만 build.gradle 파일에는 아래 그림과 같이 JUnit5는 이미 의존성이 추가가 된 모습을 보실 수 있으실 겁니다. 그렇다면 AssertJ만 따로 추가해 주도록 합시다.
testImplementation 'org.assertj:assertj-core:3.21.1'
위와 같은 디렉터리에서 main 부분의 코드들은 프로덕션 코드라고 하고 test 부분의 코드들은 테스트 코드라고 부릅니다.
먼저 테스트코드 부분의 패키지와 프로덕션 코드의 패키지는 맞춰주는 것이 좋기 때문에 test 폴더의 java 폴더 내에 org.example이라는 위와 똑같은 패키지를 만들어 그 안에 PasswordValidatorTest라는 자바 파일을 하나 만들어 주도록 합니다.
위 과정을 완료하면 아래 사진과 같은 모습을 보일 것입니다.
코드 블럭 안에서 (Window 기준) Alt + Insert 키를 누르면 아래와 같은 창이 뜨게 되고 Test Method를 선택하면 Test 메소드가 하나 생성됩니다.
또한 @DisplayName()을 통해 괄호 안에 요구사항을 적어줌으로써 아까 전 테스트 코드를 작성하는 이유 1번처럼 문서화 역할을 하게될 수 있는 것입니다.
public class PasswordValidatorTest {
@DisplayName("비밀번호가 최소 8자 이상, 12자 이하이면 예외가 발생하지 않는다.")
@Test
void validatePasswordTest() {
assertThatCode(() -> PasswordValidator.validate("123456789"))
.doesNotThrowAnyException();
}
}
일단 기본적인 test 코드를 작성해 보았는데요. assertThatCode를 통해 안의 lambda를 실행하게 되고 만약 유효한 비밀번호인 경우 아무런 예외를 던지지 않게끔 작성한 코드입니다.
- assertThatCode는 assertJ 라이브러리의 메소드로 코드의 가독성을 높여주는 역할을 합니다.(doesNotThrowAnyException)
이를 그럼 한번 실행해 보도록 하겠습니다. 실행할 때는 지난번 포스팅에서 서버를 구동시킨 것과 같이 해당 메소드 부분 옆에 있는 초록색 플레이 버튼을 눌러주시면 됩니다.
이를 실행하게 되면 위처럼 당연히 오류가 나게 됩니다. 저희가 PasswordValidator를 만들어주지 않았기 때문입니다.
그러면 당황하지 말고 위 TDD circle of life 사진에서 보셨던 것처럼 Test가 실패했으니, 재빨리 Test를 성공시키면 됩니다.
그러면 이제 해당 클래스를 만들어 보도록 하겠습니다.
빨간 글씨가 뜬다면 항상 Alt + Enter를 눌러 해결할 팁을 얻을 수 있습니다. 우리가 원하는 'Create class PasswordValidor'를 하겠습니다. 이는 당연히 main 부분의 프로덕션 코드 쪽에 만들어 주셔야 합니다.(단순 테스트 용이 아니라 실제 기능을 하는 클래스이기 때문에)
package org.example;
public class PasswordValidator {
public static void validate(String password) {
}
}
그러면 위 코드와 같이 password 인자를 받아 유효성을 검사하는 클래스를 만들어 낼 수 있고, 이제 테스트 코드로 가서 테스트를 진행해 보면, 통과하는 것을 확인할 수 있습니다.
먼저 코드를 이상하게 한번 짜보도록 하겠습니다.
public static void validate(String password) {
if (password.length() < 8 || password.length() > 12) {
throw new IllegalArgumentException("비밀번호는 최소 8자 이상 12자 이하여야 한다.");
}
}
그럴 땐 원하는 부분을 드래그 하고 Ctrl + Alt + v 커맨드를 입력하게 되면 위와 같은 창이 뜨고 현재 중복된 부분의 갯수 만큼 바꾸어 주는 옵션을 클릭하여 해당 부분이 가능해 집니다.
우리가 이렇게 리팩토링을 하게 되면 심리적인 안정감을 얻게 되는 데요.(앞선 테스트코드 사용 이유 3번!) 왜냐하면, 이렇게 리팩토링을 진행하고 나서 우리가 만들어 놓았던 테스트 코드가 통과하기만 하면 되기 때문입니다.
또한 다른 예시로, 안에 존재하던 "비밀번호는 최소 8자 이상 12자 이하여야 한다." 문구를 로컬 변수로 따로 빼는 리팩토링을 진행하고 싶다고 합시다.
그러면 IntelliJ에서는 Ctrl + Alt + c 커맨드를 통해 해당 기능을 수행할 수 있고 코드는 다음과 같이 될 것입니다.
public class PasswordValidator {
private static final String WRONG_PASSWORD_LENGTH_EXCEPTION_MESSAGE = "비밀번호는 최소 8자 이상 12자 이하여야 한다.";
public static void validate(String password) {
if (password.length() < 8 || password.length() > 12) {
throw new IllegalArgumentException(WRONG_PASSWORD_LENGTH_EXCEPTION_MESSAGE);
}
}
}
그러면 우리는 뭐만 하면 된다고 했죠? 바로 테스트 코드를 돌려보기만 하면 됩니다. 실제로 테스트는 아무 문제 없이 통과할 것입니다.
그럼 두 번째 테스트 코드를 만들어 보겠습니다.
@DisplayName("비밀번호가 8자 미만 또는 12자를 초과하는 경우 IllegalArgumentException 예외가 발생한다.")
@Test
void validatePasswordTest2() {
assertThatCode(() -> PasswordValidator.validate("123456"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("비밀번호는 최소 8자 이상 12자 이하여야 한다.");
}
- 즉 비밀번호 유효성에 위배되었을 때, 내가 설정한 것들이 제대로 이루어 졌는지 확인하는 것입니다.
- 위 예제에서는 IllegalArgumentException 라는 예외를 던져야 하고 경고 문자가 제대로 들어왔는지를 확인하는 것입니다.
위 예제로 미루어봤을 때 비밀번호가 8자 미만 또는 12자 초과인 경우 예외를 내기 때문에 7자인 경우와 13자인 경우(경계 값들)의 테스트 코드를 반드시 작성해 주어야 합니다.
그 이유는 경계값에 대해서 테스트가 통과 됨을 보장해야 이후에 문제가 될 여지를 남겨두지 않기 위함입니다.
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.8.2'
@ParameterizedTest에 대해서 자세한 설명은 아래 링크에서 확인하실 수 있습니다.
그리고 나서 @ParmeterizedTest 밑에 @ValueSource()를 추가해 줍니다. 그러면 다음과 같이 될 것입니다.
@DisplayName("비밀번호가 8자 미만 또는 12자를 초과하는 경우 IllegalArgumentException 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"1234567", "1234567890123"})
void validatePasswordTest2() {
assertThatCode(() -> PasswordValidator.validate("123456"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("비밀번호는 최소 8자 이상 12자 이하여야 한다.");
}
@ParameterizedTest는 여러 개의 data source를 사용하여 테스트 하는 것을 의미하고 @ValueSource로 안에 인자들을 어떤 식으로 줄 지 명시 한 후 validatePasswordTest2() 메소드에 인자로 String password와 같이 string 형태의 password가 들어올 것이다.라고 명시를 하면 password 변수에 앞서 @ValueSource에 지정해 둔 비밀번호 값들이 순차적으로 들어와 테스트를 진행하게 됩니다.
최종적인 test2 코드는 다음과 같습니다.
@DisplayName("비밀번호가 8자 미만 또는 12자를 초과하는 경우 IllegalArgumentException 예외가 발생한다.")
@ParameterizedTest
@ValueSource(strings = {"1234567", "1234567890123"})
void validatePasswordTest2(String password) {
assertThatCode(() -> PasswordValidator.validate(password))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("비밀번호는 최소 8자 이상 12자 이하여야 한다.");
}
해당 코드를 작성하고 실제 테스트를 돌려보면 아래와 같이 두 인자에 대해 성공적으로 테스트가 성공한 것을 볼 수 있으실 겁니다.
마무리를 슬슬 할 때가 되었는데 앞서 테스트코드를 작성하는 이유 4가지 중에서 2가지(문서화 역할, 심리적 안정감) 부분에 대해서는 어떤 것 때문에 그런지 설명을 드렸는데 나머지 2번의 '코드에 결함을 발견하기 위함'과 4번의 '테스트 하기 쉬운 코드를 작성하다 보면 더 낮은 결합도의 설계를 얻을 수 있음' 부분에 대한 설명은 하지 못했습니다.
이 둘 중 2번은 사실 테스트 라는 것 자체가 코드에 결함을 발견하기 위함 자체를 의미하기 때문에 추가적인 설명은 필요없을 것이라 생각이 들지만 4번에 대한 부분은 표현자체가 애매하기 때문에 따로 설명을 드리도록 하겠습니다.
설명에 앞서 4번의 경우를 만들어주기 위해 패스워드를 만들어주는 자바 라이브러리를 먼저 추가해 주도록 하겠습니다.
implementation 'org.passay:passay:1.6.1'
별도로 의존성이 제대로 추가되었는지 확인하기 위해서는 디렉터리에 External Libraries를 열어 제대로 라이브러리가 추가되었는지 확인할 수 있습니다.
그러면 랜덤한 패스워드를 생성해 주는 클래스를 만들어 보겠습니다.
package org.example;
import org.passay.CharacterData;
import org.passay.CharacterRule;
import org.passay.EnglishCharacterData;
import org.passay.PasswordGenerator;
public class RandomPasswordGenerator {
/**
* Special characters allowed in password.
*/
public static final String ALLOWED_SPL_CHARACTERS = "!@#$%^&*()_+";
public static final String ERROR_CODE = "ERRONEOUS_SPECIAL_CHARS";
public String generatePassword() {
PasswordGenerator gen = new PasswordGenerator();
CharacterData lowerCaseChars = EnglishCharacterData.LowerCase;
CharacterRule lowerCaseRule = new CharacterRule(lowerCaseChars);
lowerCaseRule.setNumberOfCharacters(2);
CharacterData upperCaseChars = EnglishCharacterData.UpperCase;
CharacterRule upperCaseRule = new CharacterRule(upperCaseChars);
upperCaseRule.setNumberOfCharacters(2);
CharacterData digitChars = EnglishCharacterData.Digit;
CharacterRule digitRule = new CharacterRule(digitChars);
digitRule.setNumberOfCharacters(2);
CharacterData specialChars = new CharacterData() {
public String getErrorCode() {
return ERROR_CODE;
}
public String getCharacters() {
return ALLOWED_SPL_CHARACTERS;
}
};
CharacterRule splCharRule = new CharacterRule(specialChars);
splCharRule.setNumberOfCharacters(2);
// 0 ~ 12
return gen.generatePassword((int) (Math.random() * 13), splCharRule, lowerCaseRule, upperCaseRule, digitRule);
}
}
조금 길긴 하지만 해당 클래스의 역할은 0~12자리의 random한 패스워드를 생성하는 클래스입니다.
이제는 유저가 비밀번호를 입력하는 상황을 만들어 보기 위해 User라는 Class를 만들어 보도록 하겠습니다.
package org.example;
public class User {
private String password;
public void initPassword() {
RandomPasswordGenerator randomPasswordGenerator = new RandomPasswordGenerator();
String randomPassword = randomPasswordGenerator.generatePassword();
if(randomPassword.length() >= 8 && randomPassword.length() <= 12) {
this.password = randomPassword;
}
}
}
위 코드는 패스워드를 랜덤으로 만들어서 if문의 조건을 만족할 때에만 패스워드가 세팅되는 기능을 하는데 그러한 기능이 제대로 작동되는 지 확인해 보기 위해 테스트 코드를 만들어 보겠습니다.
클래스에 대한 테스트 코드를 만드려면 아까 전과 같이 User에 커서를 둔 채로 Alt + Insert를 눌러 아래 그림과 같이 나오는 창에서 Test... 를 선택해 주면 됩니다.
이번에는 프로덕션 코드를 먼저 작성하고 테스트 코드를 작성한 방식으로 진행된 것입니다.
User 클래스에서는 password를 가져오는 Getter 메소드를 만들어줍니다.
class UserTest {
@DisplayName("패스워드를 초기화 다.")
@Test
void passwordTest() {
// given: 이런 User 객체가 주어졌고,
User user = new User();
// when: 이 메소드를 호출했을 때,
user.initPassword();
// then: 이런 경우를 기대한다.
assertThat(user.getPassword()).isNotNull();
}
}
UserTest 코드에서는 앞서 배웠던 given, when, then 에 기초하여 User를 생성하고 비밀번호 초기화 메소드를 실행했을 때 비밀번호가 정상적으로 초기화 되는지를 확인하는 코드입니다.
그렇게 테스트코드를 계속해서 실행해 보면 매번 다른 비밀번호에 대해 테스트를 진행하는데 어떨때는 실패하고 어떨때는 성공하고를 반복합니다.
그 이유는 우리가 8자에서 12자 사이로 범위를 지정할 때만 패스워드가 설정되도록 해 두었기 때문에 해당 범위에 들지 않는 무작위 패스워드가 만들어 지면 오류가 발생하게 되기 때문입니다.
그러면 어떤 식으로 테스트코드를 짜야하는 걸까요?
지금 테스트코드를 짜기 어려운 이유는 RandomPasswordGenerator에서 패스워드를 몇 글자의 패스워드를 만들어내는 지를 컨트롤 할 수 없기 때문입니다. 그러기 위해서 새로운 인터페이스(고정된 패스워드를 만드는)를 만들어 이를 제어해 보도록 하겠습니다.
그러기 위해서 main 부분에 PasswordGenerator 라는 인터페이스를 만들겠습니다.
또한 아래 그림처럼 RandomPasswordGenertor 부분에 PasswordGenertor 인터페이스를 구현받도록 합니다.
그러면 User에서는 RandomPasswordGenerator로부터 새로운 패스워드를 만들어 내는 것이 아니라 PasswordGenerator로부터 만들어지는데 PasswordGenerator 에는 generatePassword라는 메소드가 존재하지 않기 때문에 코드를 수정해 주어야 합니다. 그러면 해당 메소드를 일단 만들어 줍니다.
그렇게 되면 내 구현체가 어떤 것인지는 모르겠지만 generatePassword라는 메소드를 실행하면 password를 받고 그 패스워드를 validate 하는 과정을 진행해 나가는 것입니다.
그러면 올바른 패스워드만을 고정적으로 반환하는 CorrectFixedPasswordGenerator와 틀린 패스워드만을 고정적으로 반환하는 WrongFixedPasswordGenerator를 test부분에 만들어 각각에 대해 test를 진행하도록 하겠습니다.
<CorrectFixedPasswordGenerator>
package org.example;
public class CorrectFixedPasswordGenerator implements PasswordGenerator{
@Override
public String generatePassword() {
return "12345678"; // 8글자 패스워드
}
}
<WrongFixedPasswordGenerator>
package org.example;
public class WrongFixedPasswordGenerator implements PasswordGenerator{
@Override
public String generatePassword() {
return "12"; // 2글자 패스워드
}
}
위 두 코드는 단순하게 generatePassword 메소드를 실행시키면 각자에게 할당된 옳고, 틀린 패스워드를 반환하게 됩니다.그래서 두 클래스는 Passwordgenerator라는 구현체를 상속받게 되는 것입니다.
<UserTest>
class UserTest {
@DisplayName("패스워드를 초기화한다.")
@Test
void passwordTest() {
// given: 이런 User 객체가 주어졌고,
User user = new User();
// when: 이 메소드를 호출했을 때,
user.initPassword(new CorrectFixedPasswordGenerator());
// then: 이런 경우를 기대한다.
assertThat(user.getPassword()).isNotNull();
}
@DisplayName("패스워드가 요구사항에 부합하지 않아 초기화가 되지 않는다.")
@Test
void passwordTest2() {
// given: 이런 User 객체가 주어졌고,
User user = new User();
// when: 이 메소드를 호출했을 때,
user.initPassword(new WrongFixedPasswordGenerator());
// then: 이런 경우를 기대한다.
assertThat(user.getPassword()).isNull();
}
}
또한 테스트 코드 역시 각각에 해당하는 테스트 코드를 만들어 initPassword 부분의 인자로 앞서 만든 두 클래스를 각각 주입하게 되는 것입니다.
테스트 결과 역시 돌릴 때마다 통과하게 됩니다.
이를 통해 알 수 있는 점은 무엇일까요?
기존에는 내부에서 RandomPasswordGenerator를 받고 있었는데 조건에 부합한다면 초기화를 하고 그렇기 않다면 초기화를 하지 않았습니다.
이렇게 되면 테스트를 하려면 컨트롤 하기가 굉장히 까다로웠고 테스트를 돌릴 때마다 결과가 다르게 나오는 상황이 발생하였습니다.
그리하여, 상위에 PasswordGenerator 라는 인터페이스를 구현하였고, 실제로 운영에서는 RandomPasswordGenerator로 주입해 주겠지만 테스트코드를 위해서 PasswordGenerator를 구현한 CorrectFixedPasswordGenerator(항상 제대로 된 비밀번호 반환)와 WrongFixedPasswordGenerator(항상 잘못된 비밀번호 반환)가 RandomPasswordGenerator 대신 사용된 것입니다.
물론 실제로 운영 단계에서는 고정적인 패스워드가 들어오는 것이 아니기 때문에 Random한 패스워드가 들어오겠지만
테스트 단계에서는 구현한 기능이 제대로 동작하는지 검증하기 위해서 테스트하기 쉬운 코드를 만들어 내는 것입니다.
그럼 마지막으로 4번의 문장을 다시 한번 보겠습니다.
- 테스트 하기 쉬운 코드를 작성하다 보면 더 낮은 결합도의 설계를 얻을 수 있음
// as-is
RandomPasswordGenerator randomPasswordGenerator = new RandomPasswordGenerator();
// to-be
String password = passwordGenerator.generatePassword();
앞서 살펴 본 initPassword 메소드 부분에서 보면 알 수 있듯이 초기에는 as-is 방식을 사용하여 내부에서 생성하는 패스워드이기 때문에 강한 결합이라고 할 수 있습니다.
그렇기 때문에 해당 부분에 대해서 영향을 굉장히 많이 받을 수밖에 없는데, 이러한 강결합을 더 느슨한 결합을 만들기 위해 상위에 Interface를 두었고 그로 인하여 더 이상 RandomPasswordGenerator에 대해 의존을 가지지 않게 되기 때문에(import도 존재하지 않음) 해당 결합을 끊을 수 있게 되는 것입니다.
해당 과정을 통해 테스트코드 또한 맘편히 작성할 수도 있던 것입니다.
그리고 사실 여기서 구현한 인터페이스는 Functional Interface이기 때문에 Interface를 구현할 필요도 없이 CorrectFixedPasswordGenerator와 WrongFixedPasswordGenerator 대신에 lambda를 이용해서 구현하는 것도 가능합니다. 바로 아래 코드와 같이 말입니다.
// when: 이 메소드를 호출했을 때,
user.initPassword(() -> '1234567');
// when: 이 메소드를 호출했을 때,
user.initPassword(() -> '12');
'Back-end > Spring' 카테고리의 다른 글
[Spring] Part 1-4-2. 나만의 MVC 프레임워크 만들기 | CGI 프로그램과 서블릿 (Servlet) (0) | 2023.01.13 |
---|---|
[Spring] Part 1-4-1. 웹 애플리케이션 이해 (+계산기 프로그램 실습) (0) | 2023.01.13 |
[Spring] Part 1-3-2. 나만의 MVC 프레임워크 만들기 | 객체지향 개념 다지기 + 실습 (0) | 2023.01.09 |
[Spring] Part 1-2. 나만의 MVC 프레임워크 만들기 | Gradle 개발 환경 구성하기 (with 도커|Docker) (0) | 2023.01.09 |
[Spring] Part 1-1. 나만의 MVC 프레임워크 만들기 | 시작 및 자바 설치편 (4) | 2023.01.06 |
소중한 공감 감사합니다