우아한테크코스 레벨2 기간에 학습한 지하철 노선도 미션 전체 피드백 강의 내용을 정리한다.
지하철 노선도 미션을 진행하면서 1 단계에서는 스프링 프레임워크의 도움을 받지 않고 즉, @Service
, @Component
와 같은 어노테이션을 사용하지 않고 미션을 진행해야했다. 스프링 빈을 등록하지 않고, Service와 Dao 객체를 직접 생성하고 의존관계를 맺어주어야 했다.
하지만 DAO가 static 으로 되어있고, 굳이 Service 계층을 만들지 않아 객체 관리에 어려움이 없었다는 크루들의 의견이 많았다.
하지만 Application을 확장해나간다고 할 때, static 또는 싱글톤을 사용해서 객체 관리를 하면 어떤 문제가 있을까?
-> 컴파일 시점에 의존성이 정해지며, static하면 런타임시에 뭔가 결정하고 싶은 것을 결정할 수 없게 된다. 하지만 이러면 런타임에 다른 객체로 바뀔 일이 있을까? 미리 컴파일 타임에 정해두면 더 좋지 않을까? 하는 의문이 생기ㅣㄱ도 한다.
그렇다면 테스트에서는 H2 DB를 사용하고 실제 프로덕션에서는 MySQL을 사용한다고 하자. 스프링을 사용하면 테스트 상황에서는 H2를 사용하는 DAO 를 사용할 수 있고, 프로덕션에서는 MySQL을 사용할 수도 있다.
하지만 static을 사용하면 어떻게 구현해야 할까?
-> h2를 사용하는 서비스와 MySQL을 사용하는 서비스를 각각 만들어야 할 것이다.
@DirtiesContext(classMode = DirtiesContext.ClassMode.BEFORE_EACH_TEST_METHOD)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}
위 클래스는 E2E 테스트의 베이스가 되는 부모 테스트 클래스였다. 우리가 추가적으로 만든 인수테스트 클래스들은 모두 해당 클래스를 상속하였다. 그런데 여기서 @DirtiesContext
는 무엇일까?
@DirtiesContext
는 컨텍스트를 새로 로드해준다. 현재 classMode가 BEFORE_EACH_TEST_METHOD
이므로 각각의 테스트 메소드 실행 전마다 컨텍스트를 새로 로드해준다. 이를 통해서 테스트 환경을 다른 ApplicationContext와 격리
시켜준다. (매 테스트마다 IoC 컨테이너를 새로 만든다.)
근데 우리는 왜 ApplicationContext를 매번 새로 띄워야 할까?
스프링 테스트에서 애플리케이션 컨텍스트는 딱 한 개만 만들어지고 모든 테스트에서 공유하게 된다. 따라서 ApplicationContext의 구성이나 상태를 변경하지 않는 것이 원칙이다. 만약 변경하게 된다면 나머지 모든 테스트에 영향을 미치므로 바람직하지 못하게 된다. 이럴 때 우리는 앞서 언급한 @DirtiesContext
를 사용하게 되는 것이다. 이 어노테이션을 통해서 스프링의 테스트 컨텍스트 프레임워크에게 해당 클래스의 테스트는 ApplicationContext의 상태를 변경한다는 것을 알려주는 것이다. 즉 지금 현재 컨텍스트가 더러워 졌으니 다시 띄워줘라고 하는 것과 같다.
우리는 앞서 테스트는 서로 상호 독립되게 작성해야한다고 배웠다. 즉, 테스트의 순서 등에 영향을 받으면 안되는 것이다. 우리는 Spring Beans
, Database
를 각 테스트마다 공유하고 있다. 따라서 스프링 컨테이너를 초기화하는 DirtiesContext
를 사용할 수 있는 것이다.
하지만 경험에서 알 수 있다 시피 컨텍스트를 내렸다가 다시 띄우는 작업을 매 메소드 마다 하는 것은 테스트 시간을 많이 잡아먹는다. 지금은 10~30초 내외이지만 이것이 1~3분 이라고만 해도 우리는 테스트를 돌리는 것이 두려워질 것이고, 테스트를 돌리는 것이 그렇게 반갑지 않을 것이다.
따라서 우리는 @DirtiesContext
대신 @Sql
어노테이션을 이용해서 DB를 격리할 수 있다. 매 테스트 이후 Truncate
쿼리로 모든 테이블을 초기화하는 것이다. (Truncate는 모든 테이블을 Drop하고 Create하는 것보다 효율적이다. Truncate는 데이터만 지운다. 즉, DDL부분 테이블의 구조등에 대한 부분은 남긴다.)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Sql("/truncate.sql")
public class AcceptanceTest {
@LocalServerPort
int port;
@BeforeEach
public void setUp() {
RestAssured.port = port;
}
}
혹은 @AfterEach
를 사용해서 매 테스트가 끝날 때 마다 DB의 데이터를 직접 delete쿼리를 날림으로써 지워줄 수도 있다.
@AfterEach
public void delete() {
xxxDao.deleteAll();
}
혹은 @Transactional
어노테이션을 통해서 테스트의 독립성을 보장해줄 수도 있다.
테스트 코드에서 @Transactional
어노테이션을 사용하면 롤백을 해준다. 하지만 이는 E2E에서는 사용 불가하다.
예를 들어 ServiceTest의 경우 두 경우 모두 독립된 테스트를 보장해줄 수 있게 된다.
@SpringBootTest
@Sql("/truncate.sql")
class LineServiceTest {
...
}
@SpringBootTest
@Transactional
class LineServiceTest {
...
}
우리는 테스트 코드 또한 가독성 있게 작성해야한다는 것을 안다. 테스트 코드는 하나의 문서 역할을 할 수 있어야 하며, 테스트 의도를 명확히 드러내야 한다. 따라서 테스트 코드도 유지보수 대상이다.
인수 테스트를 진행하면서 테스트 코드가 굉장히 길고 한 눈에 알아보기 어렵다는 것을 느꼈다. 아래의 처음 리팩토링 전의 코드를 보면 매우 길고 한 눈에 알아보기 어렵다는 것을 알 수 있다. 또한 중복되는 코드 부분도 너무 많다.
@Test
@DisplayName("새로운 노선 요청이 오면 노선을 등록한다.")
void createLine() {
// given
final StationRequest stationRequest1 = new StationRequest("지하철역");
final ExtractableResponse<Response> stationResponse1 = AcceptanceFixture.post(stationRequest1, "/stations");
final Long upStationId = stationResponse1.jsonPath()
.getObject(".", StationResponse.class)
.getId();
final StationRequest stationRequest2 = new StationRequest("새로운지하철역");
final ExtractableResponse<Response> stationResponse2 = AcceptanceFixture.post(stationRequest2, "/stations");
final Long downStationId = stationResponse2.jsonPath()
.getObject(".", StationResponse.class)
.getId();
final LineRequest params = new LineRequest("신분당선", "bg-red-600", upStationId, downStationId, 10);
// when
final ExtractableResponse<Response> response = RestAssured.given().log().all()
.body(params)
.contentType(APPLICATION_JSON_VALUE)
.when()
.post("/lines")
.then().log().all()
.extract();
// then
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
assertThat(response.header("Location")).isNotBlank();
assertThat(response.as(LineResponse.class).getName()).isEqualTo("신분당선");
assertThat(response.as(LineResponse.class).getColor()).isEqualTo("bg-red-600");
}
@Test
@DisplayName("노선을 등록한다.")
void createLine() {
// given
final Long upStationId = extractIdByStationName("지하철역");
final Long downStationId = extractIdByStationName("새로운지하철역");
final LineRequest params = new LineRequest("신분당선", "bg-red-600", upStationId, downStationId, 10);
// when
ExtractableResponse<Response> response = AcceptanceFixture.post(params, "/lines");
// then
assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value());
assertThat(response.header("Location")).isNotBlank();
assertThat(response.as(LineResponse.class).getName()).isEqualTo("신분당선");
assertThat(response.as(LineResponse.class).getColor()).isEqualTo("bg-red-600");
}
리팩토링 이후의 코들르 보면 이전에 비해 훨씬 코드가 깔끔해졌다는 것을 느낄 수 있다.
또한 테스트 코드에서는 필요하다면 가독성을 위해 한글
을 사용할 수도 있다. 우리는 이렇게 테스트 코드의 가독성에 대해서도 신경써야하고, 프로덕션 코드 만큼이나 리팩토링에 비용을 투자해야 한다.
public class Line {
private Long id;
private String name;
private String color;
...
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Line line = (Line) o;
return name.equals(line.name) && color.equals(line.color);
}
@Override
public int hasCode() {
return Objects.hash(name, color);
}
}
위와 같이 노선의 이름과 색을 통해서 동등성을 비교할 수 있다. 본인도 그렇게 하였다. 하지만 이름이 바뀌면 다른 노선이 되는가? 에를 들어서 현실에서 그 예를 찾아보면 분당선에서 수인분당선으로 이름이 바뀌었다. 그렇다면 이 둘은 서로 다른 것인가? 하는 의문을 제기할 수 있다.
이에 대해 생각보다 많은 고민을 하였고, 코치님들이나 주변 크루들과도 많은 이야기를 하며 많은 의견을 주고 받았다. 결국 현재 내가 결론을 내린 생각은 다음과 같다.
리뷰어와의 코멘트 중 나의 생각