서론

사실, 제목이 결론이다. @Requestbody Annotation을 사용하려면 반드시 해당 DTO에는 기본 생성자가 명시적으로 존재하여야 한다. 뭔가 설정을 잘못했는지는 모르겠지만 intellij 에서 stacktrace가 출력되지 않아 이 사실을 알 수 가 없어서 엄청나게 삽질을 했기에 내용을 정리한다…


본론

상황

controller 테스트에서 모든 조건을 맞췄는데도 500 에러가 발생하여 테스트를 통과하지 못하는 상황이었는데 @Requestbody annotation을 DTO가 아닌 HashMap으로 변경하니 테스트가 정상적으로 통과되어 JSON이 제대로 DTO로 매핑되지 않아서 발생하는 문제라고 판단했다.

에러 발생

No suitable constructor found for type [simple type, class 클래스명]: can not instantiate from JSON object (missing default constructor or creator, or perhaps need to add/enable type information?)

직역하면 기본 생성자가 존재하지 않아서 에러가 발생했다는 내용이다.

해결 방법

빌더패턴을 사용하던 클래스에 @Noargsconstructor annotation을 사용해서 기본 생성자를 생성하게 하였다.

결론

@Requestbody와 DTO를 매핑되게 해야 할 경우 많은 조건이 필요하다.
이전 포스트에 작성했던 boolean의 변수명이라던가, 지금과 같은 기본 생성자라 라던지… 이게 모두 @Requestbody가 jackson 라이브러리를 이용해 매핑하기 때문이다.


참고 사이트

댓글 공유

서론

현재 작업 중인 프로젝트에서 테스트 코드를 작성해 테스트할 일이 있었는데 프로젝트의 환경은 spring 4.3에 Junit 4.8이었다.

이에 원래 사용하던 junit5로 넘어갈까 하였으나 junit5를 사용하려면 설정을 spring boot으로 해야 한다는 글들이 있어 같은 테스트 환경을 만들기 위해 junit만 4.12 버전으로 업그레이드한 후 테스트를 진행하였는데 spring 프로젝트지만 config 설정들을 boot 처럼 java 파일로 관리하는 형태여서 java 파일과 properties 파일을 동시에 잡아 줄 필요가 있었는데
@Contextconfiguration(classes = {블라블라…}, locations = {블라블라…})로 잡으니 에러가 발생하여 해결한 방법을 작성해놓는다.


본론

코드

1
2
3
4
5
6
7
@ContextConfiguration(classes = {
DatabaseConfig.class,
SecurityConfig.class,
SocialConfig.class,
EnumConfig.class,
WebMvcConfig.class
}, locations = "classpath:properties/test.properties")

서론에 적은 것 처럼 classes와 locations를 둘 다 설정하였더니 아래와 같이 에러가 발생하였다.

에러

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
java.lang.IllegalArgumentException: Cannot process locations AND classes for context configuration [ContextConfigurationAttributes@64c87930 declaringClass = 'com.xxxx.xxxImplTest', classes = '{class com.xxxx.config.DatabaseConfig, class com.xxx.config.SecurityConfig, class com.xxx.config.SocialConfig, class com.xxx.config.EnumConfig, class com.xxx.config.WebMvcConfig}', locations = '{classpath:properties/test.properties}', inheritLocations = true, initializers = '{}', inheritInitializers = true, name = [null], contextLoaderClass = 'org.springframework.test.context.ContextLoader']: configure one or the other, but not both.

at org.springframework.util.Assert.isTrue(Assert.java:68)
at org.springframework.test.context.support.AbstractDelegatingSmartContextLoader.processContextConfiguration(AbstractDelegatingSmartContextLoader.java:154)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:371)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:305)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:112)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:120)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:105)
at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.createTestContextManager(SpringJUnit4ClassRunner.java:152)
at org.springframework.test.context.junit4.SpringJUnit4ClassRunner.<init>(SpringJUnit4ClassRunner.java:143)
at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at sun.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.lang.reflect.Constructor.newInstance(Constructor.java:423)
at org.junit.internal.builders.AnnotatedBuilder.buildRunner(AnnotatedBuilder.java:104)
at org.junit.internal.builders.AnnotatedBuilder.runnerForClass(AnnotatedBuilder.java:86)
at org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
at org.junit.internal.builders.AllDefaultPossibilitiesBuilder.runnerForClass(AllDefaultPossibilitiesBuilder.java:37)
at org.junit.runners.model.RunnerBuilder.safeRunnerForClass(RunnerBuilder.java:70)
at org.junit.internal.requests.ClassRequest.createRunner(ClassRequest.java:28)
at org.junit.internal.requests.MemoizingRequest.getRunner(MemoizingRequest.java:19)
at org.junit.internal.requests.FilterRequest.getRunner(FilterRequest.java:36)
at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:49)
at com.intellij.rt.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:33)
at com.intellij.rt.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:230)
at com.intellij.rt.junit.JUnitStarter.main(JUnitStarter.java:58)

해결

@Testpropertysource(classpath:””)로 test properties를 잡아주고
@Contextconfiguration(classes = { XXXConfig.class}) 로 Java config 파일들을 잡아준다.

1
2
3
4
5
6
7
8
@ContextConfiguration(classes = {
DatabaseConfig.class,
SecurityConfig.class,
SocialConfig.class,
EnumConfig.class,
WebMvcConfig.class
})
@Testpropertysource("classpath:properties/test.properties")

댓글 공유

상황

프로덕션 환경에서 Mariadb를 활용하고 있었는데, 프로덕션 환경의 DB로 Repository class를 테스트를 진행하니 테스트 데이터는 롤백되어 DB 남지 않았지만, Auto increment로 지정한 ID가 증가돼서 실제 서비스에서 데이터 저장 시 증가된 ID로 데이터가 저장되는 문제가 발생했다.

해서 Test할 때는 사용 할 데이터베이스 H2를 이용하기로 했다.
H2 데이터베이스는 인메모리 관계형 데이터베이스로 메모리 안에서 실행되기 때문에 어플리케이션을 시작할 때마다 초기화되어 테스트 용도로 많이 사용된다.
하지만 테스트 환경도 프로덕션 환경과 비슷하게 만들어서 테스트 하는 경우에는 테스트환경에도 프로덕션 DB를 생성해서 사용하는 경우도 있다고 한다.

필자가 okky에 올린 질문글
Repository Test시 ID 자동 증가


Test 환경에서 H2 적용

우선 H2 DB를 POM.xml에 추가하여 의존성을 등록한다.

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>

그 후 Test 환경에서 사용하는 appication.properties에서 데이터베이스 설정을 H2로 설정해준다.

application-test.properties 위치

application-test.properties 내용

1
2
3
4
5
6
7
8
9
spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true

spring.jpa.hibernate.dialect=org.hibernate.dialect.MariaDBDialect
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
  • 프로덕션 환경에서 Mariadb를 사용하기 때문에 dialect를 Mariadb로 설정하고 MODE=Mysql로 설정했다.
  • spring.jpa.hibernate.ddl-auto=none으로 설정하면 시작 시 마다 초기화되지 않기 떄문에 테스트 환경에선 꼭 create-drop으로 설정해준다.

Repository Test class

1
2
3
4
5
6
@ExtendWith(SpringExtension.class)
@DataJpaTest
@TestPropertySource("classpath:application-test.properties")
class WordRepositoryTest {
...
}
  • JPA Test를 위해 JPA 관련된 설정을 불러오는 @DataJpaTest
  • Test환경에선 프로덕션 환경과 다르게 H2 DB를 사용하므로 H2 DB설정을 지정한 application-test.properties를 호출하기 위한 @TestPropertySource

이 정도로 설정하고 Test하면 정상적으로 H2를 이용한 테스트가 성공할 것이다.


여담

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@BeforeEach
void setUp() {
user = new User();
user.setUsername("jgji");
user.setPassword("qwe123");

userRepository.save(this.user);

user1 = new User();
user.setUsername("haha");
user.setPassword("qwe123");

userRepository.save(this.user1);

List<Word> givenWordList = getWordGiven();
this.word = givenWordList.get(0);
this.word1 = givenWordList.get(1);
}

위 처럼 @Before 메서드를 지정해놓았는데, 각각 Test 메서드를 실행하였을 땐 Auto increment로 지정한 user의 id가 정상적으로 1, 2 이런식으로 생성되었지만 test class 전체로 test를 실행하니 DB가 메서드 마다 각각 실행하고 초기화 되는 것이 아닌지 User Id가 계속 증가해서 테스트가 실패하는 문제가 있었다.

테스트 시 꼭 메서드 각각으로하고 성공한다고 넘어가는게 아니라 클래스 전체로 테스트를 해봐야 할 것 같다.


프로젝트 전체 코드


참고 사이트

댓글 공유

서론

private method도 테스트 해야되는지에 대해선 의견이 많은 것으로 알고 있지만 현재 프로젝트에서 service에서 있는 메서드 중 주요 로직은 private method에 존재하고 public method에선 호출해서 return만 해주는 method가 존재했는데, 이런 경우에 private 메서드를 테스트하기 위한 방법을 정리해둔다.


본론

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
void getPassAndFailWordList_성공() throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
//given
String[] answerIds = new String[] {"6_1", "4_1", "5_1", "9_3"};
WordService w = new WordServiceImpl(repository, userService);

Method method = w.getClass().getDeclaredMethod("getPassAndFailWordList", String[].class);
method.setAccessible(true);

List<Integer> passWordList = Arrays.asList(6,4,5);
List<Integer> failWordList = Arrays.asList(9);

Object[] obj = new Object[] {answerIds};

//when
Map<String, List<Integer>> map = (Map<String, List<Integer>>) method.invoke(w, obj);

//than
assertThat(map).extracting("pass", String.class)
.contains(passWordList);
assertThat(map).extracting("fail", String.class)
.contains(failWordList);
}
  1. test할 private method가 존재하는 class를 직접 생성
  2. getDeclaredMethod()를 이용해서 해당 클래스에 존재하는 private method를 가져오고
  3. setAccessible()로 private method에 접근을 허용
  4. invoke()로 호출하는데 이 때 invoke()의 매개변수가 Object[] 이므로 원래 호출하려던 private method의 매개변수를 Object[]에 담은 후 Object[]을 매개변수로 넘겨줘야함

참고 사이트

댓글 공유

문제

Test Code

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
@Test
void testCreateWordFail() throws Exception {
// gvien
BindingResult bindingResult = new MapBindingResult(new HashMap(), "");
bindingResult.rejectValue("test", "test", "haha");

WordDTO.AddWord addWord = new WordDTO.AddWord();
addWord.setWord("test");
addWord.setMeaning("테스트");

given(this.service.getCreateWordBindingResult(addWord, bindingResult)).willReturn(bindingResult);

String addWordToJson = getAddWordToJson();

//when
ResultActions action = mockMvc.perform(post("/word/add")
.param("save", "")
.content(addWordToJson)
.contentType(MediaType.APPLICATION_JSON)
.with(csrf()))
.andDo(print());

//then
action.andExpect(status().isOk())
.andExpect(view().name("thymeleaf/word/createWordForm"));
}

위 코드처럼 this.service.getCreateWordBindingResult(addWord, bindingResult) method가 호출될 때 willdReturn으로 지정한 bindingResult가 return되게 하고 하려했으나 given이 제대로 동작하지 않아서 인지 getCreateWordBindingResult method 안에서 result가 존재하지 않아 NullPointerException이 발생하여 테스트가 실패했다.

Test Target

1
2
3
4
5
6
7
8
9
10
11
@PostMapping(value="/word/add", params= {"save"})
public String createWord(@Valid AddWord word, BindingResult bindingResult) {
BindingResult result = service.getCreateWordBindingResult(word, bindingResult);

if (result.hasErrors()) { // NullPointerException 발생
return "thymeleaf/word/createWordForm";
}

service.insertWord(word);
return "thymeleaf/index";
}

해결

ArgumentMatchers class에 any()를 이용해서 파라미터를 넘겼더니 정상적으로 동작했다.

1
given(this.service.getCreateWordBindingResult(any(WordDTO.AddWord.class), any(BindingResult.class))).willReturn(bindingResult);
  • p.s : 기본 자료형 (int, char)이나 String 등은 any()를 사용하지 않고 그대로 파라미터로 받아도 정상적으로 동작하는 것 같은데 어째서 따로 정의한 클래스만 인식(?) 하지 못해서 willReturn이 먹히지 않고 null이 return 되는지 알 수가 없었다.

전체 코드

댓글 공유

문제

Spring Boot + thymeleaf으로 진행하던 프로젝트를 테스트 하던 중
Ajax로 호출하는 Methoed를 Test 하였더니 아래와 같이 403 error가 return 되면서 Test가 실패했다.


원인

thymeleaf를 사용하면 기본적으로 CSRF 토큰을 넘겨주기 때문에 따로 csrf 토큰을 생성하지 않았으나, ajax에선 직접 csrf 토큰을 생성해서 넘겨줘야 하기 때문에 csrf 토큰을 생성해서 넘겨주도록 되어 있는데 Test 시에는 그렇지 않아서 403 Forbidden가 발생한 것으로 보인다.

403

thymeleaf config.html

1
2
<meta id="_csrf" name="_csrf" th:content="${_csrf.token}"/>
<meta id="_csrf_header" name="_csrf_header" th:content="${_csrf.headerName}"/>

common.js

1
2
const token = $("meta[name='_csrf']").attr("content");
const header = $("meta[name='_csrf_header']").attr("content");

해결

mockMvc.perform에서 get, post 호출 시 SecurityMockMvcRequestPostProcessors 의 csrf()with()로 파라미터에 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;

...

//when
ResultActions action = mockMvc.perform(post("/word/add")
.param("save", "")
.content(addwordToJson)
.contentType(MediaType.APPLICATION_JSON)
.with(csrf())) // 이 부분
.andDo(print());

...

추가 한 후 다시 테스트 해보면 파라미터에 csrf 토큰이 생성되어 정상적으로 동작하는 것을 확인 할 수 있다.

1
2
3
4
5
6
7
8
9
10
MockHttpServletRequest:
HTTP Method = POST
Request URI = /word/add
Parameters = {save=[], _csrf=[6757e021-c0a3-4390-81d9-9c917d89c8c1]} // 이 부분
Headers = [Content-Type:"application/json", Content-Length:"59"]
Body = <no character encoding set>
Session Attrs = {SPRING_SECURITY_CONTEXT=org.springframework.security.core.context.SecurityContextImpl@83a38fdf: Authentication: org.springframework.security.authentication.UsernamePasswordAuthenticationToken@83a38fdf: Principal: org.springframework.security.core.userdetails.User@31bf3c: Username: jgji; Password: [PROTECTED]; Enabled: true; AccountNonExpired: true; credentialsNonExpired: true; AccountNonLocked: true; Granted Authorities: ROLE_USER; Credentials: [PROTECTED]; Authenticated: true; Details: null; Granted Authorities: ROLE_USER}

...

성공


참고 사이트


전체 코드

댓글 공유

평소 Junit의 assertEquals로 알고리즘 코드만 test 하다가
토이 프로젝트에서도 Junit을 사용해보기 위해 Spring에서의 Junit Test 방법을 정리 해보자 한다


Test할 Controller

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class HomeController {

@GetMapping("/")
public String home(Locale locale, Model model) {
Date date = new Date();
DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, locale);

String formattedDate = dateFormat.format(date);

model.addAttribute("serverTime", formattedDate );

return "home";
}
}
  • index 페이지로 매핑되는 home 메서드만 존재하는 Controller

standaloneSetup() 메서드를 활용한 Test

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HomeControllerTest {

private MockMvc mockMvc;

@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.standaloneSetup(new HomeController()).build();
}

@Test
public void homeTest() throws Exception {

mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("serverTime"))
.andExpect(view().name("home"));
}
}

MockMvc 클래스를 통해 HTTP GET, POST 등에 대한 테스트를 할 수 있게 한다

  • perform() : get 방식으로 url을 호출
  • status() : http status에 대해 테스트
  • model() : model에 “serverTime” attribute가 존재하는 지 확인
  • view() : return하는 view의 name이 “home”인지 확인

위 메서드들을 정상적으로 사용하기 위해선 아래 처럼 메서드들을 import 시켜야한다

1
2
3
4
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

webAppContextSetup() 메서드를 활용한 Test

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
@RunWith(SpringRunner.class)
@ContextConfiguration(locations = {"file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml"
, "file:src/main/webapp/WEB-INF/spring/root-context.xml"
, "file:src/main/webapp/WEB-INF/spring/spring-security.xml"})
@WebAppConfiguration
public class HomeControllerTest {

@Autowired
private WebApplicationContext context;

private MockMvc mockMvc;

@Before
public void setUp() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context).build();
}

@Test
public void homeTest() throws Exception {

mockMvc.perform(get("/"))
.andExpect(status().isOk())
.andExpect(model().attributeExists("serverTime"))
.andExpect(view().name("home"));
}
}

@RunWith

  • Junit에 내장된 Runner외에 다른 실행자를 실행시킨다
  • 위 코드에선 SpringRunner 실행자로 Test를 실행하는데 이때 SpringRunner class는 SpringJUnit4ClassRunner class를 상속받고 있다
  • 테스트를 진행할 때 ApplicationContext를 만들고 관리하는데 이 때 싱글톤을 보장한다

@ContextConfiguration

  • 스프링 Bean 설정 파일의 위치를 지정한다
  • 위 코드에서는 Controller는 servlet-context.xml, 그 외 Service, Repository 등은 root-context.xml, 암호화를 위한 bcryptPasswordEncoder bean은 spring-security.xml에 지정되어 있어 3개를 지정했다

@WebAppConfiguration

  • applicationContext의 웹 버전을 작성하는데 사용된다

standalonesetup과 webAppContextSetup의 차이

standalonesetup() 메서드는 테스트할 컨트롤러를 수동으로 초기화해서 주입하고, webAppContextSetup() 메서드는 스프링의 ApplicationContext의 인스턴스로 동작한다

standalonesetup() 메서드는 컨트롤러에 집중하여 테스트할 때 사용되고
webAppContextSetup() 메서드는 스프링의 DI 컨테이너를 이용해 스프링 MVC 동작을 재현해서 테스트한다


번외 java.lang.NoClassDefFoundError: javax/servlet/SessionCookieConfig 에러

1
2
3
4
5
6
7
8
9
10
11
12
13
java.lang.NoClassDefFoundError: javax/servlet/SessionCookieConfig
at org.springframework.test.web.servlet.setup.StandaloneMockMvcBuilder.initWebAppContext(StandaloneMockMvcBuilder.java:339)
at org.springframework.test.web.servlet.setup.AbstractMockMvcBuilder.build(AbstractMockMvcBuilder.java:139)
at com.toon.controller.HomeControllerTest.setUp(HomeControllerTest.java:25)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(Unknown Source)
at java.lang.reflect.Method.invoke(Unknown Source)
at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
at org.junit.internal.runners.statements.RunBefores.evaluate(RunBefores.java:24)
... 26 more
  • Spring 4.0 이상에서 Junit으로 테스트 시 servlet 3.0 API를 사용하기 때문에 발생
  • pom.xml에서 servlet 버전을 3.0.1 이상으로 올려주면 해결
  • artifactId가 javax.servlet-api 으로 변경 됐으므로 함께 변경해준다.
1
2
3
4
5
6
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>3.1.0</version>
<scope>provided</scope>
</dependency>

참고 사이트

댓글 공유

  • page 1 of 1

Junggu Ji

author.bio


author.job