자바 문제를 풀다가 작은 단위 코드들이 올바르게 동작하는지 그때그때 확인하고 싶다는 생각이 들었습니다. TDD를 적용하면 좋을 것 같아서 '테스트 주도 개발 시작하기'란 책을 읽고 코드에 적용해보게 되었습니다.
'테스트 주도 개발 시작하기' 라는 책을 읽고 이를 바탕으로 내용을 정리했습니다.
asssertEquals(5. Calculator.plus(4, 1))
)public class CalculatorTest {
@Test
void plus(){
int result = Calculator.plus(1, 2);
assertEquals(3, result);
//asssertEquals(5. Calculator.plus(4, 1));
}
}
public class Calculator {
public static int plus(int a1, int a2){
return 3;
//return 5;
//return a1 + a2;
}
}
암호 검사기 기능을 구현하는 과정에서 TDD를 써봅시다.
public class PasswordStrengthMeterTest {
@Test
void 모든조건충족_강함(){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@AB");
Assertions.assertThat(PasswordStrength.STRONG).isEqualTo(result);
}
}
public enum PasswordStrength {
STRONG
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
return PasswordStrength.STRONG;
}
}
@DisplayName("8글자만 미충족할 경우, 암호 강도 보통")
@Test
void 글자수만_미충족_보통(){
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("ab12!@A");
Assertions.assertThat(PasswordStrength.NOMAL).isEqualTo(result);
}
public enum PasswordStrength {
STRONG, NOMAL
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
if(s.length() < 8) {
return PasswordStrength.NOMAL;
}
return PasswordStrength.STRONG;
}
}
@DisplayName("숫자만 미충족할 경우, 암호 강도 보통")
@Test
void 숫자만_미충족_보통() {
PasswordStrengthMeter meter = new PasswordStrengthMeter();
PasswordStrength result = meter.meter("abcd!@AB");
Assertions.assertThat(PasswordStrength.NOMAL).isEqualTo(result);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
if(s.length() < 8) {
return PasswordStrength.NOMAL;
}
if(notContainNumber) {
return PasswordStrength.NOMAL;
}
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
}
public class PasswordStrengthMeterTest {
private PasswordStrengthMeter meter = new PasswordStrengthMeter();
private void assertStrength(String password, PasswordStrength expStr){
PasswordStrength result = meter.meter(password);
Assertions.assertThat(expStr).isEqualTo(result);
}
@Test
void 모든조건충족_강함(){
assertStrength("ab12!@AB", PasswordStrength.STRONG);
}
@DisplayName("8글자만 미충족할 경우, 암호 강도 보통")
@Test
void 글자수만_미충족_보통(){
assertStrength("ab12!@A", PasswordStrength.NOMAL);
PasswordStrength result = meter.meter("ab12!@A");
Assertions.assertThat(PasswordStrength.NOMAL).isEqualTo(result);
}
@DisplayName("숫자만 미충족할 경우, 암호 강도 보통")
@Test
void 숫자만_미충족_보통() {
assertStrength("abcd!@AB", PasswordStrength.NOMAL);
}
}
@DisplayName("null값이 들어온 경우, 에러를 터뜨린다.")
@Test
void null값_에러(){
Assertions.assertThatThrownBy(() ->
meter.meter("")
).isInstanceOf(IllegalArgumentException.class).hasMessage("비밀번호를 입력해주세요");
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(s.length() < 8) {
return PasswordStrength.NOMAL;
}
if(notContainNumber) {
return PasswordStrength.NOMAL;
}
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
}
@DisplayName("대문자만 미충족할 경우, 암호 강도 보통")
@Test
void 대문자만_미충족_보통() {
assertStrength("ab12!@ab", PasswordStrength.NOMAL);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
boolean notContainUpperCase = !checkUpperCase(s);
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(s.length() < 8) {
return PasswordStrength.NOMAL;
}
if(notContainNumber) {
return PasswordStrength.NOMAL;
}
if(notContainUpperCase) {
return PasswordStrength.NOMAL;
}
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
@DisplayName("8글자만 충족하는 경우, 암호 강도 약함")
@Test
void 글자수만_충족_약함(){
assertStrength("aaaaaaaa", PasswordStrength.WEAK);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
boolean notContainUpperCase = !checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(enoughLength && notContainNumber && notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength) return PasswordStrength.NOMAL;
if(notContainNumber) return PasswordStrength.NOMAL;
if(notContainUpperCase) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
@DisplayName("숫자만 충족하는 경우, 암호 강도 약함")
@Test
void 숫자만_충족_약함(){
assertStrength("aa123", PasswordStrength.WEAK);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
boolean notContainUpperCase = !checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(enoughLength && notContainNumber && notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength && !notContainNumber && notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength) return PasswordStrength.NOMAL;
if(notContainNumber) return PasswordStrength.NOMAL;
if(notContainUpperCase) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
@DisplayName("대문자만 충족하는 경우, 암호 강도 약함")
@Test
void 대문자만_충족_약함(){
assertStrength("abBB", PasswordStrength.WEAK);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
boolean notContainNumber = !checkNumber(s);
boolean notContainUpperCase = !checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(enoughLength && notContainNumber && notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength && !notContainNumber && notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength && notContainNumber && !notContainUpperCase) return PasswordStrength.WEAK;
if(!enoughLength) return PasswordStrength.NOMAL;
if(notContainNumber) return PasswordStrength.NOMAL;
if(notContainUpperCase) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
notContainxxx
을 쓰는 게 가독성 떨어져서 containxxx
로 수정합니다.public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
int metCount = 0;
boolean containNumber = checkNumber(s);
boolean containUpperCase = checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(enoughLength) metCount++;
if(containNumber) metCount++;
if(containUpperCase) metCount++;
if(metCount == 1) return PasswordStrength.WEAK;
if(metCount == 2) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
@DisplayName("아무 조건도 충족하지 않는 경우, 암호 강도 약함")
@Test
void 모든조건_미충족_약함(){
assertStrength("abs", PasswordStrength.WEAK);
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
int metCount = 0;
boolean containNumber = checkNumber(s);
boolean containUpperCase = checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(enoughLength) metCount++;
if(containNumber) metCount++;
if(containUpperCase) metCount++;
if(metCount <= 1) return PasswordStrength.WEAK;
if(metCount == 2) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
public class PasswordStrengthMeter {
public PasswordStrength meter(String s){
if(s.isBlank()) {
throw new IllegalArgumentException("비밀번호를 입력해주세요");
}
if(getMetCount(s) <= 1) return PasswordStrength.WEAK;
if(getMetCount(s) == 2) return PasswordStrength.NOMAL;
return PasswordStrength.STRONG;
}
private int getMetCount(String s) {
int metCount = 0;
boolean containNumber = checkNumber(s);
boolean containUpperCase = checkUpperCase(s);
boolean enoughLength = s.length() >= 8;
if(enoughLength) metCount++;
if(containNumber) metCount++;
if(containUpperCase) metCount++;
return metCount;
}
private boolean checkNumber(String s){
for(char c: s.toCharArray()){
if(Character.isDigit(c)) {
return true;
}
}
return false;
}
private boolean checkUpperCase(String s) {
for(char c: s.toCharArray()){
if(Character.isUpperCase(c)) {
return true;
}
}
return false;
}
}
Lotto 문제 중 당첨 번호의 경우, 아래와 같은 조건이 있습니다. 이 조건을 바탕으로 TDD를 적용해보겠습니다.
구현해야 할 테스트 조건에 대해 정리해보면 다음과 같습니다.
public class NumberValidateTest {
private void assertLuckNumber(String luckyNumber, String errorMessage){
assertThatThrownBy(()->
new LuckyNumber(luckyNumber))
.isInstanceOf(IllegalArgumentException.class).hasMessage(errorMessage);
}
@DisplayName("null값 예외")
@Test
void null값_예외(){
assertLuckNumber("", CommonErrorMessage.PAYMENT_BLANK_ERROR);
}
@DisplayName("숫자 조건 미충족 예외")
@Test
void 숫자로만_미충족_예외(){
assertLuckNumber("1,2,3,4,보아,6", CommonErrorMessage.PAYMENT_NUMBER_ERROR);
}
@DisplayName("숫자 범위 미충족 예외")
@Test
void 숫자범위_미충족_예외(){
assertLuckNumber("1,2,3,4,5,100", NumberErrorMessage.NUMBER_RANGE_ERROR);
}
@DisplayName("서로 다른 숫자 미충족 예외")
@Test
void 서로다른숫자_미충족_예외(){
assertLuckNumber("1,1,2,3,4,5", NumberErrorMessage.NUMBER_SAME_ERROR);
}
@DisplayName("6개 숫자 미충족 예외")
@Test
void 숫자갯수_미충족_예외(){
assertLuckNumber("1,2,3,4,5",NumberErrorMessage.NUMBER_LENGTH_ERROR);
}
@DisplayName("당첨 번호가 모든 조건 충족")
@Test
void 당첨번호_모든조건_충족(){
Assertions.assertDoesNotThrow(()->
new LuckyNumber("1,2,3,4,5,6"));
}
}
public class LuckyNumber {
private final List<Integer> luckyNumber;
public LuckyNumber(String userLuckyNumber){
validate(userLuckyNumber);
this.luckyNumber = convertToList(userLuckyNumber);
}
public List<Integer> getLuckyNumber(){
return luckyNumber;
}
private List<Integer> convertToList(String userLuckyNumber) {
ArrayList<Integer> result = new ArrayList<>();
String[] splitUnit = userLuckyNumber.split(",");
for(String number: splitUnit) {
result.add(Integer.parseInt(number));
}
return result;
}
private void validate(String userLuckyNumber){
List<Integer> result = new ArrayList<>();
String[] splitUnit = userLuckyNumber.split(",");
if(isBlank(userLuckyNumber)) throw new IllegalArgumentException(CommonErrorMessage.PAYMENT_BLANK_ERROR);
if(areNotSixDigits(result)) throw new IllegalArgumentException(NumberErrorMessage.NUMBER_LENGTH_ERROR);
if(hasSameDigit(result)) throw new IllegalArgumentException(NumberErrorMessage.NUMBER_SAME_ERROR);
for(String num: splitUnit) {
checkOnlyNumber(num);
checkNumberRange(result, num);
}
}
private static void checkNumberRange(List<Integer> result, String num) {
int number = Integer.parseInt(num);
if(isNotInRange(number)) throw new IllegalArgumentException(NumberErrorMessage.NUMBER_RANGE_ERROR);
result.add(number);
}
private void checkOnlyNumber(String num) {
if(isNotNumber(num)) throw new IllegalArgumentException(CommonErrorMessage.PAYMENT_NUMBER_ERROR);
}
private static boolean isNotInRange(int number) {
return number < 1 || number > 45;
}
private static boolean isBlank(String userLuckyNumber) {
return userLuckyNumber.isBlank();
}
private static boolean areNotSixDigits(List<Integer> result) {
return result.size() != 6;
}
private boolean isNotNumber(String number){
return !number.matches("\\d+");
}
private boolean hasSameDigit(List<Integer> number){
HashSet<Object> temp = new HashSet<>();
for(int num : number){
temp.add(num);
}
if(temp.size()!=6) return true;
return false;
}
}
public class NumberErrorMessage {
public static final String NUMBER_RANGE_ERROR = "[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다.";
public static final String NUMBER_SAME_ERROR = "[ERROR] 서로 다른 숫자로 이루어져 있어야 합니다.";
public static final String NUMBER_LENGTH_ERROR = "[ERROR] 로또 번호는 6개로 이루어져 있어야 합니다.";
public static final String BOUNS_NUMBER_SAME_ERROR = "[ERROR] 보너스 번호는 로또 번호와 다른 숫자여야 합니다.";
}
public class CommonErrorMessage {
public static final String PAYMENT_NUMBER_ERROR = "[ERROR] 숫자로만 이루어져 있어야 합니다.";
public static final String PAYMENT_BLANK_ERROR = "[ERROR] 값이 없습니다.";
}
이 책을 보기 전에 'TDD를 하면 기능과 테스트 개발을 같이 하게 되어 시간이 2배가 드는 일인데, 모듈이 잘 돌아간다는 신뢰성을 획득하게 되더라도 과연 그것만으로 TDD를 하는 것이 맞을까?'라고 TDD의 필요성에 대해 의문점이 들었습니다. 그 부분에 대해 책은 우리가 개발하는 코드들이 '소프트웨어 품질'이고, TDD는 코드가 의도대로 동작하지 않아 서비스의 품질이 떨어뜨리는 것을 막는 효과적인 방법이라고 설명하는 것 같았습니다.
그리고 기존에 생성한 테스트 코드는 서비스를 운영하는 과정에서 새로운 기능 추가 등의 과정을 거칠 때 기존 코드가 작동하지 않는다는 등의 문제를 방지하는 도구로서 존재합니다. 서비스의 품질을 유지시키는 중요한 도구이기 때문에 제품 코드와 동일하게 유지 보수의 대상이 된다는 사실을 알게 되습니다.
개발이 단순히 기능을 구현한다기보다 사용자에게 제공하는 일정 이상의 품질을 유지해야 하는 제품이라고 이해하게 되면서 TDD가 강조되는 이유를 깨닫게 되는 시간이었습니다.