본문 바로가기
자바과정/스프링

Spring Framework로 하는 TDD 감 익히기

by Parkej 2021. 10. 3.

 

더보기

본 글은 https://show400035.tistory.com/156?category=1023010 에 있는 실습내용을 본떠 테스트 주도 개발의 감을 익혀보려 연습한 내용입니다. 

 

또한 실습했던 코드는 https://github.com/p-ej/restStudy 에 게시하였습니다.

 

개발환경

- IDE : Eclipse 2021.03 ( 버전을 명시해놓는 이유... 2021.06 버전에서 스프링 레거시 프로젝트 생성이 안되는 오류를 경험했기 때문 )

- OS : Window 10

- Language : JAVA (JDK 11)

- Framework : Spring Framework 5.1.1 (Boot 아님), JUnit 4.12


TDD을 알아보기 전 제가 Spring framework에서 사용했던 내용들을 알아보겠습니다. 

# 출처 및 참고

 

먼저 JUnit 이란것이 있는데, 이것은 Java에서 독립된 *단위 테스트(Unit Test)를 지원해주는 프레임워크라고 합니다. 

 

그럼 이 단위 테스트(Unit Test)라는것이 무엇일까요 ?

 - 소스코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차

 - 모든 함수메소드에 대한 테스트 케이스(Test case)를 작성하는 절차

 - JUnit은 보이지 않고 숨겨진 단위 테스트를 끌어내어 정형화시켜 단위테스트를 쉽게 해주는 테스트 지원 프레임워크

 

 

앞으로 사용하게 될 JUnit에 대한 특징입니다. 

 - 단정(assert) 메서드로 테스트 케이스의 수행 결과를 판별합니다. ( assertEquals(예상val, 실제val))

 - JUnit4 부터는 테스트를 지원하는 어노테이션을 제공합니다. ( @Test, @Before, @After)

 - @Test 메서드가 호출할 때마다 새로운 인스턴스를 생성하여 독립적인 테스트가 이루어지게 합니다. 

 

 

JUnit에서 지원하는 어노테이션입니다. (Annotation)

@Test

 - 해당 어노테이션이 선언되면 메서드는 테스트를 수행하는 메서드가 됩니다. (단위 테스트 선언)

 - JUnit은 각각의 테스트가 서로 영향을 주지 않고 독립적으로 실행됨을 원칙으로 @Test 마다 객체를 생성합니다. 

 - @Test(timeout=6000) 단위는 밀리, 해당 시간을 넘기면 실패합니다.

 - @Test(expected=NullPointerException)은 NullPointerException이 발생하면 통과입니다.

 

@Ignore

 - 해당 어노테이션의 메서드는 테스트를 실행하지 않습니다. 

 

@Before

 - 해당 어노테이션의 메서드는 @Test 메서드가 실행되기 전에 먼저 실행됩니다. 

 - 테스트 이전에 실행 할 메소드를 지정합니다.

 - @Test 메소드가 실행 될 때마다 객체를 생성하여 실행합니다. 

 - @Test 메서드에서 공통으로 사용하는 코드를 @Before 메서드에 선언하여 사용하면 됩니다. 

 

@After 

 - 해당 어노테이션의 메서드는 @Test 메소드가 실행된 후 실행됩니다. 

 - 테스트 이후에 실행 할 메소드를 지정합니다.

 - @Test 메소드가 실행 될 때마다 객체를 생성하여 실행합니다. 

 

@BeforeClass

 - 해당 어노테이션의 메소드는 @Test 메소드보다 먼저 한번만 수행되어야 할 경우에 사용하면 됩니다.

 - 테스트 이전에 실행 할 메소드를 지정합니다.

 - @Before와 차이점은 한번만 실행되며 static으로 선언하여야 합니다.  

 

@AfterClass

 - 해당 어노테이션의 메소드는 @Test 메소드보다 나중에 한번만 수행되어야 할 경우에 사용하면 됩니다. 

 - 테스트 이후에 실행 할 메소드를 지정합니다.

 - @After와 차이점은 한번만 실행되고 static으로 선언하여야 합니다. 

 

 

자주 사용하는 JUnit의 클래스/메소드입니다.  (저는 controller 테스트를 위해 MovcMvc와 perform()을 사용했습니다.)

assert 메소드

 - assertEquals(a, b); : 객체 a와 b의 값이 일치함을 확인합니다.

 - assertArrayEquals(a, b) : 배열 a와 b의 값이 일치함을 확인합니다. 

 - assertSame(a, b); : 객체 a와 b가 같은 객체임을 확인합니다. 두 객체의 레퍼런스가 동일한가를 확인합니다.

 - assertTrue(a); : 조건 a가 참인가를 확인합니다.

 - assertNotNull(a); : 객체 a가 null이 아님을 확인합니다. 

 

perform() 메소드

 - DispatcherServlet에 요청합니다.

 - get, post, put, delete, fileUpload 등의 메소드 제공합니다.

 - ResultActions() 호출합니다. 

 

MockMvc 메소드

 - 서버를 실행하지 않고 스프링 MVC 동작을 재현할 수 있는 클래스입니다.

 - MockMvc는 TestDispatcherServlet에게 요청합니다.

 

MockHttpServletRequestBuilder() 메소드

 - param/params : 요청 파라미터 설정

 - header/headers : 요청 헤더 설정

 - cookie : 쿠키 설정

 - content : 요청 본문 설정

 - requestAttr : 요청 스코프에 객체 설정

 - flashAttr : 플래시 스코프에 객체를 설정

 - sessionAttr : 세션 스코프에 객체를 설정 

 

 

MockMvcResultMatchers() 메소드

 - status : HTTP 상태 코드 검증

 - header : 응답 헤더의 상태 검증 

 - cookie : 쿠키 상태 검증

 - content : 응답 한 본문 내용 검증

 - view : 반환 된 뷰 이름 검증

 - forwardedUrl : 경로 검증

 - redirectedUrl : 경로나 url 검증

 - model : 모델 상태 검증

 - flash : 플래시 스코프 상태 검증

 - request : 비동기 처리의 상태나 요청 스코프의 상태, 세션 스코프 상태 검증 

 

ResultActions().andExpect()

 - 실행 결과를 검증 인수 설정합니다.

 

 

ResultActions().andDo()

 - 실행 결과를 처리할 수 있는 인수 지정합니다.

 

log()

 - 디버깅 레벨에서 로그를 출력합니다.

 

print()

 - 실행 결과를 출력합니다. 

 

 

Spring-Test에서 테스트를 지원하는 어노테이션입니다.

@RunWith(SpringJUnit4ClassRunner.class)

 - 해당 어노테이션은 JUnit 프레임워크의 테스트 실행방법을 확장할 때 사용하는 어노테이션입니다. 

 - SpringJUnit4ClassRunner 클래스를 지정하면 JUnit이 테스트를 진행하는 중에 ApplicationContext를 만들고 관리하는 작업을 진행해줍니다.

 - 각각의 테스트 별로 객체가 생성되더라도 싱글톤(Singletone)의 ApplicationContext를 보장합니다. 

 - 지정하지 않으면 SpringRunner.class로 사용됩니다.

 

@ContextConfiguration

 - Spring Bean 메타 설정 파일의 패키지에서 설정 파일을 찾습니다. (설정 파일의 위치를 지정할 때 사용함.)

 - 지정하지 않는다면 테스트 파일의 패키지에서 설정 파일을 찾습니다.

 

@Autowired

 - 스프링 DI에서 사용되는 어노테이션입니다

 - 해당 변수에 자동으로 빈(Bean)을 매핑해줍니다.

 - 스프링 빈(Bean) 설정 파일을 읽이 위해 GenericXmlApplicationContext를 사용할 필요가 없습니다. 

 - 변수, setter메서드, 생성자, 일반메서드에 적용이 가능합니다.

 - 의존하는 객체를 주입할 때 주로 Type을 이용합니다. 

 - xml 빈 설정 파일의 <property>, <constructor-arg> 태그와 동일한 역할을 합니다. 

 

@WebAppConfiguration

 - 웹 애플리케이션 전용 DI 컨테이너로 처리합니다. 

 


# 출처 및 참고

TDD (테스트 주도 개발)

 - TDD는 Test Driven Development 의 약자입니다. 

흐름은 구현 -> 테스트 -> 리팩토링 입니다. ( 선 테스트코드 작성 후 실제 코드를 개발한다. )

또한

 - 목표 주도개발, 사용자 중심 개발, 인터페이스 중심 개발

을 할 수 있습니다. 

 

TDD를 하는 목적이 무엇일까요 아래와 같이 볼 수 있습니다.

 - 새로운 버그의 발생을 즉시 파악할 수 있다.

 - 잘 작동하는 클린 코드

 - 방치 된 1개의 실패는 전체의 실패가 된다.

 

그럼 TDD의 목표를 알아보겠습니다.

 - 버그 발생을 파악 할 수 있어야 합니다.

 - 내일, 모레, 1년, 10년 후에도 정상적으로 작동해야 합니다.

 - 재사용이 가능하고 자동화가 가능해야 합니다.

 - 수정/보완 된 코드로 인해 기존 코드에 버그가 발생하지 않음을 보장할 수 있습니다. 

 

TDD의 Cycle은 아래와 같습니다.

 - Red : 실패

 - Green : 성공

 - Refactoring test : 코드 리팩토링

 

 

Spring Framework에서 테스트를 하는 방법입니다

구조

 

 - 자신이 Controller를 테스트하려 한다면 src/test/java 폴더안의 패키지에서 컨트롤러이름 + "Test"를 명시하시고 클래스를 생성하면 됩니다. 

 - 또한 원활한 작업을 위해 스프링 프레임워크 버전을 4이상으로 올려주시고 pom.xml에 테스트를 위한 라이브러리를 Maven Dependency에 등록해줍니다. 

 

<!-- Servlet -->
		<!-- <dependency> <groupId>javax.servlet</groupId> <artifactId>servlet-api</artifactId> 
			<version>2.5</version> <scope>provided</scope> </dependency> -->

		<!-- https://mvnrepository.com/artifact/javax.servlet/javax.servlet-api -->
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>javax.servlet-api</artifactId>
			<version>4.0.1</version>
		</dependency>

		<dependency>
			<groupId>javax.servlet.jsp</groupId>
			<artifactId>jsp-api</artifactId>
			<version>2.1</version>
			<scope>provided</scope>
		</dependency>
		<dependency>
			<groupId>javax.servlet</groupId>
			<artifactId>jstl</artifactId>
			<version>1.2</version>
		</dependency>

		<!-- Test -->
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<version>4.12</version>
			<scope>test</scope>
		</dependency>

		<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
			<artifactId>jackson-databind</artifactId>
			<version>2.12.4</version>
		</dependency>

		<!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
		<dependency>
			<groupId>org.springframework</groupId>
			<artifactId>spring-test</artifactId>
			<version>${org.springframework-version}</version>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.skyscreamer</groupId>
			<artifactId>jsonassert</artifactId>
			<version>1.5.0</version>
		</dependency>

 


 

 

저는 TDD (테스트 주도 개발)을 제대로 또는 정확하게 하기 위해서 어떻게 해야할까 고민을 하고 실무에선 어떻게 쓰일까 하는 궁금증이 계속 생겼습니다. 무작정 실습을 한다기보다 이론적으로 왜 이것을 해야만 하는가에 알아내면 앞으로 TDD를 할때 좀 더 수월해지지 않나 싶었습니다. 

 

테스트 주도 개발을 하는 이유입니다. 

 - 우리가 프로그래밍을 하면서 생기는 버그(오류)들을 많이 보셨으리라 생각합니다. 구글링을 하면서 해당 오류를 찾아내 수정하고 하는 작업들은 지속적으로 하다보면 익숙해져서 해당 오류를 다시 재발생시킬 확률이 줄어듭니다. 요약하자면 버그잡이는 삽질이 아니고 개발의 일부라고 말할 수 있습니다. 

 

 - 또한 버그 발생의 원인을 인지하는 간격이 멀면 삽질과 똑같다 볼 수 있습니다. 

-> 버그 이유를 찾는다 -> 분석한다 -> 수정한다 -> 반영한다 -> 테스트 한다. 

이 부분은 성공하면 일사천리지만 아니면 처음부터 다시 해야 하는 번거로움이 있거든요.

 

1. 개발자가 의도한대로 로직이 동작하는지 명확하게 알 수 있고, 로직에 대해 보증이 가능하다.

2. 사전에 다양한 케이스를 고려해봄으로써 문제가 될 수 있는 잠재적 오류들을 방어할 수 있다.

3. 아키텍처와 로직이 깔끔해진다. (테스트가 어려운 코드는 좋은 코드가 아니다.)

4. 이후에 다른 사람이 로직을 수정하게 될 때, 로직의 변경에 대한 영향도가 명확하게 보여진다. (변경에 드는 비용이 적다는 것을 의미함.)

 

 

테스트 주도 개발을 할시 장점

 - 기능에 집중할 수 있습니다. 

 예를 들어 코드 구현 -> 서버 실행 -> 수동 입력 -> 실행 -> 에러 -> 에러 분석(시간소요) -> 해결 or 버그(처음으로)

 해당 부분을 테스트로 작성하면 에러를 즉시 확인 할 수 있어서 반복 과정이 줄어들게 됩니다.

 - 작성 한 코드만 필요로합니다.

 DB 연동, 서버 연결 없이 테스트가 가능합니다. 

 

 

테스트 주도 개발을 하기 위한 방법 정리 (와닿는 말이라 가져왔습니다.)

 - 아직 구현하지도 않은 상태에서 테스트를 구현하는 사고 방식에 적응이 필요합니다. 처음에는 노력이 필요핮지만 어느 순간분터는 일상이되는 부분을 적용 시킬 수 있습니다. 개발의 목표는 작은 절차부터 순서대로 하는 것, 소프트웨어는 언제든 변경되니 고민하는 것을 멈추지 말자.

 

1. 입력과 출력 결정

 INPUT : 비밀번호

 OUTPUT : 비밀번호 강도나 참/거짓

 

2. 함수 시그니처 선택

 - 매개변수와 결과를 어떻게 할지 정하는 것.

 

3. 기능상 하나의 작은 관점으로 판단

 - 보통은 이 단계에서 코드를 작성하기 때문에 TDD와 다른 부분입니다. 

 - 모든 경우의 수를 생각하지 않고 최소한의 동작에 집중합니다. 

 - 빈 문자열이라면? 아이디와 비밀번호가 같다면? 빈 문자열만 생각해봅니다. 

 

4. 테스트 구현

 - 함수가 어떻게 동작하는지 테스트 하는 단계입니다.

 

5. 코드 구현

 - 최소한의 작동 코드 구현

 

 

단위 테스트 의미

 - 전통적인 단위 테스트 : 제품의 기능을 테스트

 - TDD의 단위 테스트 : 메소드 단위의 테스트 

 

 

테스트 케이스를 작성하기 위한 팁

 - 요구 사항 이름의 테스트 케이스로 메소드 이름을 한글로 작성한다.

 - 깨진 테스트 케이스는 삭제하고 커밋 전 리뷰로 보완한다,

 - 시스템 테스트, 통합 테스트도 자동화를 해야한다. 

 

 


# RestStudyControllerTest

package com.rest.test;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


import javax.inject.Inject;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.rest.test.controller.RestStudyController;
import com.rest.test.model.User;

@RunWith(SpringJUnit4ClassRunner.class) // 해당 어노테이션은 JUnit 프레임워크의 테스트 실행방법을 확장할 때 사용하는 어노테이션
@WebAppConfiguration // 웹 애플리케이션 전용 DI 컨테이너로 처리
@ContextConfiguration(locations = "file:src/main/webapp/WEB-INF/spring/appServlet/servlet-context.xml")
// Spring Bean 메타 설정 파일의 패키지에서 설정 파일을 찾습니다. (설정 파일의 위치를 지정할 때 사용함.)
public class RestStudyControllerTest {
	/*
	 * RestStudyController의 요청응답을 테스트할 컨트롤러 
	 */
	private static final Logger logger = LoggerFactory.getLogger(RestStudyControllerTest.class);

	// Autowired와 유사함.
	@Inject
	private WebApplicationContext wac;
	private MockMvc mMvc;

	// rest api 를 호출할 Controller 의존설정
	@Autowired
	private RestStudyController restStudyController; 
	
	
	// 테스트 시작 전 실행해야할 함수 
	@Before
	public void setup() {
		this.mMvc = MockMvcBuilders.webAppContextSetup(this.wac).build();
		logger.info("mMvc setup : '{}'",this.mMvc);
	}

	// rest api 호출 URI 테스트(Get AllUser 모든 유저 조회하기)
//	@Test
	public void restGetTest() throws Exception {

		/*
		 해당 에러 : https://stackoverflow.com/questions/57305684/java-lang-assertionerror-response-content 로 해결
		mMvc.perform(get("/users"))
		.andExpect(status().isOk())
		.andExpect(content().string("{\"responseCode\":200, \"responseMsg\":\"success\"}"))
		.andDo(print());
		 */
		
		/*
			응답이 내부적으로 JSON형태로 변환되니 케이스를 string()대신 json()을 사용해야 한다.
			ObjectMapper를 사용해 객체 목록을 JSON 형태로 변환한다. 
		*/
		ObjectMapper mapper = new ObjectMapper();
		String result = mapper.writeValueAsString(restStudyController.getAllUsers());

		logger.info("result : '{}' ", result);

		mMvc.perform(MockMvcRequestBuilders
				.get("/users")
				.accept(MediaType.APPLICATION_JSON))
		.andExpect(status().isOk())
		.andExpect(content().json(result))
		.andDo(print());

	}
	
	// 특정 유저 조회 (아이디) get URI : /users/{userid}
//	@Test
	public void restGetUserIdTest() throws Exception{
		ObjectMapper mapper = new ObjectMapper();
		String result = mapper.writeValueAsString(restStudyController.getUserByUserId("testid1")); // testid1 인 유저의 정보를 가져온다. 
		
		logger.info("result : '{}' ", result);
		
		mMvc.perform(MockMvcRequestBuilders.get("/users/testid1").accept(MediaType.APPLICATION_JSON))
		.andExpect(status().isOk())
		.andExpect(content().json(result))
		.andDo(print());
		
	}
	
	// 유저 등록
//	@Test
	public void registerPostTest() throws Exception{
		ObjectMapper mapper = new ObjectMapper();
		
		User user = new User(6, "testName6", "testid6", "1234");
		mMvc.perform(post("/users")
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(user)))
		.andExpect(status().isOk())
		.andDo(print());
	}
	
	// 유저 정보 수정
//	@Test
	public void modifyPutTest() throws Exception{
		ObjectMapper mapper = new ObjectMapper();
		User user = new User(5, "tttttest","testid5","1234");
		
		mMvc.perform(put("/users/testid5")
				.contentType(MediaType.APPLICATION_JSON)
				.content(mapper.writeValueAsString(user)))
		.andExpect(status().isOk())
		.andDo(print());
	}
	
	// 유저 삭제 
	@Test
	public void deleteTest() throws Exception{
//		mMvc.perform(MockMvcRequestBuilders.delete("/users/testid1")) // true
		mMvc.perform(MockMvcRequestBuilders.delete("/users/testid7")) // false
		.andExpect(status().isOk())
		.andDo(print());
		
	}

}

 

 

많이 부족한 상태로 

 

저는 선 코드 작성 후 테스트 코드를 작성해봤습니다. 근데 다시 생각해보면 테스트 진행이 먼저가 아니라 코드 구현이 먼저였어서 제가 원하는 TDD와는 다르다는것을 깨달았습니다 ㅎㅎ

 

이번 포스팅을 통해 많이 알게 된 부분들이 있습니다. 앞으로의 TDD 실습은 내용들을 보완한 후 깔끔해진 상태로 포스팅 하겠습니다 

반응형

댓글