Intro
클라이언트 기능을 구현했으니 테스트코드를 작성해야 한다. Entity, DTO, Repository, Service, Controller 항목을 테스트했으며, 각 항목별 사용한 기술은 아래와 같다.
- 공통: JUnit5, AssertJ
- Entity / DTO: -
- RepositoryTest: DataJpaTest
- ServiceTest: Mockito
- ControllerTest: RestAssured
테스트 항목 별로 무엇을, 어떻게 테스트했는지 정리해보도록 하겠다.
Entity
JUnit5의 jupiter보다 더 직관적인 테스트코드를 작성하게 해주는 AssertJ를 사용했다.
Entity 테스트코드에서 테스트한 항목은 다음과 같다.
- 정적 팩토리 메서드
- 연관관계 편의 메서드
정적 팩토리 메서드 테스트
Entity에는 필수 값만 입력받아 새로운 엔티티를 만들어주는 정적 팩토리 메서드가 선언돼있다.
public static Client create(String name, String email, String password) {
Client client = new Client();
client.name = name;
client.email = email.toLowerCase();
client.password = password;
client.role = Role.CLIENT;
client.isEnabled = true;
return client;
}
엔티티의 전체 필드를 입력하는 것이 아니기 때문에 자동으로 값이 채워지는 필드에 대해 예상한 값이 들어가는지, 테스트할 필요가 있다.
또한 함수로 호출하기 때문에 같은 자료형이라면 인자값의 순서가 바뀌어서 들어갈 수도 있다.
Ex) create(String name, String email) -> create("email", "name")로 호출
위와 같은 경우는 컴파일 시 에러가 발생하지 않지만, 운영 시 문제가 생긴다.
리팩토링 과정에서 변경되는 사항이나 실수로 인한 에러를 방지하기 위해 테스트할 필요가 있다.
@Test
@DisplayName("create")
public void create() {
//given
String name = "name";
String content = "content";
Recommend recommend = Recommend.RECOMMEND;
ConnectionType connectionType = ConnectionType.b2b;
//when
RoadmapItem roadmapItem = RoadmapItem.create(name, content, 1, 2, recommend, connectionType, null, null);
//then
assertThat(roadmapItem.getId()).isNull();
assertThat(roadmapItem.getName()).isEqualTo(name);
assertThat(roadmapItem.getContent()).isEqualTo(content);
assertThat(roadmapItem.getRecommend()).isEqualTo(recommend);
assertThat(roadmapItem.getConnectionType()).isEqualTo(connectionType);
assertThat(roadmapItem.getRoadmapItemList().size()).isZero();
assertThat(roadmapItem.getReferenceList().size()).isZero();
}
연관관계 편의 메서드 테스트
JPA의 연관관계 편의 메서드를 테스트한다.
가끔 List를 초기화해주지 않아 생기는 NPE를 잡아줬다.
@Test
@DisplayName("updateParent")
public void updateParent() {
//given
RoadmapItem parent = RoadmapItem.create("parentName", "parentContent", 0, 0, Recommend.NOT_RECOMMEND, ConnectionType.t2b, null, null);
//when
RoadmapItem roadmapItem1 = RoadmapItem.create("name", "content", 0, 0, Recommend.RECOMMEND, ConnectionType.b2b, null, null);
RoadmapItem roadmapItem2 = RoadmapItem.create("name", "content", 0, 0, Recommend.RECOMMEND, ConnectionType.b2b, parent, null);
roadmapItem1.updateParent(parent);
//then
assertThat(roadmapItem1.getParent()).isEqualTo(parent);
assertThat(roadmapItem2.getParent()).isEqualTo(parent);
assertThat(parent.getRoadmapItemList()).contains(roadmapItem1, roadmapItem2);
}
@Test
@DisplayName("addRoadmapItem")
public void addRoadmapItem() {
//given
RoadmapItem parent = RoadmapItem.create("parentName", "parentContent", 0, 0, Recommend.NOT_RECOMMEND, ConnectionType.t2b, null, null);
RoadmapItem roadmapItem = RoadmapItem.create("name", "content", 0, 0, Recommend.RECOMMEND, ConnectionType.b2b, null, null);
//when
parent.addRoadmapItem(roadmapItem);
//then
assertThat(roadmapItem.getParent()).isEqualTo(parent);
assertThat(parent.getRoadmapItemList()).contains(roadmapItem);
}
DTO
Entity와 마찬가지로 AssertJ를 사용했다. 정적 팩토리 메서드를 테스트했다.
정적 팩토리 메서드 테스트
Entity의 정적 팩토리 메소드와 같은 맥락으로 인자값의 순서가 바뀌거나 리팩토링으로 인해 매개변수의 위치가 바뀌는 것을 대비해 테스트코드를 작성했다.
@Test
@DisplayName("create")
public void from() {
// given
Roadmap roadmap = Roadmap.create("title", "image", Accessibility.PRIVATE, null);
roadmap.setId(1L);
RoadmapItem roadmapItem = RoadmapItem.create("name", "content", 1, 2, Recommend.RECOMMEND, ConnectionType.b2b, null, roadmap);
roadmapItem.setId(1L);
RoadmapItemReference reference1 = RoadmapItemReference.create(roadmapItem, "url1");
RoadmapItemReference reference2 = RoadmapItemReference.create(roadmapItem, "url1");
// when
RoadmapItemDto roadmapItemDto = RoadmapItemDto.from(roadmapItem);
// then
assertThat(roadmapItemDto.id()).isEqualTo(roadmapItem.getId());
assertThat(roadmapItemDto.name()).isEqualTo(roadmapItem.getName());
assertThat(roadmapItemDto.content()).isEqualTo(roadmapItem.getContent());
assertThat(roadmapItemDto.recommend()).isEqualTo(roadmapItem.getRecommend());
assertThat(roadmapItemDto.referenceList()).contains(reference1.getUrl(), reference2.getUrl());
assertThat(roadmapItemDto.roadmapId()).isEqualTo(roadmap.getId());
}
Repository
Repository를 통한 find 함수들을 테스트한다. Repository만 테스트하기 때문에 관련없는 Bean까지 등록하는 @SpringBootTest 대신 @DataJpaTest를 사용했다.
테스트 환경에선 기존의 @Configuration 파일이 자동으로 등록되지 않는다. 때문에 수동으로 @Import 해줘야 하는데, Repository 테스트 시 간단하게 import하기 위해 JPA 관련 설정만 들어있는 config 파일을 만들었다.
// JpaRepositoryConfig.java
@Configuration
@EnableJpaAuditing
public class JpaRepositoryConfig {
@Bean
public JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
그리고 @DataJpaTest와 @Import를 어노테이션 하나로 묶어줬다.
// RepositoryTest.java
@DataJpaTest
@Import(JpaRepositoryConfig.class)
@Retention(RetentionPolicy.RUNTIME)
public @interface RepositoryTest {
}
이제 RepositoryTest 클래스에 @RepositoryTest 어노테이션 하나만 붙여주면 설정이 적용된다.
@RepositoryTest
class RoadmapRepositoryTest {
...
}
테스트 환경에서 DB URL을 jdbc:h2:mem:testdb로 설정해 외부 DB 연결 없이 In-Memory DB로 테스트할 수 있다.
// test/application.yml
spring:
datasource:
url: jdbc:h2:mem:testdb;MODE=postgresql
driver-class-name: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2
Entity 조회, 검색 코드를 주로 테스트했다.
@Nested
@DisplayName("Client 조회")
class TestSelect {
@Test
@DisplayName("ID")
public void TestId() {
//given
Client client = Client.create("client", "email", "password");
clientRepository.save(client);
//when
Client result = clientRepository.findById(client.getId()).orElse(null);
//then
assertThat(result).isNotNull();
assertThat(result.getId()).isEqualTo(client.getId());
}
@Test
@DisplayName("Email/Password")
public void TestEmailPassword() {
//given
String email1 = "client1@gmail.com";
Client client1 = Client.create("client1", email1, "password");
String email2 = "client2@gmail.com";
Client client2 = Client.create("client2", email2, null);
clientRepository.save(client1);
clientRepository.save(client2);
//when
Client result1 = clientRepository.findByEmailAndPasswordIsNotNull(email1).orElse(null);
Client result2 = clientRepository.findByEmailAndPasswordIsNotNull(email2).orElse(null);
Client result3 = clientRepository.findByEmailAndPasswordIsNotNull("").orElse(null);
//then
assertThat(result1).isNotNull();
assertThat(result1.getId()).isGreaterThan(0);
assertThat(result2).isNull();
assertThat(result3).isNull();
}
@Test
@DisplayName("OAuthID")
public void TestOAuthClient() {
//given
String googleId = "googleId";
String githubId = "githubId";
Client client = Client.createOAuth("oauth", "email", "picture", googleId, githubId);
clientRepository.save(client);
//when
Client result1 = clientRepository.findByGoogleOAuthId(googleId).orElse(null);
Client result2 = clientRepository.findByGithubOAuthId(githubId).orElse(null);
//then
assertThat(result1).isNotNull();
assertThat(result1.getGoogleOAuthId()).isEqualTo(googleId);
assertThat(result2).isNotNull();
assertThat(result2.getGithubOAuthId()).isEqualTo(githubId);
}
}
Service
서비스에선 비즈니스 로직을 수행하는데, 정상적으로 로직이 수행되는 경우와 그렇지 않은 경우가 있다.
다른 Bean들을 Mockito 라이브러리를 통해 Mock으로 만들고, 각 상황에 따른 로직 흐름을 테스트했다.
Mock
Mock이 되면 해당 Mock 객체는 원래의 함수를 호출할 수 있지만, 내부적으론 텅 비어있어 아무것도 수행하지 않게 된다.
아래와 같이 Mock을 설정하고 주입해줄 수 있다.
// Mockito를 사용하기 위해 필요하다.
@ExtendWith(MockitoExtension.class)
class ClientServiceTest {
// @Mock: 해당 객체는 껍데기가 된다.
@Mock ClientRepository clientRepository;
@Mock PasswordEncoder passwordEncoder;
@Mock HttpSession httpSession;
// @InjectMocks: 이 객체의 필드 중 @Mock으로 등록된 타입의 필드가 있다면 Mock으로 대체된다.
@InjectMocks
ClientService clientService;
...
}
이후 BDD 형식으로 Mock이 어떤 동작을 수행할지 정해줄 수 있다.
given(clientRepository.save(any(Client.class))).will(invocation -> {
Client argument = invocation.getArgument(0, Client.class);
argument.setId(1L);
return argument;
});
given(passwordEncoder.encode(any())).willReturn("$encoded_password$");
테스트 항목
테스트 항목은 Exception이 발생되는 모든 분기와 정상적으로 로직이 수행되는 경우로 구성했다.
가령 아래와 같이 로직이 수행될 경우, success에 도달해 성공적으로 수행되는 테스트케이스 하나와 exception1~3을 통해 실패하는 테스트케이스를 각각 하나씩 추가해 총 4개가 되는것이다.
start
...
if a -> throw exception1
...
if b -> throw exception2
...
if c -> throw exception3
...
success
이렇게 테스트 코드를 작성하는것 만으로도 테스트를 통해 함수가 무슨 역할을 하고, 어떤 입력과 출력을 가져야 하는지도 알 수 있겠다는 생각이 들었다. 문서로 작성하는것 만큼은 아니지만 테스트 코드를 잘 작성하는것 만으로도 코드를 관리하는데 다방면으로 도움이 될것 같다.
Controller
RestAssured를 사용하여 테스트 환경에서 서버의 API를 호출할 수 있다.
이를 통해 클라이언트 요청 및 서버 응답을 테스트했다.
@Test
@DisplayName("성공")
public void success() {
String email = "test@email.com";
ClientDto.Register register = new ClientDto.Register(email, "name", "!Asd1234");
ExtractableResponse<Response> response =
given()
.log().all()
.port(port)
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(register)
.when()
.post("/api/v1/client/register")
.then()
.log().all()
.statusCode(HttpStatus.SC_OK)
.extract();
ClientDto.Response client = response.jsonPath().getObject(".", ClientDto.Response.class);
assertThat(client.email()).isEqualTo(email);
assertThat(client.reputation()).isZero();
}
위와 같이 테스트 코드를 작성하고 실행시키면, 아래와 같이 실제로 요청을 보내고 응답을 받아온다.
테스트코드 작성이 편하고 가독성도 좋지만, 사용하기 위해 환경설정하는게 번거롭다.
환경설정
우선 RestAssured는 서버에 실제 요청을 보내기 때문에 요청을 처리하기 위한 서버 전체 기능이 동작해야 한다.
이를 위해 @SpringBootTest를 사용할 것이다.
하지만 이대로 테스트를 진행하면 아래와 같이 로그상엔 서버가 띄워졌는데 연결할 수 없다는 에러메시지를 보게 된다.
java.net.ConnectException: Connection refused
@SpringBootTest에는 WebEnvironment라는 설정이 들어있는데, 아래의 4개 설정들이 존재한다.
- MOCK: 실제 HTTP서버 (e.g. 내장 Tomcat)을 띄우지 않고, 웹 서버의 역할만 하는 Mocking된 웹 환경을 제공한다.
- DEFINED_PORT: application.yml에 정의된 port로 실제 서버를 띄운다.
- RANDOM_PORT: 랜덤 port로 실제 서버를 띄운다.
- NONE: 서블릿도 비활성화하여 웹 서버로 동작하지도 않는다.
이 중 Default는 MOCK으로, 실제 서버를 띄우지 않기 때문에 당연이 요청을 해도 아무런 응답이 오지 않는다.
아래와 같이 webEnvironment를 DEFINED_PORT나 RANDOM_PORT로 변경하면 해결된다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
RANDOM_PORT로 설정할 경우 RestAssured에 요청을 보낼 포트를 명시해줘야 한다.
@LocalServerPort를 사용하여 포트를 받아올 수 있다.
@LocalServerPort
private int port;
@Test
public void test() {
given()
.log().all()
.port(port)
...
}
위와 같이 포트 설정까지 마치면 테스트가 정상적으로 돌아갈것이다.
하지만 아직 문제가 하나 더 있다.
DEFINED_PORT, RANDOM_PORT를 사용할 경우 별도의 환경에서 서버가 실행되기 때문에, @Transactional이 작동하지 않아 이전 테스트 결과가 다음 테스트 영향을 주게 된다.
때문에 각 테스트가 끝날 때마다 모든 테이블을 Truncate 해줘야 한다.
이 부분은 망나니개발자님의 블로그를 참고했다.
https://mangkyu.tistory.com/264
[Spring] @SpringBootTest의 테스트 격리시키기(TestExecutionListener), @Transactional로 롤백되지 않는 이유
이번에 넥스트스텝 ATDD 강의를 듣게 되었습니다. 과제 중에 @SpringBootTest를 사용하는 테스트들을 격리시키는 부분이 있었는데, 제가 사용했던 방법을 공유하도록 하겠습니다. 1. SpringBootTest가 @Tran
mangkyu.tistory.com
아래와 같이 각 테스트가 끝난 후 모든 테이블을 Truncate 시켜주는 TestExecutionListener를 작성하고,
public class AcceptanceTestExecutionListener extends AbstractTestExecutionListener {
@Override
public void afterTestMethod(@NonNull final TestContext testContext) {
final JdbcTemplate jdbcTemplate = getJdbcTemplate(testContext);
final List<String> truncateQueries = getTruncateQueries(jdbcTemplate);
truncateTables(jdbcTemplate, truncateQueries);
}
private List<String> getTruncateQueries(final JdbcTemplate jdbcTemplate) {
return jdbcTemplate.queryForList("SELECT Concat('TRUNCATE TABLE ', TABLE_NAME, ';') AS q FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'PUBLIC'", String.class);
}
private JdbcTemplate getJdbcTemplate(final TestContext testContext) {
return testContext.getApplicationContext().getBean(JdbcTemplate.class);
}
private void truncateTables(final JdbcTemplate jdbcTemplate, final List<String> truncateQueries) {
execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY FALSE");
truncateQueries.forEach(v -> execute(jdbcTemplate, v));
execute(jdbcTemplate, "SET REFERENTIAL_INTEGRITY TRUE");
}
private void execute(final JdbcTemplate jdbcTemplate, final String query) {
jdbcTemplate.execute(query);
}
}
@TestExecutionListners 목록에 등록해주면 된다.
mergeMode는 default 리스너들을 대체할지 선택하는 것으로, MERGE_WITH_DEFAULTS로 설정하면 대체하지 않고 추가된다고 한다.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestExecutionListeners(value = {AcceptanceTestExecutionListener.class}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
모든 테스트에 위와 같은 어노테이션을 추가하기 번거롭기 때문에 아래와 같이 하나의 어노테이션으로 묶어줬다.
@Retention(RetentionPolicy.RUNTIME)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestExecutionListeners(value = {AcceptanceTestExecutionListener.class}, mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS)
public @interface AcceptanceTest {
}
요청 데이터 검증 테스트
아래와 같이 Request DTO에 Spring Validation을 적용해 클라이언트 요청 시 입력값을 검증받는다.
public record Register(
@NotEmpty @Email
String email,
@NotEmpty
@Pattern(
regexp = "^[0-9a-z가-힣]{1,10}$",
message = "숫자, 영문 소문자, 한글로 이루어진 10자리 이내의 이름이어야 합니다."
)
String name,
@NotEmpty
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,14}$",
message = "하나 이상의 대문자, 소문자, 숫자, 특수문자를 포함한 8~14 자리의 비밀번호이어야 합니다."
)
String password) {
}
이런 검증들이 정상적으로 되는지 확인하기 위해 잘못된 데이터로 요청하고, Bad Request 응답이 오는지 테스트했다.
@Test
@DisplayName("이메일 형식 오류")
public void emailPatternError() {
List<String> emailList = Arrays.asList(null, "", "@email.com", "test@.com", "test@email.");
emailList.forEach((email) -> {
ClientDto.Register register = new ClientDto.Register(email, "name", "!Asd1234");
ExtractableResponse<Response> response =
given()
.log().all()
.port(port)
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(register)
.when()
.post("/api/v1/client/register")
.then()
.log().all()
.statusCode(HttpStatus.SC_BAD_REQUEST)
.extract();
ErrorResponse errorResponse = response.jsonPath().getObject(".", ErrorResponse.class);
assertThat(errorResponse.code()).isEqualTo(CommonErrorCode.INVALID_PARAMETER.name());
});
}
@Test
@DisplayName("이름 형식 오류")
public void namePatternError() {
List<String> nameList = Arrays.asList(null, "", "A", "abcdefghijklmnop", "!@#$");
nameList.forEach((name) -> {
ClientDto.Register register = new ClientDto.Register("test@gmail.com", name, "!Asd1234");
ExtractableResponse<Response> response =
given()
.log().all()
.port(port)
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(register)
.when()
.post("/api/v1/client/register")
.then()
.log().all()
.statusCode(HttpStatus.SC_BAD_REQUEST)
.extract();
ErrorResponse errorResponse = response.jsonPath().getObject(".", ErrorResponse.class);
assertThat(errorResponse.code()).isEqualTo(CommonErrorCode.INVALID_PARAMETER.name());
});
}
@Test
@DisplayName("비밀번호 패턴 불일치")
public void passwordPatternError() {
List<String> passwordList = Arrays.asList(
null, // null
"", // empty
"a", // length < 8
"abcdefghijklmnopqrstuvwxyz", // length > 14
"!ABCDE123", // Does not contain lowercase
"!abcde123", // Does not contain uppercase
"Abcde1234", // Does not contain special character
"!@#Abcdef" // Does not contain number
);
passwordList.forEach((password) -> {
ClientDto.Register register = new ClientDto.Register("test@email.com", "name", password);
ExtractableResponse<Response> response =
given()
.log().all()
.port(port)
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(register)
.when()
.post("/api/v1/client/register")
.then()
.log().all()
.statusCode(HttpStatus.SC_BAD_REQUEST)
.extract();
ErrorResponse errorResponse = response.jsonPath().getObject(".", ErrorResponse.class);
assertThat(errorResponse.code()).isEqualTo(CommonErrorCode.INVALID_PARAMETER.name());
});
}
응답 데이터 테스트
요청에 대한 응답이 DTO로 잘 변환되는지, 데이터는 정상적인지 테스트한다.
@Test
@DisplayName("성공")
public void success() {
String email = "test@email.com";
ClientDto.Register register = new ClientDto.Register(email, "name", "!Asd1234");
ExtractableResponse<Response> response =
given()
.log().all()
.port(port)
.accept(ContentType.JSON)
.contentType(ContentType.JSON)
.body(register)
.when()
.post("/api/v1/client/register")
.then()
.log().all()
.statusCode(HttpStatus.SC_OK)
.extract();
ClientDto.Response client = response.jsonPath().getObject(".", ClientDto.Response.class);
assertThat(client.email()).isEqualTo(email);
assertThat(client.reputation()).isZero();
}
이렇게 테스트코드를 작성함으로써 오류를 사전에 발견해 나중에 디버깅하는데 걸릴 시간을 많이 단축시킬 수 있었다.
조금 귀찮더라도 테스트코드는 미리 작성하는게 좋은것 같다.
'Projects > OpenRoadmaps' 카테고리의 다른 글
[OpenRoadmaps] 6. 로드맵 에디터 기능 구현 (0) | 2023.01.27 |
---|---|
[OpenRoadmaps] 5. 로드맵 API 구현 및 테스트 (0) | 2023.01.05 |
[OpenRoadmaps] 3. 이메일 및 OAuth2 로그인 구현 (0) | 2022.11.28 |
[OpenRoadmaps] 2. 프로젝트 구조 설계 및 환경 설정 (0) | 2022.11.28 |
[OpenRoadmaps] 1. 프로젝트 시작 (0) | 2022.11.27 |