본문 바로가기

일상

NEXTSTEP ATDD 1주차 회고

멋쟁이 사자처럼 교육이 끝나고 이번에는 nextstep이라는 교육 사이트의 ATDD 라는 교육을 듣게 되어 회고를 하게 되었다.

수강비가 일단 70만원이라고 당황을 하였다.


인프런이나 패스트캠퍼스처럼 강의를 따라 진행하는 것이니라 미션을 진행하고 리뷰어 분들에게 리뷰를 받는것이 이 교육의 진행 방식이다.
ATDD 는 지하철 관련하여 기능을 추가하고 테스트를 해보는 시간을 가졌다.
교육의 소개는 이것으로 짧게 소개를 하고 본론으로 들어가도록 하겠다.

ATDD가 무엇일까?


간략하게 말하면 시나리오를 적고 그것을 테스트를 진행하는 애자일 방법론이다.

Feature: 라면 끊이기

  Scenario: 신라면 끊이기
    Given 냄비가 준비가 되어 있다.
    And 가스불도 준비가 되어 있다.
    When 물과 라면을 넣는다.
      AND 3분을 기다린다.
    Then 라면이 잘 끊여졌는지 응답값을 받는다.


저는 간단하게 라면을 비유를 했는데, 단위 테스트는 한 메소드를 테스트를 해보지만 시나리오가 시나리오 형태 기능들이 진행되는지에 대해 테스트를 한다고 보면 된다.
어떻게 보면 E2E테스트, 통합테스트 같은 것도 겹치는 부분이 있는 것 같다.

기획자분들이나 개발자 분들의 원활한 커뮤니케이션을 위해 이해하는 용도로도 사용할 수 있다.
하지만, ATDD는 모든 문제를 해결할 수 없다



1주차 미션은 rest-assured 라는 것을 이용하여 간단하게 테스트 하는 과정을 가졌다.


mockMvc 를 사용할 수 있지만 restassured 는 가독성이나 API전체 구간을 테스트하기에 알맞을 것같아 사용하였다.
물론 mockMvc가 Presentation Layer만 빌드 하기 때문에 속도가 훨씬 빠른건 안 비밀..

과정을 진행하다 보면 테스트할때 한가지 문제점이 보인다.

@DisplayName("지하철역 관련 기능")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class StationAcceptanceTest {

    @LocalServerPort
    int port;

    @BeforeEach
    public void setUp() {
        RestAssured.port = port;
    }

    /**
     * When 지하철역을 생성하면
     * Then 지하철역이 생성된다
     * Then 지하철역 목록 조회 시 생성한 역을 찾을 수 있다
     */
    @DisplayName("지하철역을 생성한다.")
    @Test
    void createStation() {
    }

    /**
     * Given 2개의 지하철역을 생성하고
     * When 지하철역 목록을 조회하면
     * Then 2개의 지하철역을 응답 받는다
     */
    // TODO: 지하철역 목록 조회 인수 테스트 메서드 생성
    @DisplayName("지하철역을 조회한다.")
    @Test
    void getStations() {
    }

    /**
     * Given 지하철역을 생성하고
     * When 그 지하철역을 삭제하면
     * Then 그 지하철역 목록 조회 시 생성한 역을 찾을 수 없다
     */
    // TODO: 지하철역 제거 인수 테스트 메서드 생성
    @DisplayName("지하철역을 제거한다.")
    @Test
    void deleteStation() {
        
    }
    
 }


따로 따로 테스트를 진행할떄는 성공이 나온다.
그런데 제거 -> 조회 -> 생성를 할떄, 조회에서 에러가 발생을 한다.
그 이유는 조회에서 테스트로 생성된 2개 지하철역이 남아 있어 남아있는 지하철역이 3개로 나와 예상했던 1개의 지하철이 나오지 않았기 때문이다.



첫번쨰 테스트인 생성이 조회에서도 영향을 주는 현상이 이루어졌다.

Why? ....
@DataJpaTest안에는 @Transactional 이 있어 테스트가 끝나면 트랜잭션을 롤백 시켜 모든 테스트가 격리되는 걸로 알고 있을 것이다.
하지만 우리가 여러 test를 한꺼번에 사용하기 위해 사용한 SpringBootTest.WebEnvironment.RANDOM_PORT를 사용하면 별도의 쓰레드에서 스프링 컨테이너가 실행되어 스프링 컨테이너가 실제로 구동된것과 서로 다른 쓰레드 이니, 하나의 트랜잭션으로는 묶이지 않아 롤백되지 않는 것이다.


해결방법은 뭐가 있을까요?

나는 첫번쨰로 @Order를 써서 순서를 정해서 하였다.


결과는 성공적으로 되었다.
하지만, 순서를 잘 정해져야지만 성공하는 테스트 코드가 되버려 유지보수하기 어려운 테스트 코드가 되버린다.

두번째 방법은 @DirtiesContext을 사용하는 것이다.


@DirtiesContext는 테스트별로 context를 초기화하여 Spring이 다시 실행 되는 불필요함이 있다.


세번째 방법은 @BeforeEach 나 @AfterEach같은 반복에서 Repository를 이용하여 제거하는 것이다.


이 방법도 테스트 격리가 된다. 하지만 delete하는 데이터가 많아질 수록 점점 성능이 저하될것이다.


네번째 방법은 @SQL을 사용하는 것이다.



trucate.sql 에는 밑에 sql이 작성되어 있고 이것을 실행 시키는 방식이다.
delete 보다 truncate가 트랜잭션 로그 공간도 차지 하지않고 락도 걸리지 않아 위에 delete보다 더 좋은 방법이라고 생각한다.

truncate table station;


하지만 여기서도 문제점이 있는데 truncate 하는 테이블을 일일이 다 적어져야 하는 불편함이 있다.

그래서 마지막으로 EntityManager을 이용하여 table을 조회하여 전부 truncate하는 방식으로 생각을 하였다.

import com.google.common.base.CaseFormat;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import javax.persistence.Entity;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class DatabaseCleanup implements InitializingBean {

    @PersistenceContext
    private EntityManager entityManager;
    private List<String> tableNameList;

    @Override
    public void afterPropertiesSet() throws Exception {
        tableNameList = entityManager.getMetamodel()
                .getEntities()
                .stream()
                .filter(e -> e.getJavaType().getAnnotation(Entity.class) != null)
                .map(e -> CaseFormat.UPPER_CAMEL.to(CaseFormat.LOWER_UNDERSCORE, e.getName()))
                .collect(Collectors.toList());
    }

    @Transactional
    public void execute() {
        entityManager.flush();
        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY FALSE").executeUpdate();

        for (String tableName : tableNameList) {
            entityManager.createNativeQuery("TRUNCATE TABLE " + tableName).executeUpdate();
            entityManager.createNativeQuery("ALTER TABLE " + tableName + " ALTER COLUMN ID RESTART WITH 1").executeUpdate();
        }

        entityManager.createNativeQuery("SET REFERENTIAL_INTEGRITY TRUE").executeUpdate();
    }

}


물론 여기도 문제점이 존재한다.
testClass 마다 @BeforEach에 넣어줘야 하는 문제가 생겼다.

그래서 class 를 하나 생성하여 extends를 해주면 databaseClean하는 코드들이 보이지 않게 수정을 하였다.

@ActiveProfiles("test")
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
    @LocalServerPort
    int port;

    @Autowired
    private DatabaseCleanup databaseCleanup;

    @BeforeEach
    public void setUp() {
        RestAssured.port = port;
        databaseCleanup.execute();
    }
}



이여서 RestAssurd에 관련하여 조금 얘기를 해볼까 한다.

ExtractableResponse<Response> response = RestAssured
            .given().log().all()
            .when().delete("/lines/{id}", stationLineId)
            .then().log().all()
            .extract();


기본적으로 이런 형식으로 사용할 수 가 있다.
body안에 json을 넣어 보내려면 밑에와 같은 방식으로 보낼 수 있다.

 Map<String, String> param = new HashMap<String, String>();
 param.put("name", name);
 
 ExtractableResponse<Response> response = RestAssured
            .given().log().all()
            .body(param)
            .contentType(MediaType.APPLICATION_JSON_VALUE)
            .when().post("/lines")
            .then().log().all()
            .extract();


contentType를 추가하여 JSON형태로 보낼 수 있으며, Map이나 class를 넣어 주면 값을 보낼 수 있습니다.

assertThat(response.statusCode()).isEqualTo(httpStatus.value());


반환값의 Http 상태값은 statusCode()로 확인을 할 수 있습니다.

response.jsonPath().getList("name");  // List<String>
response.jsonPath().getLong("id");    // Long


반환 값도 이런식으로 jsonPath를 사용하여 반환값을 확인할 수 있다.

중복 제거 하기


API 테스트를 하게 되면 중복되는 API를 호출을 하게 되는데 이 경우에는 static으로 요청하는 API를 만들어 객체를 만들지 않아도 static영역에서 꺼내 쓰도록 사용하는 것이 포인트 이다.

public static ExtractableResponse<Response> 지하철_노선_목록_조회() {
        return RestAssured
            .given().log().all()
            .when().get("/lines")
            .then().log().all()
            .extract();
}



그리고 몇가지 피드백을 받은 것이 있다.
스타일에 차이가 있겠지만, given, when, then 형식만 있을 수록 가독성이 높아진다는 것이다.
만약 로직이 길어지면 메서드에 담아서 실행시키는 것도 방법중 하나이다.

@Test
void 지하철_노선_생성_인수테스트() {
  // when
  지하철_노선을_생성한다("분당선", "bg-green-600", 1, 2, 5);

  // then
  var 지하철_노선_목록 = 지하철_노선_목록을_조회한다();
  지하철_노선이_포함되어_있다("분당선");
}


이런 형식으로 한글로 메서드나 변수를 쓰는 것도 좋은 방법이라고 하였다.