Web/Spring 2020. 3. 20. 21:44

 

이전 시간에 DB관련 테스트 작성하는 법을 다루어 봤는데, 이번 시간에는 Webflux handler 테스트 코드를 한번 작성해보려고 한다. 이전까지는 service 단까지만 테스트를 모듈별로 작성하였지만, 핸들러로 인입하여 한번에 모든 로직을 돌려보는 테스트는 직접 넣어보지 않았던 것 같다.(사실 핸들러, 컨트롤러 테스트가 이것저것 설정해야할 것들이 많아서..)

 

간단히 바로 예제를 다루어본다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Configuration
public class RouteConfig {
 
    @Bean
    public RouterFunction<ServerResponse> routeFunction(TestHandler testHandler) {
        return route()
                .nest(path("/"), builder -> builder
                        .GET("/test", accept(APPLICATION_JSON), testHandler::testHandler)
                ).build();
    }
}
 
@Component
public class TestHandler {
 
    @Autowired
    private TestService testService;
 
    public Mono<ServerResponse> testHandler(ServerRequest serverRequest) {
        return ServerResponse.ok().body(BodyInserters.fromProducer(testService.testService(), String.class));
    }
}
 
@Service
public class TestService {
    public Mono<String> testService() {
        return Mono.just("testResponse");
    }
}
cs

 

간단하게 handler와 service 클래스를 구현하였다. 단순히 String을 반환하고 있다. 해당 핸들러를 테스트하는 코드는 아래와 같다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
}
cs

 

spring MVC와는 조금 다를 수 있다. 해당 테스트 코드를 간단하게 설명하면 우리가 작성한 routeConfig를 WebTestClient에 바인딩해준다. 그 이후에 해당 클라이언트를 이용하여 routeConfig 내에 존재하는 라우팅 엔드포인트에 요청을 날리면 핸들러 로직을 수행후 응답을 내려준다. 그리고 해당 응답을 이용하여 원하는 응답이 나왔는지 assert 해볼 수 있다. 위는 정상적인 경우만 다루어보았고, 한번 없는 요청을 보내보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@SpringBootTest(classes = {
        TestHandler.class,
        RouteConfig.class,
        TestService.class
})
@MockBeans({
})
class HandlerTest {
 
    private WebTestClient client;
    @Autowired
    private RouteConfig routeConfig;
    @Autowired
    private TestHandler testHandler;
 
    private static final String ENDPOINT = "/test";
    private static final String INVALID_ENDPOINT = "/invalid";
 
    @BeforeEach
    public void beforeTest() {
        client = WebTestClient
                .bindToRouterFunction(routeConfig.routeFunction(testHandler))
                .build();
    }
 
    @Test
    public void handlerTest() {
        client.get()
                .uri(ENDPOINT)
                .exchange()
                .expectStatus().isOk()
                .expectBody(String.class)
                .value(value -> {
                    assertEquals(value, "testResponse");
                });
    }
 
    @Test
    public void notFoundHandlerMappingTest() {
        client.get()
                .uri(INVALID_ENDPOINT)
                .exchange()
                .expectStatus().is4xxClientError();
    }
 
}
cs

 

테스트를 하나더 추가하였다. 이 요청 url은 존재하지 않는 handler mapping이므로 정상적이라면 404 not found를 응답코드로 내려줄 것이라고 기대하고 테스트코드를 작성하였다.

 

실행해보면 테스트가 정상적으로 통과한다.

 

여기까지 간단하게 웹플럭스 핸들러 테스트를 작성해보았다. 아주 간단하게만 작성하였지만 테스트에 고려할만한 TC(Test Case)는 아주 많은 것같다. 400 예외를 고려한 테스트, 혹은 특정 예외를 고려한 테스트 혹은 모든 로직이 잘 동작하는 테스트 마지막으로 파라미터마다 동작이 다른것을 테스트 해보는 경우등 아주 많은 경우가 있을 것이다. 모든 테스트케이스를 잘 고려해 테스트를 짜보자.

 

마지막으로 만약 애플케이션에 ErrorHandler가 적용되어있다면 위와 같이 짠 테스트 코드는 ErrorHandler가 내뱉는 응답을 받지 못한다. 왜냐 테스트 컨텍스트 빈으로 등록하지 않았기 때문이다. 만약 파라미터가 잘못들어와 로직상에서 예외를 던졌고, 만약 그 예외를 ErrorHandler가 받아서 400이라는 HttpStatusCode로 리턴하고 있는데 그것을 기대하고 파라미터가 잘못들어온 경우를 is4xxClientError()로 잡으면 테스트는 통과하지 못한다. 그 이유는 WebTestClient는 500에러를 내뿜고 있기 때문이다. 그 이유는 위에 말한 것과 같이 ErrorHandler를 테스트 컨텍스트 빈으로 등록하지 않아서 4xx로 변환되지 못하고 애플리케이션 로직상에서 나는 예외(500)로 인식하기 때문이다. 

posted by 여성게
:
Web/Spring 2020. 3. 1. 01:34

 

오늘 다루어볼 내용은 테스트 코드에서 DB관련된 테스트를 작성하는 방법이다. 사실 지금까지 여러가지 유닛테스트를 짜면서 로직에 대한 검증을 하긴 했지만, 데이터베이스와 관련된 테스트 작성은 조금 꺼려하기는 했다. 이유는 여러가지이지만, 귀찮은 설정들이 필요하고 사실 외부 환경에 따라 테스트가 실패할 가능성도 있기 때문이다. 하지만 계속 미룰수는 없는 법.. 오늘은 간단하게 데이터베이스관련 된 테스트를 Junit으로 만들어볼 것이다.

 

환경

  • springboot
  • mongodb
  • junit

 

다루어볼 예제는 정말 간단한 User 객체에 대한 생성,수정,조회 등을 테스트해볼 것이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
buildscript {
    ext {
        flapdoodleVersion = "2.2.0"
    }
}
 
plugins {
    id 'org.springframework.boot' version '2.2.5.RELEASE'
    id 'io.spring.dependency-management' version '1.0.9.RELEASE'
    id 'java'
}
 
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '13'
 
configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-mongodb-reactive'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testImplementation 'io.projectreactor:reactor-test'
    testImplementation "de.flapdoodle.embed:de.flapdoodle.embed.mongo:${flapdoodleVersion}"
-> 임베디드 몽고 의존
}
 
test {
    useJUnitPlatform()
}
 
cs

 

build.gradle 내용이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
public class User {
 
    @JsonIgnore
    private ObjectId id;
    @NotNull
    private String username;
    @NotNull
    private Integer age;
    @NotNull
    private Address address;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class Address {
        @NotNull
        private String si;
        @NotNull
        private String gu;
        @NotNull
        private String dong;
    }
}
cs

 

간단한 유저 도메인 객체이다.

 

1
2
3
4
5
public interface UserRepository {
    Mono<User> findUserById(final ObjectId id);
    Mono<User> updateUser(final User user);
    Mono<User> createUser(final User user);
}
cs

 

해당 인터페이스는 DB Access 구현 방식에 구애 받지 않고 core한 영역에서 사용하기 위한 User Data Access 인터페이스이다. 나머지 코드를 보면 왜 구현 코드에 구애 받지 않는지 알 수 있다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Data
@AllArgsConstructor(staticName = "of")
@NoArgsConstructor
@Document("User")
public class UserData {
 
    @Id
    private ObjectId id;
    private String name;
    private Integer age;
    private AddressData userAddress;
    private String hobby;
 
    @Data
    @AllArgsConstructor(staticName = "of")
    @NoArgsConstructor
    public static class AddressData {
        private String si;
        private String gu;
        private String dong;
 
        public User.Address from() {
            return User.Address.of(
                    si,
                    gu,
                    dong
            );
        }
 
        public static UserData.AddressData to(User.Address address) {
            return UserData.AddressData.of(
                    address.getSi(),
                    address.getGu(),
                    address.getDong()
            );
        }
    }
 
    public User from() {
        return User.of(
                id,
                name,
                age,
                userAddress.from(),
                hobby
        );
    }
 
    public static UserData to(User user) {
        return UserData.of(
                user.getId(),
                user.getUsername(),
                user.getAge(),
                AddressData.to(user.getAddress()),
                user.getHobby()
        );
    }
}
cs

 

실제 DB Access를 위한 엔티티 클래스이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Repository
@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepository {
 
    private final UserMongoRepository repository;
 
    @Override
    public Mono<User> findUserById(final ObjectId id) {
        return repository.findById(id).map(UserData::from);
    }
 
    @Override
    public Mono<User> updateUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
    @Override
    public Mono<User> createUser(final User user) {
        return repository.save(UserData.to(user)).map(UserData::from);
    }
 
}
cs

 

실제 DB Access 구현체를 필드에 가지고 있는 UserRepository의 구현체이다. 즉, core(프레임워크에 영향을 받지 않는 비지니스 영역)한 영역에서 UserRepository를 사용할때는 구현체에 대한 자세한 정보가 필요하지 않고 DI해서 사용하면 되는 것이다. 

 

1
public interface UserMongoRepository extends ReactiveMongoRepository<UserData, ObjectId> { }
cs

 

UserRepositoryImpl가 가지고 있는 DB Access Object 구현체이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableReactiveMongoRepositories(basePackages = {"com.example.test.data.db.repositories"}, reactiveMongoTemplateRef = "userMongoTemplate")
public class MongoConfig {
 
    @Bean
    public MongoClient mongoClient() {
        return MongoClients.create("mongodb://localhost:27017");
    }
 
    @Bean
    public ReactiveMongoTemplate userMongoTemplate(MongoClient mongoClient) {
        return new ReactiveMongoTemplate(mongoClient, "USER_DB");
    }
 
}
cs

 

몽고디비 설정이다. 여기까지 실제 동작하는 코드 영역의 클래스들이다. 이러한 클래스들이 잘 동작하길 원할 것이고, 실제 테스트를 해볼 것이지만 효율적인 테스트 방법이 무엇일까? 관련 API를 만들어서 직접 호출해본다? 과연 좋은 테스트 방법인지는 모르겠다. 이러한 DB 관련 테스트 코드를 짜보자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@TestConfiguration
@EnableReactiveMongoRepositories(basePackages = {
        "com.example.test.data.db.repositories"
}, reactiveMongoTemplateRef = "userTestMongoTemplate")
public class TestEmbeddedMongoConfig {
 
    @Bean(initMethod = "start", destroyMethod = "stop")
    public MongodExecutable mongodExecutable() throws IOException {
        MongodStarter starter = MongodStarter.getDefaultInstance();
 
        IMongodConfig mongoDConfig = new MongodConfigBuilder()
                .version(Version.Main.V3_4)
                .net(new Net("127.0.0.1"27017false))
                .build();
 
        return starter.prepare(mongoDConfig);
    }
 
    @Bean("userTestMongoTemplate")
    public ReactiveMongoTemplate mongoTemplate() throws IOException {
        MongoClient mongo = MongoClients.create("mongodb://localhost:27017");
        return new ReactiveMongoTemplate(mongo, "USER_TEST_DB");
    }
}
cs

 

해당 설정은 테스트 코드에서만 사용하기 위한 설정이다. 몽고디비는 임베디드 몽고를 사용할 것이다. 필자로 사실 내부적으로 정확히 어떻게 동작하는지는 자세히 보지 않았지만, 실제 특정 포트를 물고 프로세스로 뜬다. 그래서 직접 포트 번호, ip를 기입해준다. 설정들이 다양하지만 간단히 예제를 다루어보는 것임으로 간단하게 설정을 마친다.

 

다음은 실제 테스트 코드이다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@ContextConfiguration(
        initializers = ConfigFileApplicationContextInitializer.class,
        classes = {
                TestEmbeddedMongoConfig.class,
                UserRepositoryImpl.class
        })
@ExtendWith(SpringExtension.class)
public class UserRepositoryTest {
 
    @Autowired
    private UserRepository userRepository;
 
    private User initData;
 
    @BeforeEach
    public void setup() {
        initData = userRepository.createUser(
                User.of(
                        new ObjectId(),
                        "yeoseonggae",
                        29,
                        User.Address.of("seoul""kangbuk""mia"),
                        "none"
                )
        ).block();
    }
 
    @Test
    @DisplayName("유저 데이터 생성 테스트")
    public void createUserTest() {
        //준비
        User createUser = User.of(
                new ObjectId(),
                "UserName",
                29,
                User.Address.of("seoul""kangbuk""mia"),
                "none"
        );
 
        //실행
        Mono<User> createdUser = userRepository.createUser(createUser);
 
        //단언
        StepVerifier.create(createdUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "UserName");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 데이터 수정 테스트")
    public void updateUserTest() {
        //준비
        initData.setUsername("여성게");
 
        //실행
        Mono<User> updatedUser = userRepository.updateUser(initData);
 
        //단언
        StepVerifier.create(updatedUser)
                .assertNext(user -> {
                    assertEquals(user.getId().toHexString(), initData.getId().toHexString());
                    assertEquals(user.getUsername(), "여성게");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
 
    @Test
    @DisplayName("유저 이름으로 유저 데이터 조회 테스트")
    public void findUserByNameTest() {
        //준비
        final ObjectId id = initData.getId();
 
        //실행
        Mono<User> findUser = userRepository.findUserById(id);
 
        //단언
        StepVerifier.create(findUser)
                .assertNext(user -> {
                    assertEquals(user.getUsername(), "yeoseonggae");
                    assertEquals(user.getAge(), 29);
                    assertEquals(user.getHobby(), "none");
                    assertEquals(user.getAddress().getSi(), "seoul");
                }).verifyComplete();
    }
}
cs

 

바로 위에 있는 테스트 몽고 설정을 import 한다. 그리고 우리는 UserRepository를 테스트 할것임으로 모든 빈들을 가져올 필요가 없이 테스트에 필요한 빈만 주입받아서 테스트 코드를 작성한다. 과연 결과는 어떻게 될까?(위 코드에 조금더 추가하여 실제로 데이터가 어떻게 왔다갔다하는지 콘솔로 찍었다.)

 

우선 테스트 성공 여부이다.

 

 

크아 깔끔하게 테스트를 통과하였다. 그렇다면 진짜 데이터가 왔다갔다 한것이 맞을까? 콘솔로 찍힌 로그를 확인해보자 !

 

 

유저 데이터 수정 테스트 로그이다.

 

 

유저 데이터 생성 테스트 로그이다.

 

 

마지막으로 조회 테스트 로그이다. 실제로 데이터가 잘 찍혀있다. 여기까지 간단히 몽고디비를 이용한 DB Access unit 테스트를 다루어보았다. 정말 간단한 테스트이지만, 이 예제를 잘 이용하면 실제 서비스 클래스 영역까지 사용되어서 정말 내가 개발한 클래스가 정상 작동하는지 확인해볼 수 있다.

 

혹시 코드가 필요하다면 아래 깃헙를 참고하자.

 

 

yoonyeoseong/dbTest

Contribute to yoonyeoseong/dbTest development by creating an account on GitHub.

github.com

posted by 여성게
: