[Java] 플레이그라운드 with TDD, 클린코드 - AssertJ (1)

8 분 소요

시작하면서…

자바로 간단한 프로젝트를 만들어보려 했는데, 그냥 무작정 혼자 만들다가는 나쁜 버릇이 들 것 같아서
강의 하나를 수강하기로 했다. 특히나 자바는 객체지향이라는 개념을 사용하는 언어이고, 난 대부분 절차지향 언어만
사용해봤었기 때문에, 아직 간단한 책 한권으로는 객체지향으로 정확히 어떻게 프로그램을 구현하는지에 대한 감이 없기 때문에,
오히려 이대로 혼자 프로젝트를 진행하면, 결과물은 그런대로 작동할 수 있겠으나 코드 자체는 유지보수가 어렵고, 객체지향의 강점이
하나도 드러나지 않는 나쁜코드가 될 거라는 생각이 들었다. 개발자로써 알고리즘 자체를 짜는 것도 중요하지만 모두가 같이 일하는 환경에서는 직관적이고 유지보수가 편하게 코드를 작성하는것도 아주 중요한 덕목이라는 생각이 들었다.

그래서 이러한 관련 강의를 찾다가 아주 평이 좋은 강의를 발견해서 수강하며 블로그에 남겨두려한다.
이 강의의 좋은 점은 내용도 좋지만, 주로 미션위주의 강의라 내가 직접 만들어보며 어떤게 잘못되었고 어떤게 올바른 코드인지 알 수 있다는 것이었고, 테스트주도형 개발도 함께 배울 수 있을 뿐만 아니라 실제 개발처럼 깃허브로 버젼을 푸쉬해가며 미션을 진행 해볼 수 있다는 것이었다. 그래서 별 고민없이 바로 결제하고 수강을 시작했다.

첫번째 미션은 숫자야구게임을 만드는것이다. 그럼 시작해보자.

단위테스트

자바는 대부분의 기능을 클래스로 구현해놓고, 그 클래스들을 메인메서드에서 동작시킨다.
메인 메서드는 프로그램을 시작할 뿐만 아니라 구현한 프로그램을 테스트하는데도 사용하는데, 테스트 주도 개발을 하려면 어떻게 메인메서드에서 구현한 프로그램을 테스트 할 수 있는지 알아야 한다.

그럼 간단한 사칙연산 프로그램을 만들어 프로그램을 테스트 해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package TDD;

public class Calculator {
    int add(int a, int b){
        return a+b;
    }
    int subtract(int a, int b){
        return a-b;
    }
    int multiply(int a, int b){
        return a*b;
    }
    int divide(int a, int b){
        return a/b;
    }
    public static void main(String[] args){
        Calculator cal= new Calculator();
        System.out.println(cal.add(3,4));
        System.out.println(cal.subtract(5,4));
        System.out.println(cal.multiply(2,6));
        System.out.println(cal.divide(8,4));
    }
}

위 코드를 보면 클래스 Calculator에 해당하는 프로덕션 코드 부분과, main에 해당하는 테스트 코드 부분이 있다. cal이라는 인스턴스를 만들어 준 뒤, 아래에 사칙연산을 실제로 실행시켜봄으로써 이 코드가 정상적으로 작동하는지 확인해보았는데, 이러한 메인메서드 테스트는 문제점이 존재한다.

main method 테스트 문제점

  • 프로덕션 코드와 테스트 코드가 하나에 존재한다. 이로인해 클래스 크기가 커지고 복잡도가 증가한다.
  • 테스트 코드가 실제 서비스에 같이 배포된다.
  • main method 하나에서 여러 개의 기능을 테스트 하기 때문에 복잡도가 증가한다.
  • method 이름을 통해서 어떤 부분을 테스트 하는 것인지 알기가 힘듬
  • 테스트 결과를 사람이 수동으로 확인해야한다.

이러한 main method를 사용한 테스트가 가지는 문제점을 해결하기 위해 등장한 것이 바로 JUnit 이다.

JUnit

JUnit은 단위 테스트 Framework로, System.out으로 번거롭게 테스트 케이스를 확인하지 않도록 해주는 도구이다. 프로그램 테스트 시 걸릴 시간도 관리할 수 있게 해주며, 오픈소스 플러그인 형태로 Eclipse에 포함되어있다. 개발을 진행하면서, 어느정도의 개발이 진행되면 반드시 프로그램에 대한 단위 테스트는 필수로 진행해주어야 한다.

그럼 단위 테스트를 어떻게 진행해야 하는지 알아보자.

String 클래스에 대한 학습 테스트

깃허브에서 내려받은 폴더에 있는 StringTest 클래스가 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
package study;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class StringTest {
    @Test
    void replace() {
        String actual = "abc".replace("b", "d");
        assertThat(actual).isEqualTo("adc");
    }
}

replace 함수메서드를 실행하면 테스트가 가능하다.

그럼 StringTest에 다음 요구사항을 새로운 test case로 추가해보자.

AssertJ

여기서 요구하는 테스트를 하기 위해서는 AsserJ에 대해서 알아야한다.

AssertJ는 자바 테스트를 위해 좀 더 풍부한 문법을 제공하며, 메서드 체이닝을 통해 더 직관적인 테스트 흐름을 작성할 수 있도록 개발된 오픈소스 라이브러리이다.

위의 replace를 테스트한 코드를 다시 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
package study;

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

public class StringTest {
    @Test
    void replace() {
        String actual = "abc".replace("b", "d");
        assertThat(actual).isEqualTo("adc");
    }
}

actual이라는 문자열 “abc”.replace(“b”,”d”), abc에서 b를 d로 바꾸는 객체를 만들고,
assertThat(actual).isEqualTo(“adc”) 로 실제로 replace 메서드가 우리가 원하는 값인 “adc”를 출력하는지를 확인해준다.

이처럼 AssertJ는 간편하게 테스트 코드를 작성할 수 있도록 도와주는 오픈소스이다.
그럼 몇가지 더 유용한 사용법을 한번 알아보자.

Test Fail Message

JUnit5 에서는 마지막 인자값에 as()를 통해 테스트 실패 메시지를 명시해주어 테스트가 실패했을때 출력되는 메시지를 만들 수 있다.

인물에 대한 정보를 담는 Character 이라는 클래스가 있고, 그 안에 있는 getAge() 메서드를 테스트 한다고하자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Person {
    String name;
    int age;
    String tribe;

    public Person(String name, int age, String tribe) {
        this.age = age;
        this.name = name;
        this.tribe = tribe;
    }

    public int getAge(){
        return age;
    }
    public String getName(){
        return name;
    }

}

이 Character 메서드는 이름, 나이, 종족을 멤버변수로 갖고, 나이를 반환하는 getAge()와 이름을 반환하는 getName() 메서드를 갖고있다. 우리가 만약 getAge() 메서드가 테스트코드에서 실패한다면, 이 이름의 사람의 나이를 확인해보라는 메시지 창을 띄우고 싶은 경우라면, 아래와 같이 AssertJ를 이용하여 코드를 작성할 수 있다.

만약 테스트코드가 실패하게 하면 이 사람의 나이를 다시한번 확인해보라는 문구를 넣고싶다면, 아래와 같이 하면된다.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
    public void Character() {
        Character frodo = new Character("Frodo", 33, "HOBBIT");
        // failing assertion, remember to call as() before the assertion, not after !

        assertThat(frodo.getAge()).as("check %s's age", frodo.getName()).isEqualTo(100);
    }
    
    
>>> [check Frodo's age] 
>>> expected: 100
>>> but was : 33

isEqualTo를 사용하여 우리가 원하는 기댓값과 같은 결과를 출력하는지 확인해보았고, 33이 나와야하지만 100이 나왔기 때문에 우리가 삽입해주었던 문장인 “check [이름] age” 문장이 출력되었다.

이렇게 유용한 메서드인 as()를 사용할 때 주의 해야할 점이 있는데, 바로 assertion이 수행되기 전에 사용해야한다는 것이다. 마지막에 as를 사용하면 에러를 동반하게 된다.
따라서 반드시 as()는 assertion이 수행되기 전에 사용해주어야한다.

Filtering Assertions

특정 filter를 자바 람다식을 이용하여 표현할 수 있는 유용한 기능이다. 아래 예제코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package TDD;

public class Human {
    String name;
    int age;

    public Human(String name, int age){
        this.name = name;
        this.age = age;
    }

    public String getName(){
        return name;
    }

}

Human 이라는 클래스를 만들어주고, name과 age를 멤버변수로 선언해준 뒤 나이를 가져오는 getAge()메서드를 만들어준다.
그리고 테스크 코드를 만들어 아래와 같이 작성한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
   public void filterTest() {
       List<Human> list = new ArrayList<>();
       Human kim = new Human("Kim", 22);
       Human park = new Human("Park", 25);
       Human lee = new Human("Lee", 22);
       Human jack = new Human("Jack", 23);

       list.add(kim);
       list.add(park);
       list.add(lee);
       list.add(jack);

       assertThat(list).filteredOn(human -> human.getName().contains("a")).containsOnly(park, jack);
   }

filterdOn을 사용하여 안에 람다식을 통해 “a”가 포함된 단어를 필터링해주고, 이 추출된 단어들이 park과 jack이 맞는지 테스트 해주는 코드이다.

이처럼 filter 기능을 사용하면 특정 케이스들만 골라 올바르게 동작하는지 테스트 해볼 수 있다.

또 객체의 프로퍼티를 검증할 수도 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
    public void filterTest() {
        List<Human> list = new ArrayList<>();
        Human kim = new Human("Kim", 22);
        Human park = new Human("Park", 25);
        Human lee = new Human("Lee", 22);
        Human jack = new Human("Jack", 23);

        list.add(kim);
        list.add(park);
        list.add(lee);
        list.add(jack);

        assertThat(list).filteredOn("age", 22).containsOnly(kim, lee);
    }

위의 코드와 코드는 같지만, 프로퍼티에 접근해서 값을 검증하고 있다. age가 22인 객체는 kim과 lee이다.
만약 반대로 값이 포함되는 않는 경우도 아래의 메서드를 통해 간결하게 검증할 수 있다.

  • not
  • in
  • notIn
1
ssertThat(list).filteredOn("age", notIn(22)).containsOnly(park, jack);

이렇게 하면 age가 22가 아닌 객체를 검증한다.

프로퍼티 추출하기

테스트르르 위해 리스트에있는 객체들을 검증하려면 반복문에서 이름을 하나하나 꺼내와서 또 다른 리스트에 담고 비교하는 과정을
거쳐야한다. 하지만 extracying()을 사용하면 아주 간편하게 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
    public void filterTest() {
        List<Human> list = new ArrayList<>();
        Human kim = new Human("Kim", 22);
        Human park = new Human("Park", 25);
        Human lee = new Human("Lee", 22);
        Human jack = new Human("Jack", 23);

        list.add(kim);
        list.add(park);
        list.add(lee);
        list.add(jack);

        assertThat(list).extracting("name").contains("Kim", "Park", "Lee", "Jack");
    }

위 코드 처럼 extracting을 사용하면, 알아서 name의 프로퍼티를 갖는 값들만 추출하여 검증해준다.

String assertions

String assertions는 다양한 메서드를 통해 간단하게 문자열을 검증할 수 있도록 도와준다.

1
2
3
4
5
6
7
@Test
    public void string(){
        String expression = "This is a string";

        assertThat(expression).startsWith("This").endsWith("string").contains("a");
    }

위 코드처럼 직관적으로 startsWith이면 This로 시작하는지를 검증해주고, endsWith은 끝나는 단어가 string인지 검증해준다.

Exception 처리 테스트

AssertJ는 예외처리를 가독성있게 할 수 있도록 도와주는 assertThatThrownBy()라는 메서드를 제공한다.
기존의 에외 처리 테스트는

1
2
3
4
5
6
@Test
    public void exception(){
        Throwable thrown = catchThrowable(()-> {throw new Exception("Error");});

        assertThat(thrown).isInstanceOf(Exception.class).hasMessageContaining("Error");
    }

이렇게 thrown 이라는 객체를 만들어 주어야 하지만, assertThatThrownBy() 를 사용해주면

1
2
3
4
5
@Test
  public void exception(){
     assertThatThrownBy(()->{ throw new Exception("Error");})
        .isInstanceOf(Exception.class).hasMessageContaining("Error");
  }

다음과 같이 가독성있게 코드를 짤 수 있다.

또 자주 쓰이는 예외처리에 대해서는 이미 정의된 함수를 제공하는데,

  • assertThatNullPointerException
  • assertThatIllegalArgumentException
  • assertThatIllegalStateException
  • assertThatIOException

다음과 같이 4가지이다. 사용법은 아래와 같다.

1
2
3
4
5
6
7
@Test
   public void exception_example(){
       assertThatIOException().isThrownBy(() -> {throw new IOException("Error");})
               .withMessage("Error")
               .withMessageContaining("Error")
               .withNoCause();
   }

자주 쓰이는 이 4가지 예외 타입이 아니라면, 다음과 같이 해주면 된다.

1
2
3
4
5
6
7
@Test
   public void exception_example(){
       assertThatExceptionOfType(IOException.class).isThrownBy(() -> {throw new IOException("Error");})
               .withMessage("Error")
               .withMessageContaining("Error")
               .withNoCause();
   }

그럼이제 과제에서 요구한 사항들을 하나하나 코딩해보자.

요구사항 1

  • “1,2”을 ,로 split 했을 때 1과 2로 잘 분리되는지 확인하는 학습 테스트를 구현한다.
  • “1”을 ,로 split 했을 때 1만을 포함하는 배열이 반환되는지에 대한 학습 테스트를 구현한다.

힌트

  • 배열로 반환하는 값의 경우 assertj의 contains()를 활용해 반환 값이 맞는지 검증한다.
  • 배열로 반환하는 값의 경우 assertj의 containsExactly()를 활용해 반환 값이 맞는지 검증한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @Test
      void split(){
          String[] actual = "1,2".split(",");
          String[] actual2 = "1,".split(",");
    
          assertThat(actual).contains("1","2");
          assertThat(actual2).containsExactly("1");
    
      }
    

    String 배열을 만들어 그 안에 “1,2”인 actual 과 “1,”인 actual2를 만들었다.
    actual 들을 split() 메서드를 통해 나눠 준뒤, 우리가 원하는 결과값인 값을 만족하는지를 테스트 해줄 수 있다.

요구사항 2

  • “(1,2)” 값이 주어졌을 때 String의 substring() 메소드를 활용해 ()을 제거하고 “1,2”를 반환하도록 구현한다.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    @Test
     void substring(){
         String actual = "(1,2)";
         String subActual = actual.substring(1, actual.length()-1);
    
         assertThat(subActual).isEqualTo("1,2");
    
    
     }
    

    substring() 메서드로 괄호를 제거하고 출력갑과 기댓값이 같은지 확인하도록 구현하였다.

요구사항 3

  • “abc” 값이 주어졌을 때 String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져오는 학습 테스트를 구현한다.
  • String의 charAt() 메소드를 활용해 특정 위치의 문자를 가져올 때 위치 값을 벗어나면 StringIndexOutOfBoundsException이 발생하는 부분에 대한 학습 테스트를 구현한다.
  • JUnit의 @DisplayName을 활용해 테스트 메소드의 의도를 드러낸다.

이 요구사항은 앞서 배운 예외처리의 두가지 방법으로 해결할 수 있다.
먼저 assertThatThrownBy( ) 로 구현하는 경우,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//요구사항 3-1
   @Test
   @DisplayName("charAt()으로 특정 위치 문자 가져오기")
   void charAt1() {
       String actual = "abc";

       assertThat(actual.charAt(0)).isEqualTo('a');
       assertThat(actual.charAt(1)).isEqualTo('b');
       assertThat(actual.charAt(2)).isEqualTo('c');

       assertThatThrownBy(() -> {
           char val = actual.charAt(3);
           throw new Exception("범위 초과");
       }).isInstanceOf(IndexOutOfBoundsException.class).hasMessageContaining("String");
   }

이렇게 할 수 있고,
좀 더 가독성있게 코드를 짜고 싶다면 assertThatExceptionOfType을 사용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
//요구사항 3-2
    @Test
    @DisplayName("charAt으로 특정 위치 문자 가져오기 - assertThatExceptionOfType 으로 Exception 처리")
    void charAt2(){
        String actual = "abc";
        assertThatExceptionOfType(IndexOutOfBoundsException.class)
                .isThrownBy(()->{
                    char val = actual.charAt(3);
                    System.out.println(val);
                    throw new Exception("범위 초과");
                }).withMessageContaining("String");

    }

카테고리:

업데이트:

댓글남기기